feat: 添加权限控制相关功能,重构菜单与页面访问逻辑
1. 新增基于权限的菜单过滤和页面路由控制 2. 重构usePageData钩子支持按需加载 3. 调整个人中心页面布局与密码修改功能 4. 优化表格列展示与顶部用户菜单样式
This commit is contained in:
20
src/App.css
20
src/App.css
@@ -210,6 +210,22 @@
|
||||
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 {
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -272,6 +288,10 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-grid .section-card:first-child {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
225
src/App.tsx
225
src/App.tsx
@@ -4,6 +4,7 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Form,
|
||||
Input,
|
||||
Layout,
|
||||
@@ -67,6 +68,7 @@ type AdminProfile = {
|
||||
status: string;
|
||||
isSuperAdmin: boolean;
|
||||
roles: Role[];
|
||||
permissions: Permission[] | ['*'];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -151,7 +153,7 @@ async function fetchPage<T>(url: string): Promise<PageData<T>> {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
function usePageData<T>(url: string) {
|
||||
function usePageData<T>(url: string, enabled = true) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<PageData<T>>({
|
||||
list: [],
|
||||
@@ -159,6 +161,9 @@ function usePageData<T>(url: string) {
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchPage<T>(url));
|
||||
@@ -168,8 +173,10 @@ function usePageData<T>(url: string) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
void load();
|
||||
}, [url]);
|
||||
}
|
||||
}, [url, enabled]);
|
||||
|
||||
return { data, loading, reload: load };
|
||||
}
|
||||
@@ -244,6 +251,7 @@ type MenuConfig = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
requiredPermissions?: string[];
|
||||
children?: MenuConfig[];
|
||||
};
|
||||
|
||||
@@ -251,25 +259,56 @@ const SYSTEM_MENU_KEY = '/system';
|
||||
|
||||
const menuItems: MenuConfig[] = [
|
||||
{ key: '/dashboard', label: '工作台', icon: <IconDashboard /> },
|
||||
{ key: '/dramas', label: '短剧管理', icon: <IconVideoCamera /> },
|
||||
{ key: '/users', label: '用户管理', icon: <IconUser /> },
|
||||
{ key: '/dramas', label: '短剧管理', icon: <IconVideoCamera />, requiredPermissions: ['drama:read'] },
|
||||
{ key: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] },
|
||||
{
|
||||
key: SYSTEM_MENU_KEY,
|
||||
label: '系统管理',
|
||||
icon: <IconSettings />,
|
||||
children: [
|
||||
{ key: '/permissions', label: '权限管理', icon: <IconSafe /> },
|
||||
{ key: '/roles', label: '角色管理', icon: <IconApps /> },
|
||||
{ key: '/personnel', label: '人员管理', icon: <IconUser /> },
|
||||
{ key: '/permissions', label: '权限管理', icon: <IconSafe />, requiredPermissions: ['permission:read'] },
|
||||
{ key: '/roles', label: '角色管理', icon: <IconApps />, requiredPermissions: ['role:read'] },
|
||||
{ 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) {
|
||||
if (pathname.startsWith('/profile')) {
|
||||
return { item: { key: '/profile', label: '个人中心' } };
|
||||
function getPermissionCodes(profile: AdminProfile | null) {
|
||||
if (!profile) {
|
||||
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) {
|
||||
if (item.children) {
|
||||
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>;
|
||||
}
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
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 visibleMenuItems = useMemo(() => filterMenuItems(menuItems, profile), [profile]);
|
||||
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -326,26 +390,6 @@ function AdminLayout() {
|
||||
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 (
|
||||
<Layout className="layout">
|
||||
<Sider className="sidebar" width={220} collapsed={collapsed} collapsible trigger={null}>
|
||||
@@ -359,7 +403,7 @@ function AdminLayout() {
|
||||
onClickMenuItem={(key) => navigate(key)}
|
||||
onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)}
|
||||
>
|
||||
{menuItems.map((item) => renderMenuItem(item))}
|
||||
{visibleMenuItems.map((item) => renderMenuItem(item))}
|
||||
</Menu>
|
||||
</Sider>
|
||||
<Layout>
|
||||
@@ -371,62 +415,63 @@ function AdminLayout() {
|
||||
<span>后端 API: {API_BASE_URL}</span>
|
||||
</div>
|
||||
</Space>
|
||||
<Space>
|
||||
<Dropdown
|
||||
position="br"
|
||||
trigger="click"
|
||||
droplist={(
|
||||
<Menu>
|
||||
<Menu.Item key="profile" onClick={() => navigate('/profile')}>个人中心</Menu.Item>
|
||||
<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 onClick={() => navigate('/profile')}>个人中心</Button>
|
||||
<Button onClick={() => setPasswordVisible(true)}>修改密码</Button>
|
||||
<Button onClick={logout}>退出登录</Button>
|
||||
</Space>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content className="content">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/dramas" element={<DramasPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/roles" element={<RolesPage />} />
|
||||
<Route path="/personnel" element={<PersonnelPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
|
||||
<Route path="/dramas" element={<PermissionRoute profile={profile} requiredPermissions={['drama:read']}><DramasPage /></PermissionRoute>} />
|
||||
<Route path="/users" element={<PermissionRoute profile={profile} requiredPermissions={['user:read']}><UsersPage /></PermissionRoute>} />
|
||||
<Route path="/permissions" element={<PermissionRoute profile={profile} requiredPermissions={['permission:read']}><PermissionsPage /></PermissionRoute>} />
|
||||
<Route path="/roles" element={<PermissionRoute profile={profile} requiredPermissions={['role:read']}><RolesPage /></PermissionRoute>} />
|
||||
<Route path="/personnel" element={<PermissionRoute profile={profile} requiredPermissions={['admin-user:read']}><PersonnelPage /></PermissionRoute>} />
|
||||
<Route path="/profile" element={<PermissionRoute profile={profile}><ProfilePage /></PermissionRoute>} />
|
||||
<Route path="/logs" element={<PermissionRoute profile={profile} requiredPermissions={['log:read']}><LogsPage /></PermissionRoute>} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const dramas = usePageData<Drama>('/api/admin/v1/dramas');
|
||||
const users = usePageData<AppUser>('/api/admin/v1/users');
|
||||
const logs = usePageData<RequestLog>('/api/admin/v1/logs/requests');
|
||||
function DashboardPage({ profile }: { profile: AdminProfile | null }) {
|
||||
const canReadDramas = hasPermissions(profile, ['drama:read']);
|
||||
const canReadUsers = hasPermissions(profile, ['user:read']);
|
||||
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 (
|
||||
<>
|
||||
<div className="page-grid">
|
||||
<MetricCard label="短剧管理" value={dramas.data.pagination.total} />
|
||||
<MetricCard label="用户管理" value={users.data.pagination.total} />
|
||||
<MetricCard label="请求日志" value={logs.data.pagination.total} />
|
||||
{canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} />}
|
||||
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} />}
|
||||
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} />}
|
||||
<MetricCard label="接口状态" value="运行中" />
|
||||
</div>
|
||||
{canReadLogs ? (
|
||||
<Card className="section-card" title="最近请求">
|
||||
<RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} />
|
||||
</Card>
|
||||
) : null}
|
||||
{!hasAnyDataModule ? <NoAccessPage /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -497,7 +542,7 @@ function UsersPage() {
|
||||
|
||||
function PermissionsPage() {
|
||||
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 = {
|
||||
@@ -553,8 +598,7 @@ function getPermissionAction(code: string) {
|
||||
}
|
||||
|
||||
function getPermissionTitle(permission: Permission, module?: PermissionModuleConfig) {
|
||||
const action = actionNames[getPermissionAction(permission.code)] || permission.name;
|
||||
return module ? `${action}${module.subject}` : permission.name;
|
||||
return permission.name || (module ? `${actionNames[getPermissionAction(permission.code)]}${module.subject}` : permission.code);
|
||||
}
|
||||
|
||||
function buildPermissionModuleNode(
|
||||
@@ -723,7 +767,7 @@ function RolesPage() {
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '编码', dataIndex: 'code' },
|
||||
{ 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> },
|
||||
]} />
|
||||
<Modal title="新增角色" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null}>
|
||||
@@ -908,9 +952,12 @@ function PersonnelPage() {
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [profile, setProfile] = useState<AdminProfile | null>(null);
|
||||
|
||||
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 (
|
||||
<div className="profile-grid">
|
||||
<Card className="section-card" title="个人中心" loading={loading}>
|
||||
@@ -970,6 +1037,20 @@ function ProfilePage() {
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存修改</Button>
|
||||
</Form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user