feat: 添加权限控制相关功能,重构菜单与页面访问逻辑

1. 新增基于权限的菜单过滤和页面路由控制
2. 重构usePageData钩子支持按需加载
3. 调整个人中心页面布局与密码修改功能
4. 优化表格列展示与顶部用户菜单样式
This commit is contained in:
2026-07-01 13:56:20 +08:00
parent 7da5df2419
commit 6722752d5e
2 changed files with 179 additions and 78 deletions

View File

@@ -210,6 +210,22 @@
font-size: 12px; font-size: 12px;
} }
.user-menu-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #1d2129;
cursor: pointer;
}
.user-menu-trigger:hover {
background: #f2f3f5;
}
.content { .content {
padding: 20px; padding: 20px;
} }
@@ -272,6 +288,10 @@
align-items: start; align-items: start;
} }
.profile-grid .section-card:first-child {
grid-row: span 2;
}
.profile-summary { .profile-summary {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -4,6 +4,7 @@ import {
Avatar, Avatar,
Button, Button,
Card, Card,
Dropdown,
Form, Form,
Input, Input,
Layout, Layout,
@@ -67,6 +68,7 @@ type AdminProfile = {
status: string; status: string;
isSuperAdmin: boolean; isSuperAdmin: boolean;
roles: Role[]; roles: Role[];
permissions: Permission[] | ['*'];
createdAt: string; createdAt: string;
}; };
@@ -151,7 +153,7 @@ async function fetchPage<T>(url: string): Promise<PageData<T>> {
return response.data.data; return response.data.data;
} }
function usePageData<T>(url: string) { function usePageData<T>(url: string, enabled = true) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<PageData<T>>({ const [data, setData] = useState<PageData<T>>({
list: [], list: [],
@@ -159,6 +161,9 @@ function usePageData<T>(url: string) {
}); });
const load = async () => { const load = async () => {
if (!enabled) {
return;
}
setLoading(true); setLoading(true);
try { try {
setData(await fetchPage<T>(url)); setData(await fetchPage<T>(url));
@@ -168,8 +173,10 @@ function usePageData<T>(url: string) {
}; };
useEffect(() => { useEffect(() => {
void load(); if (enabled) {
}, [url]); void load();
}
}, [url, enabled]);
return { data, loading, reload: load }; return { data, loading, reload: load };
} }
@@ -244,6 +251,7 @@ type MenuConfig = {
key: string; key: string;
label: string; label: string;
icon?: ReactNode; icon?: ReactNode;
requiredPermissions?: string[];
children?: MenuConfig[]; children?: MenuConfig[];
}; };
@@ -251,25 +259,56 @@ const SYSTEM_MENU_KEY = '/system';
const menuItems: MenuConfig[] = [ const menuItems: MenuConfig[] = [
{ key: '/dashboard', label: '工作台', icon: <IconDashboard /> }, { key: '/dashboard', label: '工作台', icon: <IconDashboard /> },
{ key: '/dramas', label: '短剧管理', icon: <IconVideoCamera /> }, { key: '/dramas', label: '短剧管理', icon: <IconVideoCamera />, requiredPermissions: ['drama:read'] },
{ key: '/users', label: '用户管理', icon: <IconUser /> }, { key: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] },
{ {
key: SYSTEM_MENU_KEY, key: SYSTEM_MENU_KEY,
label: '系统管理', label: '系统管理',
icon: <IconSettings />, icon: <IconSettings />,
children: [ children: [
{ key: '/permissions', label: '权限管理', icon: <IconSafe /> }, { key: '/permissions', label: '权限管理', icon: <IconSafe />, requiredPermissions: ['permission:read'] },
{ key: '/roles', label: '角色管理', icon: <IconApps /> }, { key: '/roles', label: '角色管理', icon: <IconApps />, requiredPermissions: ['role:read'] },
{ key: '/personnel', label: '人员管理', icon: <IconUser /> }, { key: '/personnel', label: '人员管理', icon: <IconUser />, requiredPermissions: ['admin-user:read'] },
{ key: '/profile', label: '个人中心', icon: <IconUser /> },
{ key: '/logs', label: '日志管理', icon: <IconFile />, requiredPermissions: ['log:read'] },
], ],
}, },
{ key: '/logs', label: '日志管理', icon: <IconFile /> },
]; ];
function findCurrentMenu(pathname: string) { function getPermissionCodes(profile: AdminProfile | null) {
if (pathname.startsWith('/profile')) { if (!profile) {
return { item: { key: '/profile', label: '个人中心' } }; return [];
} }
if (profile.isSuperAdmin || profile.permissions[0] === '*') {
return ['*'];
}
return (profile.permissions as Permission[]).map((permission) => permission.code);
}
function hasPermissions(profile: AdminProfile | null, requiredPermissions?: string[]) {
if (!requiredPermissions?.length) {
return true;
}
const codes = getPermissionCodes(profile);
if (codes.includes('*')) {
return true;
}
return requiredPermissions.every((permission) => codes.includes(permission));
}
function filterMenuItems(items: MenuConfig[], profile: AdminProfile | null): MenuConfig[] {
return items
.map((item) => {
if (item.children) {
const children = filterMenuItems(item.children, profile);
return children.length ? { ...item, children } : null;
}
return hasPermissions(profile, item.requiredPermissions) ? item : null;
})
.filter((item): item is MenuConfig => Boolean(item));
}
function findCurrentMenu(pathname: string) {
for (const item of menuItems) { for (const item of menuItems) {
if (item.children) { if (item.children) {
const child = item.children.find((childItem) => pathname.startsWith(childItem.key)); const child = item.children.find((childItem) => pathname.startsWith(childItem.key));
@@ -296,14 +335,39 @@ function renderMenuItem(item: MenuConfig) {
return <Menu.Item key={item.key}>{item.icon}{item.label}</Menu.Item>; return <Menu.Item key={item.key}>{item.icon}{item.label}</Menu.Item>;
} }
function NoAccessPage() {
return (
<Card className="section-card" title="暂无权限">
<Text type="secondary">访</Text>
</Card>
);
}
function PermissionRoute({
profile,
requiredPermissions,
children,
}: {
profile: AdminProfile | null;
requiredPermissions?: string[];
children: ReactElement;
}) {
if (!profile) {
return <Card className="section-card" loading />;
}
if (!hasPermissions(profile, requiredPermissions)) {
return <NoAccessPage />;
}
return children;
}
function AdminLayout() { function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [profile, setProfile] = useState<AdminProfile | null>(null); const [profile, setProfile] = useState<AdminProfile | null>(null);
const [passwordVisible, setPasswordVisible] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const current = useMemo(() => findCurrentMenu(location.pathname), [location.pathname]); const current = useMemo(() => findCurrentMenu(location.pathname), [location.pathname]);
const visibleMenuItems = useMemo(() => filterMenuItems(menuItems, profile), [profile]);
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []); const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
useEffect(() => { useEffect(() => {
@@ -326,26 +390,6 @@ function AdminLayout() {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
}; };
const changePassword = async (values: { oldPassword: string; newPassword: string; confirmPassword: string }) => {
if (values.newPassword !== values.confirmPassword) {
Message.error('两次输入的新密码不一致');
return;
}
setChangingPassword(true);
try {
await api.patch('/api/admin/v1/auth/password', {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
Message.success('密码修改成功,请重新登录');
setPasswordVisible(false);
localStorage.removeItem(TOKEN_KEY);
navigate('/login', { replace: true });
} finally {
setChangingPassword(false);
}
};
return ( return (
<Layout className="layout"> <Layout className="layout">
<Sider className="sidebar" width={220} collapsed={collapsed} collapsible trigger={null}> <Sider className="sidebar" width={220} collapsed={collapsed} collapsible trigger={null}>
@@ -359,7 +403,7 @@ function AdminLayout() {
onClickMenuItem={(key) => navigate(key)} onClickMenuItem={(key) => navigate(key)}
onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)} onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)}
> >
{menuItems.map((item) => renderMenuItem(item))} {visibleMenuItems.map((item) => renderMenuItem(item))}
</Menu> </Menu>
</Sider> </Sider>
<Layout> <Layout>
@@ -371,62 +415,63 @@ function AdminLayout() {
<span> API {API_BASE_URL}</span> <span> API {API_BASE_URL}</span>
</div> </div>
</Space> </Space>
<Space> <Dropdown
<Avatar size={28}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar> position="br"
<Text>{profile?.nickname || profile?.username || '管理员'}</Text> trigger="click"
<Button onClick={() => navigate('/profile')}></Button> droplist={(
<Button onClick={() => setPasswordVisible(true)}></Button> <Menu>
<Button onClick={logout}>退</Button> <Menu.Item key="profile" onClick={() => navigate('/profile')}></Menu.Item>
</Space> <Menu.Item key="logout" onClick={logout}>退</Menu.Item>
</Menu>
)}
>
<button className="user-menu-trigger" type="button">
<Avatar size={28}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar>
<Text>{profile?.nickname || profile?.username || '管理员'}</Text>
</button>
</Dropdown>
</Header> </Header>
<Content className="content"> <Content className="content">
<Routes> <Routes>
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage profile={profile} />} />
<Route path="/dramas" element={<DramasPage />} /> <Route path="/dramas" element={<PermissionRoute profile={profile} requiredPermissions={['drama:read']}><DramasPage /></PermissionRoute>} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<PermissionRoute profile={profile} requiredPermissions={['user:read']}><UsersPage /></PermissionRoute>} />
<Route path="/permissions" element={<PermissionsPage />} /> <Route path="/permissions" element={<PermissionRoute profile={profile} requiredPermissions={['permission:read']}><PermissionsPage /></PermissionRoute>} />
<Route path="/roles" element={<RolesPage />} /> <Route path="/roles" element={<PermissionRoute profile={profile} requiredPermissions={['role:read']}><RolesPage /></PermissionRoute>} />
<Route path="/personnel" element={<PersonnelPage />} /> <Route path="/personnel" element={<PermissionRoute profile={profile} requiredPermissions={['admin-user:read']}><PersonnelPage /></PermissionRoute>} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<PermissionRoute profile={profile}><ProfilePage /></PermissionRoute>} />
<Route path="/logs" element={<LogsPage />} /> <Route path="/logs" element={<PermissionRoute profile={profile} requiredPermissions={['log:read']}><LogsPage /></PermissionRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</Content> </Content>
</Layout> </Layout>
<Modal title="修改密码" visible={passwordVisible} onCancel={() => setPasswordVisible(false)} footer={null}>
<Form layout="vertical" onSubmit={changePassword}>
<Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item label="新密码" field="newPassword" rules={[{ required: true }, { minLength: 6, message: '新密码至少 6 位' }]}>
<Input.Password />
</Form.Item>
<Form.Item label="确认新密码" field="confirmPassword" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Button type="primary" htmlType="submit" loading={changingPassword}></Button>
</Form>
</Modal>
</Layout> </Layout>
); );
} }
function DashboardPage() { function DashboardPage({ profile }: { profile: AdminProfile | null }) {
const dramas = usePageData<Drama>('/api/admin/v1/dramas'); const canReadDramas = hasPermissions(profile, ['drama:read']);
const users = usePageData<AppUser>('/api/admin/v1/users'); const canReadUsers = hasPermissions(profile, ['user:read']);
const logs = usePageData<RequestLog>('/api/admin/v1/logs/requests'); const canReadLogs = hasPermissions(profile, ['log:read']);
const dramas = usePageData<Drama>('/api/admin/v1/dramas', canReadDramas);
const users = usePageData<AppUser>('/api/admin/v1/users', canReadUsers);
const logs = usePageData<RequestLog>('/api/admin/v1/logs/requests', canReadLogs);
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
return ( return (
<> <>
<div className="page-grid"> <div className="page-grid">
<MetricCard label="短剧管理" value={dramas.data.pagination.total} /> {canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} />}
<MetricCard label="用户管理" value={users.data.pagination.total} /> {canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} />}
<MetricCard label="请求日志" value={logs.data.pagination.total} /> {canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} />}
<MetricCard label="接口状态" value="运行中" /> <MetricCard label="接口状态" value="运行中" />
</div> </div>
<Card className="section-card" title="最近请求"> {canReadLogs ? (
<RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} /> <Card className="section-card" title="最近请求">
</Card> <RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} />
</Card>
) : null}
{!hasAnyDataModule ? <NoAccessPage /> : null}
</> </>
); );
} }
@@ -497,7 +542,7 @@ function UsersPage() {
function PermissionsPage() { function PermissionsPage() {
const { data, loading } = usePageData<Permission>('/api/admin/v1/permissions'); const { data, loading } = usePageData<Permission>('/api/admin/v1/permissions');
return <Card className="section-card" title="权限管理"><Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[{ title: 'ID', dataIndex: 'id', width: 80 }, { title: '编码', dataIndex: 'code' }, { title: '名称', dataIndex: 'name' }, { title: '描述', dataIndex: 'description' }]} /></Card>; return <Card className="section-card" title="权限管理"><Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[{ title: 'ID', dataIndex: 'id', width: 80 }, { title: '权限名称', dataIndex: 'name' }, { title: '描述', dataIndex: 'description' }]} /></Card>;
} }
type PermissionTreeNode = { type PermissionTreeNode = {
@@ -553,8 +598,7 @@ function getPermissionAction(code: string) {
} }
function getPermissionTitle(permission: Permission, module?: PermissionModuleConfig) { function getPermissionTitle(permission: Permission, module?: PermissionModuleConfig) {
const action = actionNames[getPermissionAction(permission.code)] || permission.name; return permission.name || (module ? `${actionNames[getPermissionAction(permission.code)]}${module.subject}` : permission.code);
return module ? `${action}${module.subject}` : permission.name;
} }
function buildPermissionModuleNode( function buildPermissionModuleNode(
@@ -723,7 +767,7 @@ function RolesPage() {
{ title: 'ID', dataIndex: 'id', width: 80 }, { title: 'ID', dataIndex: 'id', width: 80 },
{ title: '编码', dataIndex: 'code' }, { title: '编码', dataIndex: 'code' },
{ title: '名称', dataIndex: 'name' }, { title: '名称', dataIndex: 'name' },
{ title: '权限列表', render: (_, record: Role) => <Space wrap>{record.permissions.map((item) => <Tag key={item.id}>{item.code}</Tag>)}</Space> }, { title: '权限列表', render: (_, record: Role) => <Space wrap>{record.permissions.map((item) => <Tag key={item.id}>{item.name}</Tag>)}</Space> },
{ title: '操作', width: 140, render: (_, record: Role) => <Button size="small" onClick={() => openAssignPermissions(record)}></Button> }, { title: '操作', width: 140, render: (_, record: Role) => <Button size="small" onClick={() => openAssignPermissions(record)}></Button> },
]} /> ]} />
<Modal title="新增角色" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null}> <Modal title="新增角色" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null}>
@@ -908,9 +952,12 @@ function PersonnelPage() {
} }
function ProfilePage() { function ProfilePage() {
const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [profile, setProfile] = useState<AdminProfile | null>(null); const [profile, setProfile] = useState<AdminProfile | null>(null);
const loadProfile = async () => { const loadProfile = async () => {
@@ -942,6 +989,26 @@ function ProfilePage() {
} }
}; };
const changePassword = async (values: { oldPassword: string; newPassword: string; confirmPassword: string }) => {
if (values.newPassword !== values.confirmPassword) {
Message.error('两次输入的新密码不一致');
return;
}
setChangingPassword(true);
try {
await api.patch('/api/admin/v1/auth/password', {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
Message.success('密码修改成功,请重新登录');
passwordForm.resetFields();
localStorage.removeItem(TOKEN_KEY);
navigate('/login', { replace: true });
} finally {
setChangingPassword(false);
}
};
return ( return (
<div className="profile-grid"> <div className="profile-grid">
<Card className="section-card" title="个人中心" loading={loading}> <Card className="section-card" title="个人中心" loading={loading}>
@@ -970,6 +1037,20 @@ function ProfilePage() {
<Button type="primary" htmlType="submit" loading={saving}></Button> <Button type="primary" htmlType="submit" loading={saving}></Button>
</Form> </Form>
</Card> </Card>
<Card className="section-card" title="修改密码">
<Form form={passwordForm} layout="vertical" onSubmit={changePassword}>
<Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}>
<Input.Password placeholder="请输入旧密码" />
</Form.Item>
<Form.Item label="新密码" field="newPassword" rules={[{ required: true }, { minLength: 6, message: '新密码至少 6 位' }]}>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item label="确认新密码" field="confirmPassword" rules={[{ required: true }]}>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={changingPassword}></Button>
</Form>
</Card>
</div> </div>
); );
} }