diff --git a/docs/ui/README.md b/docs/ui/README.md index 72fdf45..f951124 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -19,10 +19,10 @@ docs/ui/ │ ├── usage/ # 用量统计 │ ├── usage-detail/ # 用量明细 │ └── quota/ # 限流与配额 -└── system/ # 系统管理(待补图) - ├── user/ - ├── role/ - └── permission/ +└── system/ # 系统管理 + ├── user/ # 用户管理(列表 / 表单 / 重置密码) + ├── role/ # 角色管理(含权限配置抽屉) + └── permission/ # 权限配置(菜单 / 按钮) ``` --- @@ -43,10 +43,12 @@ docs/ui/ | ├ 用量统计 | `/llm/usage` | [llm/usage/chatone-llm-usage-ui.png](./llm/usage/chatone-llm-usage-ui.png) | | ├ 用量明细 | `/llm/usage/details` | [llm/usage-detail/chatone-llm-usage-detail-ui.png](./llm/usage-detail/chatone-llm-usage-detail-ui.png) | | └ 限流配额 | `/llm/quota` | [llm/quota/chatone-llm-quota-ui.png](./llm/quota/chatone-llm-quota-ui.png) | -| **系统管理** | `/system` | 待补 | -| ├ 用户管理 | `/system/user` | `system/user/` | -| ├ 角色管理 | `/system/role` | `system/role/` | -| └ 权限配置 | `/system/permission` | `system/permission/` | +| **系统管理** | `/system` | — | +| ├ 用户管理 | `/system/user` | [system/user/chatone-system-user-list-ui.png](./system/user/chatone-system-user-list-ui.png) | +| │ ├ 新增/编辑 | (Modal) | [system/user/chatone-system-user-form-ui.png](./system/user/chatone-system-user-form-ui.png) | +| │ └ 重置密码 | (Modal) | [system/user/chatone-system-user-reset-pwd-ui.png](./system/user/chatone-system-user-reset-pwd-ui.png) | +| ├ 角色管理 | `/system/role` | [system/role/chatone-system-role-ui.png](./system/role/chatone-system-role-ui.png) | +| └ 权限配置 | `/system/permission` | [system/permission/chatone-system-permission-ui.png](./system/permission/chatone-system-permission-ui.png) | --- @@ -92,6 +94,33 @@ docs/ui/ --- +## 系统管理:功能清单 + +### 用户管理 `/system/user` + +- 新增 / 编辑 / 删除用户(含批量禁用) +- 启用 / 禁用账号(Switch) +- 筛选:关键词(用户名 / 邮箱 / 手机)、角色、状态 +- 绑定角色(多选) +- 重置密码(临时密码 + 强制下次修改) +- 展示最近登录时间、创建时间 + +### 角色管理 `/system/role` + +- 新增 / 编辑 / 删除角色 +- 角色编码(如 `ROLE_ADMIN`)、描述、启停 +- 查看关联用户数 +- 抽屉配置:菜单树权限 + 按钮权限码 + +### 权限配置 `/system/permission` + +- **菜单权限**:树形维护目录 / 菜单 / 按钮;路由、图标、排序、显示开关、权限标识 +- **按钮权限**:权限名称、权限码(如 `user:create`)、所属菜单;供前端 `` 使用 + +权限模型:RBAC(用户 → 角色 → 菜单/按钮权限)。 + +--- + ## 建议的菜单 Mock 结构 ```ts @@ -106,5 +135,15 @@ docs/ui/ { name: '用量明细', path: '/llm/usage/details' }, { name: '限流配额', path: '/llm/quota' }, ], +}, +{ + name: '系统管理', + path: '/system', + icon: 'SettingOutlined', + children: [ + { name: '用户管理', path: '/system/user' }, + { name: '角色管理', path: '/system/role' }, + { name: '权限配置', path: '/system/permission' }, + ], } ``` diff --git a/docs/ui/system/permission/chatone-system-permission-ui.png b/docs/ui/system/permission/chatone-system-permission-ui.png new file mode 100644 index 0000000..ca29859 Binary files /dev/null and b/docs/ui/system/permission/chatone-system-permission-ui.png differ diff --git a/docs/ui/system/role/chatone-system-role-ui.png b/docs/ui/system/role/chatone-system-role-ui.png new file mode 100644 index 0000000..19b60d9 Binary files /dev/null and b/docs/ui/system/role/chatone-system-role-ui.png differ diff --git a/docs/ui/system/user/chatone-system-user-form-ui.png b/docs/ui/system/user/chatone-system-user-form-ui.png new file mode 100644 index 0000000..2c2fd18 Binary files /dev/null and b/docs/ui/system/user/chatone-system-user-form-ui.png differ diff --git a/docs/ui/system/user/chatone-system-user-list-ui.png b/docs/ui/system/user/chatone-system-user-list-ui.png new file mode 100644 index 0000000..e8e150f Binary files /dev/null and b/docs/ui/system/user/chatone-system-user-list-ui.png differ diff --git a/docs/ui/system/user/chatone-system-user-reset-pwd-ui.png b/docs/ui/system/user/chatone-system-user-reset-pwd-ui.png new file mode 100644 index 0000000..54fb6b0 Binary files /dev/null and b/docs/ui/system/user/chatone-system-user-reset-pwd-ui.png differ diff --git a/e2e/README.md b/e2e/README.md index cfd87ce..43cb67d 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -17,8 +17,8 @@ pnpm test:e2e:update # 确认 UI 改动后更新截图基线 - `visual/`:页面截图回归。 - `snapshots/`:由 `test:e2e:update` 生成的基线图。 -模型列表、供应商管理的功能和视觉用例已启用。确认页面视觉改动符合预期后,执行 +模型列表、供应商管理、用户管理、角色管理、权限配置的功能和视觉用例已启用。确认页面视觉改动符合预期后,执行 `pnpm test:e2e:update` 更新基线。 -设计目标图位于 `docs/ui/llm/list/`;截图基线用于检测后续代码造成的 +设计目标图位于 `docs/ui/`;截图基线用于检测后续代码造成的 视觉变化,不替代设计稿还原度审查。 diff --git a/e2e/pages/system-permission.spec.ts b/e2e/pages/system-permission.spec.ts new file mode 100644 index 0000000..1cbd085 --- /dev/null +++ b/e2e/pages/system-permission.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '../fixtures/auth'; + +test.describe('系统管理 / 权限配置', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/system/permission'); + await page.getByText('菜单树', { exact: true }).waitFor(); + }); + + test('展示菜单权限与详情', async ({ page }) => { + await expect(page.getByRole('tab', { name: '菜单权限' })).toBeVisible(); + await expect(page.getByText('菜单树', { exact: true })).toBeVisible(); + await expect(page.getByText('菜单详情', { exact: true })).toBeVisible(); + await expect(page.getByLabel('菜单名称')).toHaveValue('用户管理'); + await expect(page.getByLabel('权限标识')).toHaveValue('system:user:view'); + }); + + test('可保存菜单详情', async ({ page }) => { + await page.getByLabel('菜单名称').fill('用户中心'); + await page.getByRole('button', { name: '保 存' }).click(); + await expect(page.getByText('菜单已保存')).toBeVisible(); + await expect(page.getByRole('tree').getByText('用户中心')).toBeVisible(); + }); + + test('可新增同级菜单', async ({ page }) => { + await page.getByRole('button', { name: '+ 新增同级' }).click(); + await expect(page.getByText('已新增同级菜单')).toBeVisible(); + await expect(page.getByLabel('菜单名称')).toHaveValue('新菜单'); + }); + + test('可管理按钮权限', async ({ page }) => { + await page.getByRole('tab', { name: '按钮权限' }).click(); + const buttonPanel = page.getByRole('tabpanel', { name: '按钮权限' }); + await expect(buttonPanel.getByRole('button', { name: '新增按钮权限' })).toBeVisible(); + await expect(buttonPanel.getByText('system:user:add')).toBeVisible(); + + await buttonPanel.getByRole('button', { name: '新增按钮权限' }).click(); + const dialog = page.getByRole('dialog', { name: '新增按钮权限' }); + await dialog.getByLabel('权限名称').fill('导入'); + await dialog.getByLabel('权限码').fill('system:user:import'); + await dialog.getByRole('button', { name: '保 存' }).click(); + + await expect(buttonPanel.getByText('system:user:import')).toBeVisible(); + await expect(page.getByText('按钮权限已添加')).toBeVisible(); + }); +}); diff --git a/e2e/pages/system-roles.spec.ts b/e2e/pages/system-roles.spec.ts new file mode 100644 index 0000000..5fadba5 --- /dev/null +++ b/e2e/pages/system-roles.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '../fixtures/auth'; + +test.describe('系统管理 / 角色管理', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/system/role'); + await page.getByRole('table').waitFor(); + }); + + test('展示角色列表并支持筛选', async ({ page }) => { + await expect(page.getByRole('button', { name: '新增角色' })).toBeVisible(); + await expect(page.getByText('超级管理员', { exact: true })).toBeVisible(); + await expect(page.getByText('ROLE_ADMIN')).toBeVisible(); + + await page.getByPlaceholder('搜索角色名称').fill('只读'); + await expect(page.getByText('只读', { exact: true })).toBeVisible(); + await expect(page.getByText('超级管理员', { exact: true })).toHaveCount(0); + }); + + test('可新增角色', async ({ page }) => { + await page.getByRole('button', { name: '新增角色' }).click(); + const dialog = page.getByRole('dialog', { name: '新增角色' }); + + await dialog.getByLabel('角色名称').fill('审计'); + await dialog.getByLabel('角色编码').fill('ROLE_AUDIT'); + await dialog.getByLabel('描述').fill('审计只读权限'); + await dialog.getByRole('button', { name: '保 存' }).click(); + + await expect(page.getByText('审计', { exact: true })).toBeVisible(); + await expect(page.getByText('角色已添加')).toBeVisible(); + }); + + test('可切换状态并配置权限', async ({ page }) => { + const statusSwitch = page.getByRole('switch', { name: '运营状态' }); + await expect(statusSwitch).toBeChecked(); + await statusSwitch.click(); + await expect(statusSwitch).not.toBeChecked(); + + const row = page.getByRole('row').filter({ hasText: '运营' }); + await row.getByRole('button', { name: '权限配置' }).click(); + await expect(page.getByText('配置权限 - 运营')).toBeVisible(); + await expect(page.getByText('菜单权限')).toBeVisible(); + await expect(page.getByText('按钮权限')).toBeVisible(); + + await page.getByRole('button', { name: '保 存' }).click(); + await expect(page.getByText('权限已保存')).toBeVisible(); + }); + + test('可编辑并删除角色', async ({ page }) => { + const row = page.getByRole('row').filter({ hasText: '只读' }); + await row.getByRole('button', { name: '编辑' }).click(); + const dialog = page.getByRole('dialog', { name: '编辑角色' }); + await dialog.getByLabel('描述').fill('只读访问,不可修改'); + await dialog.getByRole('button', { name: '保 存' }).click(); + await expect(page.getByText('只读访问,不可修改')).toBeVisible(); + + await row.getByRole('button', { name: '删除' }).click(); + await page.getByRole('button', { name: '确 定' }).click(); + await expect(page.getByText('ROLE_VIEWER')).toHaveCount(0); + }); +}); diff --git a/e2e/pages/system-users.spec.ts b/e2e/pages/system-users.spec.ts new file mode 100644 index 0000000..46970e6 --- /dev/null +++ b/e2e/pages/system-users.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '../fixtures/auth'; + +test.describe('系统管理 / 用户管理', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/system/user'); + await page.getByRole('table').waitFor(); + }); + + test('展示用户列表并支持筛选', async ({ page }) => { + await expect(page.getByRole('button', { name: '新增用户' })).toBeVisible(); + await expect(page.getByText('admin', { exact: true })).toBeVisible(); + + await page.getByPlaceholder('搜索用户名 / 邮箱 / 手机号').fill('viewer'); + await expect(page.getByText('viewer', { exact: true })).toBeVisible(); + await expect(page.getByText('admin', { exact: true })).toHaveCount(0); + }); + + test('可新增用户', async ({ page }) => { + await page.getByRole('button', { name: '新增用户' }).click(); + const dialog = page.getByRole('dialog', { name: '新增用户' }); + + await dialog.getByLabel('用户名').fill('tester'); + await dialog.getByLabel('昵称').fill('测试用户'); + await dialog.getByLabel('邮箱').fill('tester@chatone.ai'); + await dialog.getByLabel('手机号').fill('13800000009'); + await dialog.getByLabel('初始密码').fill('Test123456'); + await dialog.getByRole('button', { name: '保 存' }).click(); + + await expect(page.getByText('tester', { exact: true })).toBeVisible(); + await expect(page.getByText('用户已添加')).toBeVisible(); + }); + + test('可切换状态并重置密码', async ({ page }) => { + const statusSwitch = page.getByRole('switch', { name: 'operator状态' }); + await expect(statusSwitch).toBeChecked(); + await statusSwitch.click(); + await expect(statusSwitch).not.toBeChecked(); + + const row = page.getByRole('row').filter({ hasText: 'operator' }); + await row.getByRole('button', { name: '重置密码' }).click(); + const dialog = page.getByRole('dialog', { name: '重置密码 - operator' }); + await dialog.getByLabel('新密码').fill('NewPass123'); + await dialog.getByLabel('确认密码').fill('NewPass123'); + await dialog.getByRole('button', { name: '确认重置' }).click(); + + await expect(page.getByText('密码已重置')).toBeVisible(); + }); + + test('可编辑并删除用户', async ({ page }) => { + const operatorRow = page.getByRole('row').filter({ hasText: 'operator' }); + await operatorRow.getByRole('button', { name: '编辑' }).click(); + const dialog = page.getByRole('dialog', { name: '编辑用户' }); + await dialog.getByLabel('昵称').fill('新运营'); + await dialog.getByRole('button', { name: '保 存' }).click(); + await expect(page.getByText('新运营', { exact: true })).toBeVisible(); + + const viewerRow = page.getByRole('row').filter({ hasText: 'viewer' }); + await viewerRow.getByRole('button', { name: '删除' }).click(); + await page.getByRole('button', { name: '确 定' }).click(); + await expect(page.getByText('viewer', { exact: true })).toHaveCount(0); + }); + + test('可批量禁用用户', async ({ page }) => { + const row = page.getByRole('row').filter({ hasText: 'operator' }); + await row.getByRole('checkbox').check(); + await page.getByRole('button', { name: '批量禁用' }).click(); + await page.getByRole('button', { name: '确 定' }).click(); + + await expect(page.getByRole('switch', { name: 'operator状态' })).not.toBeChecked(); + await expect(page.getByText('已禁用 1 个用户')).toBeVisible(); + }); +}); diff --git a/e2e/snapshots/chromium/visual/system-permission.spec.ts/system-permission.png b/e2e/snapshots/chromium/visual/system-permission.spec.ts/system-permission.png new file mode 100644 index 0000000..a871289 Binary files /dev/null and b/e2e/snapshots/chromium/visual/system-permission.spec.ts/system-permission.png differ diff --git a/e2e/snapshots/chromium/visual/system-roles.spec.ts/system-roles-list.png b/e2e/snapshots/chromium/visual/system-roles.spec.ts/system-roles-list.png new file mode 100644 index 0000000..9da6c53 Binary files /dev/null and b/e2e/snapshots/chromium/visual/system-roles.spec.ts/system-roles-list.png differ diff --git a/e2e/snapshots/chromium/visual/system-users.spec.ts/system-users-list.png b/e2e/snapshots/chromium/visual/system-users.spec.ts/system-users-list.png new file mode 100644 index 0000000..2060b5f Binary files /dev/null and b/e2e/snapshots/chromium/visual/system-users.spec.ts/system-users-list.png differ diff --git a/e2e/visual/system-permission.spec.ts b/e2e/visual/system-permission.spec.ts new file mode 100644 index 0000000..81b9275 --- /dev/null +++ b/e2e/visual/system-permission.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../fixtures/auth'; + +test('权限配置视觉基线', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/system/permission'); + await page.getByText('菜单树', { exact: true }).waitFor(); + await page.evaluate(() => document.fonts.ready); + + await expect(page).toHaveScreenshot('system-permission.png', { + fullPage: true, + }); +}); diff --git a/e2e/visual/system-roles.spec.ts b/e2e/visual/system-roles.spec.ts new file mode 100644 index 0000000..24a8364 --- /dev/null +++ b/e2e/visual/system-roles.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../fixtures/auth'; + +test('角色管理视觉基线', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/system/role'); + await page.getByRole('table').waitFor(); + await page.evaluate(() => document.fonts.ready); + + await expect(page).toHaveScreenshot('system-roles-list.png', { + fullPage: true, + }); +}); diff --git a/e2e/visual/system-users.spec.ts b/e2e/visual/system-users.spec.ts new file mode 100644 index 0000000..583f54d --- /dev/null +++ b/e2e/visual/system-users.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../fixtures/auth'; + +test('用户管理视觉基线', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/system/user'); + await page.getByRole('table').waitFor(); + await page.evaluate(() => document.fonts.ready); + + await expect(page).toHaveScreenshot('system-users-list.png', { + fullPage: true, + }); +}); diff --git a/src/api/menu.ts b/src/api/menu.ts index bc31485..71c82fa 100644 --- a/src/api/menu.ts +++ b/src/api/menu.ts @@ -71,6 +71,7 @@ const mockMenus: MenuItem[] = [ id: '3-3', name: '权限配置', path: '/system/permission', + icon: 'SafetyCertificateOutlined', }, ], }, diff --git a/src/pages/system/permission/index.tsx b/src/pages/system/permission/index.tsx new file mode 100644 index 0000000..8e875f4 --- /dev/null +++ b/src/pages/system/permission/index.tsx @@ -0,0 +1,491 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { ProCard } from '@ant-design/pro-components'; +import { + App, + Button, + Col, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Radio, + Row, + Select, + Space, + Switch, + Table, + Tabs, + Tree, + Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { DataNode } from 'antd/es/tree'; +import { useEffect, useMemo, useState } from 'react'; + +import { + iconOptions, + initialButtonPermissions, + initialMenuTree, + type ButtonPermissionItem, + type MenuNodeType, + type MenuPermissionNode, +} from './mock'; + +interface MenuFormValues { + title: string; + path: string; + icon: string; + order: number; + type: MenuNodeType; + visible: boolean; + permission: string; + remark?: string; +} + +interface ButtonFormValues { + name: string; + code: string; + menuKey: string; +} + +const findNode = (nodes: MenuPermissionNode[], key: string): MenuPermissionNode | undefined => { + for (const node of nodes) { + if (node.key === key) return node; + if (node.children) { + const found = findNode(node.children, key); + if (found) return found; + } + } + return undefined; +}; + +const findParent = ( + nodes: MenuPermissionNode[], + key: string, + parent?: MenuPermissionNode, +): MenuPermissionNode | undefined => { + for (const node of nodes) { + if (node.key === key) return parent; + if (node.children?.length) { + const found = findParent(node.children, key, node); + if (found !== undefined || node.children.some(child => child.key === key)) { + return found ?? node; + } + } + } + return undefined; +}; + +const updateNode = ( + nodes: MenuPermissionNode[], + key: string, + updater: (node: MenuPermissionNode) => MenuPermissionNode, +): MenuPermissionNode[] => + nodes.map(node => { + if (node.key === key) return updater(node); + if (!node.children) return node; + return { ...node, children: updateNode(node.children, key, updater) }; + }); + +const removeNode = (nodes: MenuPermissionNode[], key: string): MenuPermissionNode[] => + nodes + .filter(node => node.key !== key) + .map(node => (node.children ? { ...node, children: removeNode(node.children, key) } : node)); + +const toTreeData = (nodes: MenuPermissionNode[]): DataNode[] => + nodes.map(node => ({ + key: node.key, + title: node.title, + children: node.children ? toTreeData(node.children) : undefined, + })); + +const flattenMenus = (nodes: MenuPermissionNode[], parentTitle = ''): { key: string; label: string }[] => { + const result: { key: string; label: string }[] = []; + nodes.forEach(node => { + const label = parentTitle ? `${parentTitle} / ${node.title}` : node.title; + if (node.type === '菜单' || node.type === '目录') { + result.push({ key: node.key, label }); + } + if (node.children) result.push(...flattenMenus(node.children, label)); + }); + return result; +}; + +export default function PermissionManagement() { + const { message } = App.useApp(); + const [menuForm] = Form.useForm(); + const [buttonForm] = Form.useForm(); + const [menuTree, setMenuTree] = useState(initialMenuTree); + const [buttons, setButtons] = useState(initialButtonPermissions); + const [selectedKey, setSelectedKey] = useState('3-1'); + const [buttonOpen, setButtonOpen] = useState(false); + const [editingButton, setEditingButton] = useState(); + const [activeTab, setActiveTab] = useState('menu'); + + const selectedNode = useMemo(() => findNode(menuTree, selectedKey), [menuTree, selectedKey]); + const menuOptions = useMemo(() => flattenMenus(menuTree), [menuTree]); + + const relatedButtons = useMemo(() => buttons.filter(item => item.menuKey === selectedKey), [buttons, selectedKey]); + + const syncForm = (node?: MenuPermissionNode) => { + if (!node) return; + menuForm.setFieldsValue({ + title: node.title, + path: node.path, + icon: node.icon, + order: node.order, + type: node.type, + visible: node.visible, + permission: node.permission, + remark: node.remark ?? '', + }); + }; + + useEffect(() => { + syncForm(selectedNode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedNode]); + + const selectMenu = (key: string) => { + setSelectedKey(key); + }; + + const addSibling = () => { + if (!selectedNode) { + message.warning('请先选择菜单'); + return; + } + const parent = findParent(menuTree, selectedKey); + const newNode: MenuPermissionNode = { + key: `menu-${Date.now()}`, + title: '新菜单', + path: '/new', + icon: 'AppstoreOutlined', + order: 99, + type: '菜单', + visible: true, + permission: 'new:view', + }; + + if (!parent) { + setMenuTree(current => [...current, newNode]); + } else { + setMenuTree(current => + updateNode(current, parent.key, node => ({ + ...node, + children: [...(node.children ?? []), newNode], + })), + ); + } + setSelectedKey(newNode.key); + syncForm(newNode); + message.success('已新增同级菜单'); + }; + + const addChild = () => { + if (!selectedNode) { + message.warning('请先选择菜单'); + return; + } + const newNode: MenuPermissionNode = { + key: `menu-${Date.now()}`, + title: '新子菜单', + path: `${selectedNode.path}/child`, + icon: 'AppstoreOutlined', + order: (selectedNode.children?.length ?? 0) + 1, + type: '菜单', + visible: true, + permission: `${selectedNode.permission}:child`, + }; + setMenuTree(current => + updateNode(current, selectedKey, node => ({ + ...node, + type: node.type === '菜单' ? '目录' : node.type, + children: [...(node.children ?? []), newNode], + })), + ); + setSelectedKey(newNode.key); + syncForm(newNode); + message.success('已新增子级菜单'); + }; + + const deleteMenu = () => { + if (!selectedNode) return; + const nextTree = removeNode(menuTree, selectedKey); + setMenuTree(nextTree); + const fallback = nextTree[0]?.key ?? nextTree[0]?.children?.[0]?.key; + if (fallback) { + setSelectedKey(fallback); + syncForm(findNode(nextTree, fallback)); + } else { + setSelectedKey(''); + menuForm.resetFields(); + } + message.success('菜单已删除'); + }; + + const saveMenu = async () => { + if (!selectedKey) return; + const values = await menuForm.validateFields(); + setMenuTree(current => + updateNode(current, selectedKey, node => ({ + ...node, + ...values, + })), + ); + message.success('菜单已保存'); + }; + + const openCreateButton = () => { + setEditingButton(undefined); + buttonForm.setFieldsValue({ + name: '', + code: '', + menuKey: selectedKey || menuOptions[0]?.key, + }); + setButtonOpen(true); + }; + + const openEditButton = (record: ButtonPermissionItem) => { + setEditingButton(record); + buttonForm.setFieldsValue({ + name: record.name, + code: record.code, + menuKey: record.menuKey, + }); + setButtonOpen(true); + }; + + const saveButton = async () => { + const values = await buttonForm.validateFields(); + const menuLabel = menuOptions.find(item => item.key === values.menuKey)?.label ?? ''; + if (editingButton) { + setButtons(current => + current.map(item => + item.id === editingButton.id + ? { ...item, name: values.name, code: values.code, menuKey: values.menuKey, menuPath: menuLabel } + : item, + ), + ); + message.success('按钮权限已更新'); + } else { + setButtons(current => [ + ...current, + { + id: `btn-${Date.now()}`, + name: values.name, + code: values.code, + menuKey: values.menuKey, + menuPath: menuLabel, + }, + ]); + message.success('按钮权限已添加'); + } + setButtonOpen(false); + buttonForm.resetFields(); + }; + + const deleteButton = (record: ButtonPermissionItem) => { + setButtons(current => current.filter(item => item.id !== record.id)); + message.success('按钮权限已删除'); + }; + + const buttonColumns: ColumnsType = [ + { title: '权限名称', dataIndex: 'name', width: 120 }, + { + title: '权限码', + dataIndex: 'code', + width: 220, + render: (value: string) => {value}, + }, + { title: '所属菜单', dataIndex: 'menuPath', ellipsis: true }, + { + title: '操作', + key: 'actions', + width: 140, + render: (_, record) => ( + + + deleteButton(record)}> + + + + ), + }, + ]; + + const menuTab = ( + + + + + + + + + + } + > + { + if (keys[0]) selectMenu(String(keys[0])); + }} + /> + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + 切换到「按钮权限」页签可维护完整权限码列表;此处展示当前选中菜单关联的按钮权限。 + + + rowKey="id" + size="small" + columns={buttonColumns} + dataSource={relatedButtons} + pagination={false} + /> + + +
+ ); + + const buttonTab = ( + + + + 维护按钮级权限码,供前端 Access 与角色授权使用。 + + + + rowKey="id" + columns={buttonColumns} + dataSource={buttons} + pagination={{ pageSize: 10, showSizeChanger: true, showTotal: total => `共 ${total} 条` }} + /> + + + ); + + return ( + <> + + + 权限配置 + + + 维护菜单树与按钮权限码,支撑 RBAC 角色授权与前端按钮级控制。 + + + + + { + setButtonOpen(false); + buttonForm.resetFields(); + }} + > +
+ + + + + + + + } + placeholder="搜索角色名称" + style={{ width: 240 }} + value={keyword} + onChange={event => setKeyword(event.target.value)} + /> + + + + + + + + + + + +
+
+ + setPermissionRole(undefined)} + footer={ + + + + + } + > + +
+ + 菜单权限 + + { + const keys = Array.isArray(checked) ? checked : checked.checked; + setMenuKeys(keys.map(String)); + }} + /> +
+
+ 按钮权限 + setButtonKeys(values.map(String))} + style={{ display: 'flex', flexDirection: 'column', gap: 8 }} + /> +
+
+
+ + ); +} diff --git a/src/pages/system/role/mock.ts b/src/pages/system/role/mock.ts new file mode 100644 index 0000000..d6ff48a --- /dev/null +++ b/src/pages/system/role/mock.ts @@ -0,0 +1,106 @@ +export interface RoleItem { + id: string; + name: string; + code: string; + userCount: number; + enabled: boolean; + description: string; + updatedAt: string; + menuKeys: string[]; + buttonKeys: string[]; +} + +export interface MenuPermissionNode { + key: string; + title: string; + children?: MenuPermissionNode[]; +} + +export const menuPermissionTree: MenuPermissionNode[] = [ + { key: 'dashboard', title: '总览' }, + { + key: 'llm', + title: '大模型管理', + children: [ + { key: 'llm-models', title: '模型列表' }, + { key: 'llm-providers', title: '供应商管理' }, + { key: 'llm-usage', title: '用量统计' }, + { key: 'llm-usage-details', title: '用量明细' }, + { key: 'llm-quota', title: '限流配额' }, + ], + }, + { + key: 'system', + title: '系统管理', + children: [ + { key: 'system-user', title: '用户管理' }, + { key: 'system-role', title: '角色管理' }, + { key: 'system-permission', title: '权限配置' }, + ], + }, +]; + +export const buttonPermissionOptions = [ + { label: 'user:create', value: 'user:create' }, + { label: 'user:edit', value: 'user:edit' }, + { label: 'user:delete', value: 'user:delete' }, + { label: 'llm:create', value: 'llm:create' }, +]; + +export const initialRoles: RoleItem[] = [ + { + id: 'role-1', + name: '超级管理员', + code: 'ROLE_ADMIN', + userCount: 3, + enabled: true, + description: '拥有系统全部权限', + updatedAt: '2026-07-20 10:00', + menuKeys: [ + 'dashboard', + 'llm', + 'llm-models', + 'llm-providers', + 'llm-usage', + 'llm-usage-details', + 'llm-quota', + 'system', + 'system-user', + 'system-role', + 'system-permission', + ], + buttonKeys: ['user:create', 'user:edit', 'user:delete', 'llm:create'], + }, + { + id: 'role-2', + name: '运营', + code: 'ROLE_OPERATOR', + userCount: 5, + enabled: true, + description: '负责模型与用户日常运营', + updatedAt: '2026-07-22 15:30', + menuKeys: [ + 'dashboard', + 'llm', + 'llm-models', + 'llm-providers', + 'llm-usage', + 'llm-quota', + 'system', + 'system-user', + 'system-role', + ], + buttonKeys: ['user:create', 'user:edit', 'llm:create'], + }, + { + id: 'role-3', + name: '只读', + code: 'ROLE_VIEWER', + userCount: 4, + enabled: true, + description: '仅可查看数据,不可修改', + updatedAt: '2026-07-18 09:12', + menuKeys: ['dashboard', 'llm', 'llm-models', 'llm-usage'], + buttonKeys: [], + }, +]; diff --git a/src/pages/system/user/index.tsx b/src/pages/system/user/index.tsx new file mode 100644 index 0000000..1417a86 --- /dev/null +++ b/src/pages/system/user/index.tsx @@ -0,0 +1,462 @@ +import { PlusOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons'; +import { ProCard } from '@ant-design/pro-components'; +import { + App, + Avatar, + Button, + Checkbox, + Col, + Form, + Input, + Modal, + Popconfirm, + Row, + Select, + Space, + Switch, + Table, + Tag, + Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useMemo, useState } from 'react'; + +import { initialUsers, roleOptions, type UserItem, type UserRole } from './mock'; + +interface UserFormValues { + username: string; + nickname: string; + email: string; + phone: string; + roles: UserRole[]; + password?: string; + enabled: boolean; + remark?: string; +} + +interface PasswordFormValues { + password: string; + confirmPassword: string; + forceChange: boolean; +} + +const roleColors: Record = { + 超级管理员: 'blue', + 运营: 'cyan', + 只读: 'default', +}; + +const maskPhone = (phone: string) => phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); + +export default function UserManagement() { + const { message } = App.useApp(); + const [userForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + const [users, setUsers] = useState(initialUsers); + const [keyword, setKeyword] = useState(''); + const [role, setRole] = useState(); + const [enabled, setEnabled] = useState(); + const [selectedKeys, setSelectedKeys] = useState([]); + const [editingUser, setEditingUser] = useState(); + const [formOpen, setFormOpen] = useState(false); + const [resetUser, setResetUser] = useState(); + + const filteredUsers = useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase(); + return users.filter(user => { + const matchesKeyword = + !normalizedKeyword || + user.username.toLowerCase().includes(normalizedKeyword) || + user.nickname.toLowerCase().includes(normalizedKeyword) || + user.email.toLowerCase().includes(normalizedKeyword) || + user.phone.includes(normalizedKeyword); + + return ( + matchesKeyword && (!role || user.roles.includes(role)) && (enabled === undefined || user.enabled === enabled) + ); + }); + }, [enabled, keyword, role, users]); + + const closeUserForm = () => { + setFormOpen(false); + setEditingUser(undefined); + userForm.resetFields(); + }; + + const openCreate = () => { + setEditingUser(undefined); + userForm.setFieldsValue({ + username: '', + nickname: '', + email: '', + phone: '', + roles: ['只读'], + password: '', + enabled: true, + remark: '', + }); + setFormOpen(true); + }; + + const openEdit = (user: UserItem) => { + setEditingUser(user); + userForm.setFieldsValue({ ...user, password: '' }); + setFormOpen(true); + }; + + const saveUser = async () => { + const values = await userForm.validateFields(); + + if (editingUser) { + setUsers(current => + current.map(user => + user.id === editingUser.id + ? { + ...user, + nickname: values.nickname, + email: values.email, + phone: values.phone, + roles: values.roles, + enabled: values.enabled, + } + : user, + ), + ); + message.success('用户已更新'); + } else { + setUsers(current => [ + ...current, + { + id: `user-${Date.now()}`, + username: values.username, + nickname: values.nickname, + email: values.email, + phone: values.phone, + roles: values.roles, + enabled: values.enabled, + lastLoginAt: '从未登录', + createdAt: '2026-07-26', + }, + ]); + message.success('用户已添加'); + } + + closeUserForm(); + }; + + const deleteUser = (user: UserItem) => { + setUsers(current => current.filter(item => item.id !== user.id)); + setSelectedKeys(current => current.filter(key => key !== user.id)); + message.success('用户已删除'); + }; + + const batchDisable = () => { + setUsers(current => current.map(user => (selectedKeys.includes(user.id) ? { ...user, enabled: false } : user))); + message.success(`已禁用 ${selectedKeys.length} 个用户`); + setSelectedKeys([]); + }; + + const openResetPassword = (user: UserItem) => { + setResetUser(user); + passwordForm.setFieldsValue({ password: '', confirmPassword: '', forceChange: true }); + }; + + const resetPassword = async () => { + await passwordForm.validateFields(); + message.success('密码已重置'); + setResetUser(undefined); + passwordForm.resetFields(); + }; + + const columns: ColumnsType = [ + { + title: '用户名', + dataIndex: 'username', + width: 120, + render: (value: string) => ( + + } /> + + {value} + + + ), + }, + { title: '昵称', dataIndex: 'nickname', width: 70, ellipsis: true }, + { title: '邮箱', dataIndex: 'email', width: 150, ellipsis: true }, + { + title: '手机号', + dataIndex: 'phone', + width: 110, + render: (value: string) => {maskPhone(value)}, + }, + { + title: '角色', + dataIndex: 'roles', + width: 100, + render: (roles: UserRole[]) => ( + + {roles.map(item => ( + + {item} + + ))} + + ), + }, + { + title: '状态', + dataIndex: 'enabled', + width: 65, + render: (value: boolean, user) => ( + { + setUsers(current => current.map(item => (item.id === user.id ? { ...item, enabled: checked } : item))); + message.success(checked ? '用户已启用' : '用户已禁用'); + }} + /> + ), + }, + { + title: '最近登录', + dataIndex: 'lastLoginAt', + width: 150, + render: (value: string) => {value}, + }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 100, + render: (value: string) => {value}, + }, + { + title: '操作', + key: 'actions', + width: 180, + fixed: 'right', + render: (_, user) => ( + + + + deleteUser(user)}> + + + + ), + }, + ]; + + return ( + <> + + +
+ + 用户管理 + + 管理系统用户账号、角色绑定与启用状态。 +
+ + + + + } + placeholder="搜索用户名 / 邮箱 / 手机号" + style={{ width: 260 }} + value={keyword} + onChange={event => setKeyword(event.target.value)} + /> + + + + + + + + + + + + + + + rowKey="id" + columns={columns} + dataSource={filteredUsers} + rowSelection={{ selectedRowKeys: selectedKeys, onChange: setSelectedKeys }} + scroll={{ x: 1045 }} + pagination={{ pageSize: 10, showSizeChanger: true, showTotal: total => `共 ${total} 条` }} + /> +
+
+ + + form={userForm} layout="vertical" requiredMark="optional"> + + + + + + + + + + + + + + + + + + + + + + + +