diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index b7707dd..696b0c7 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -2,6 +2,11 @@ import * as React from 'react' import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview' import { SolutionListPage } from '@/features/solutions/SolutionListPage' +import { AuditLogPage } from '@/features/system/AuditLogPage' +import { SystemMenusPage } from '@/features/system/SystemMenusPage' +import { SystemRolesPage } from '@/features/system/SystemRolesPage' +import { SystemUserGroupsPage } from '@/features/system/SystemUserGroupsPage' +import { SystemUsersPage } from '@/features/system/SystemUsersPage' import { TicketCreatePage } from '@/features/tickets/TicketCreatePage' import { TicketDetailPage } from '@/features/tickets/TicketDetailPage' import { TicketListPage } from '@/features/tickets/TicketListPage' @@ -24,6 +29,26 @@ export function Router() { return } + if (route === '/audit') { + return + } + + if (route === '/system/users') { + return + } + + if (route === '/system/user-groups') { + return + } + + if (route === '/system/roles') { + return + } + + if (route === '/system/menus') { + return + } + if (route === '/') { return } diff --git a/frontend/src/features/system/AuditLogPage.tsx b/frontend/src/features/system/AuditLogPage.tsx new file mode 100644 index 0000000..89ba591 --- /dev/null +++ b/frontend/src/features/system/AuditLogPage.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' + +import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable' +import { AppEmptyState } from '@/components/app/AppEmptyState' +import { AppFilterBar } from '@/components/app/AppFilterBar' +import { AppPageHeader } from '@/components/app/AppPageHeader' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { formatDateTime } from '@/lib/format' + +import { listAuditLogs } from './api' +import type { AuditLog } from './types' + +export function AuditLogPage() { + const [logs, setLogs] = React.useState([]) + const [query, setQuery] = React.useState('') + const [error, setError] = React.useState('') + + React.useEffect(() => { + listAuditLogs() + .then((items) => { + setLogs(items) + setError('') + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : '加载失败')) + }, []) + + const filteredLogs = logs.filter((log) => { + const keyword = query.trim().toLowerCase() + if (!keyword) return true + return `${log.module} ${log.action} ${log.target_type} ${log.target_id} ${log.result}`.toLowerCase().includes(keyword) + }) + + const columns: AppDataTableColumn[] = [ + { + key: 'created_at', + header: '时间', + cell: (log) => {formatDateTime(log.created_at)} + }, + { + key: 'module', + header: '模块', + cell: (log) => {log.module} + }, + { + key: 'action', + header: '动作', + cell: (log) => {log.action} + }, + { + key: 'target', + header: '对象', + cell: (log) => {log.target_type} #{log.target_id || '-'} + }, + { + key: 'result', + header: '结果', + cell: (log) => {log.result} + }, + { + key: 'ip', + header: '来源 IP', + cell: (log) => {log.ip_address || '-'} + } + ] + + return ( +
+
+ + + + setQuery(event.target.value)} /> + + + {error ? : null} + {!error ? ( + log.id} + emptyTitle="暂无审计日志" + emptyDescription="产生关键操作后,审计记录会显示在这里。" + footer={

当前显示 {filteredLogs.length} 条记录

} + /> + ) : null} +
+
+ ) +} diff --git a/frontend/src/features/system/SystemMenusPage.tsx b/frontend/src/features/system/SystemMenusPage.tsx new file mode 100644 index 0000000..148ae6e --- /dev/null +++ b/frontend/src/features/system/SystemMenusPage.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' + +import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable' +import { AppEmptyState } from '@/components/app/AppEmptyState' +import { AppPageHeader } from '@/components/app/AppPageHeader' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' + +import { listMenus, updateMenu } from './api' +import type { SystemMenu } from './types' + +export function SystemMenusPage() { + const [menus, setMenus] = React.useState([]) + const [query, setQuery] = React.useState('') + const [error, setError] = React.useState('') + + const load = React.useCallback(async () => { + try { + setMenus(await listMenus()) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '加载失败') + } + }, []) + + React.useEffect(() => { + void load() + }, [load]) + + const filteredMenus = menus.filter((menu) => { + const keyword = query.trim().toLowerCase() + if (!keyword) return true + return `${menu.code} ${menu.title} ${menu.path} ${menu.permission_code}`.toLowerCase().includes(keyword) + }) + + async function toggleVisible(menu: SystemMenu, checked: boolean) { + await updateMenu(menu.id, { is_visible: checked }) + await load() + } + + async function toggleEnabled(menu: SystemMenu, checked: boolean) { + await updateMenu(menu.id, { is_enabled: checked }) + await load() + } + + const columns: AppDataTableColumn[] = [ + { + key: 'title', + header: '菜单', + cell: (menu) => ( +
+

{menu.title}

+

{menu.code}

+
+ ) + }, + { + key: 'path', + header: '路径', + cell: (menu) => {menu.path || '-'} + }, + { + key: 'permission', + header: '权限码', + cell: (menu) => menu.permission_code ? {menu.permission_code} : '-' + }, + { + key: 'visible', + header: '展示', + cell: (menu) => void toggleVisible(menu, checked)} /> + }, + { + key: 'enabled', + header: '启用', + cell: (menu) => void toggleEnabled(menu, checked)} /> + } + ] + + return ( +
+
+ + +
+ setQuery(event.target.value)} /> +
+ + {error ? : null} + {!error ? ( + menu.id} + emptyTitle="暂无菜单" + emptyDescription="执行治理初始化后,系统菜单会显示在这里。" + footer={

当前显示 {filteredMenus.length} 个菜单项

} + /> + ) : null} +
+
+ ) +} diff --git a/frontend/src/features/system/SystemRolesPage.tsx b/frontend/src/features/system/SystemRolesPage.tsx new file mode 100644 index 0000000..3d4e26b --- /dev/null +++ b/frontend/src/features/system/SystemRolesPage.tsx @@ -0,0 +1,177 @@ +import * as React from 'react' + +import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable' +import { AppEmptyState } from '@/components/app/AppEmptyState' +import { AppPageHeader } from '@/components/app/AppPageHeader' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' + +import { createRole, listPermissions, listRoles, setRolePermissions } from './api' +import type { Permission, RoleDetail } from './types' + +export function SystemRolesPage() { + const [roles, setRoles] = React.useState([]) + const [permissions, setPermissions] = React.useState([]) + const [roleName, setRoleName] = React.useState('') + const [editingRole, setEditingRole] = React.useState(null) + const [error, setError] = React.useState('') + + const load = React.useCallback(async () => { + try { + const [nextRoles, nextPermissions] = await Promise.all([listRoles(), listPermissions()]) + setRoles(nextRoles) + setPermissions(nextPermissions) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '加载失败') + } + }, []) + + React.useEffect(() => { + void load() + }, [load]) + + async function handleCreate(event: React.FormEvent) { + event.preventDefault() + await createRole({ name: roleName, permission_codes: [] }) + setRoleName('') + await load() + } + + const columns: AppDataTableColumn[] = [ + { + key: 'name', + header: '角色', + cell: (role) => {role.name} + }, + { + key: 'permissions', + header: '权限数量', + cell: (role) => {role.permission_codes.length} + }, + { + key: 'summary', + header: '权限摘要', + cell: (role) => ( +
+ {role.permission_codes.slice(0, 6).map((code) => {code})} + {role.permission_codes.length > 6 ? +{role.permission_codes.length - 6} : null} +
+ ) + }, + { + key: 'actions', + header: '操作', + cell: (role) => + } + ] + + return ( +
+
+ + +
+ setRoleName(event.target.value)} required /> + +
+ + {error ? : null} + {!error ? ( + role.id} + emptyTitle="暂无角色" + emptyDescription="创建角色后可继续配置权限。" + /> + ) : null} + + setEditingRole(null)} + onSaved={load} + /> +
+
+ ) +} + +function RolePermissionDialog({ + role, + permissions, + onClose, + onSaved +}: { + role: RoleDetail | null + permissions: Permission[] + onClose: () => void + onSaved: () => Promise +}) { + const [selected, setSelected] = React.useState([]) + + React.useEffect(() => { + setSelected(role?.permission_codes ?? []) + }, [role]) + + const groupedPermissions = React.useMemo(() => { + return permissions.reduce>((groups, permission) => { + groups[permission.module] = groups[permission.module] ?? [] + groups[permission.module].push(permission) + return groups + }, {}) + }, [permissions]) + + async function handleSave() { + if (!role) return + await setRolePermissions(role.id, selected) + await onSaved() + onClose() + } + + return ( + !open && onClose()}> + + + 配置角色权限 + {role?.name} 可访问的菜单和接口能力。 + +
+ {Object.entries(groupedPermissions).map(([module, modulePermissions]) => ( +
+

{module}

+
+ {modulePermissions.map((permission) => ( + + ))} +
+
+ ))} +
+ + + + +
+
+ ) +} diff --git a/frontend/src/features/system/SystemUserGroupsPage.tsx b/frontend/src/features/system/SystemUserGroupsPage.tsx new file mode 100644 index 0000000..bdcf604 --- /dev/null +++ b/frontend/src/features/system/SystemUserGroupsPage.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' + +import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable' +import { AppEmptyState } from '@/components/app/AppEmptyState' +import { AppPageHeader } from '@/components/app/AppPageHeader' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { formatDateTime } from '@/lib/format' + +import { createUserGroup, listUserGroups, updateUserGroup } from './api' +import type { UserGroup } from './types' + +export function SystemUserGroupsPage() { + const [groups, setGroups] = React.useState([]) + const [form, setForm] = React.useState({ name: '', code: '', description: '' }) + const [error, setError] = React.useState('') + + const load = React.useCallback(async () => { + try { + setGroups(await listUserGroups()) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '加载失败') + } + }, []) + + React.useEffect(() => { + void load() + }, [load]) + + async function handleCreate(event: React.FormEvent) { + event.preventDefault() + await createUserGroup({ ...form, is_active: true }) + setForm({ name: '', code: '', description: '' }) + await load() + } + + async function toggleActive(group: UserGroup, checked: boolean) { + await updateUserGroup(group.id, { is_active: checked }) + await load() + } + + const columns: AppDataTableColumn[] = [ + { + key: 'name', + header: '用户组', + cell: (group) => ( +
+

{group.name}

+

{group.code}

+
+ ) + }, + { + key: 'description', + header: '说明', + cell: (group) => {group.description || '-'} + }, + { + key: 'status', + header: '状态', + cell: (group) => ( +
+ void toggleActive(group, checked)} /> + {group.is_active ? '启用' : '停用'} +
+ ) + }, + { + key: 'updated_at', + header: '更新时间', + cell: (group) => ( + {group.updated_at ? formatDateTime(group.updated_at) : '-'} + ) + } + ] + + return ( +
+
+ + +
+ setForm({ ...form, name: event.target.value })} required /> + setForm({ ...form, code: event.target.value })} required /> + setForm({ ...form, description: event.target.value })} /> + +
+ + {error ? : null} + {!error ? ( + group.id} + emptyTitle="暂无用户组" + emptyDescription="创建用户组后会显示在这里。" + footer={

共 {groups.length} 个用户组

} + /> + ) : null} +
+
+ ) +} diff --git a/frontend/src/features/system/SystemUsersPage.tsx b/frontend/src/features/system/SystemUsersPage.tsx new file mode 100644 index 0000000..6071d86 --- /dev/null +++ b/frontend/src/features/system/SystemUsersPage.tsx @@ -0,0 +1,256 @@ +import * as React from 'react' + +import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable' +import { AppEmptyState } from '@/components/app/AppEmptyState' +import { AppFilterBar } from '@/components/app/AppFilterBar' +import { AppPageHeader } from '@/components/app/AppPageHeader' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' + +import { createUser, listRoles, listUserGroups, listUsers, setUserGroups, setUserRoles, updateUser } from './api' +import type { RoleDetail, SystemUser, UserGroup } from './types' + +type UserForm = { + username: string + display_name: string + email: string + password: string +} + +const emptyForm: UserForm = { + username: '', + display_name: '', + email: '', + password: '' +} + +export function SystemUsersPage() { + const [users, setUsers] = React.useState([]) + const [roles, setRoles] = React.useState([]) + const [groups, setGroups] = React.useState([]) + const [query, setQuery] = React.useState('') + const [form, setForm] = React.useState(emptyForm) + const [editingUser, setEditingUser] = React.useState(null) + const [error, setError] = React.useState('') + + const load = React.useCallback(async () => { + try { + const [nextUsers, nextRoles, nextGroups] = await Promise.all([listUsers(), listRoles(), listUserGroups()]) + setUsers(nextUsers) + setRoles(nextRoles) + setGroups(nextGroups) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '加载失败') + } + }, []) + + React.useEffect(() => { + void load() + }, [load]) + + const filteredUsers = users.filter((user) => { + const keyword = query.trim().toLowerCase() + if (!keyword) return true + return `${user.username} ${user.display_name} ${user.email}`.toLowerCase().includes(keyword) + }) + + async function handleCreateUser(event: React.FormEvent) { + event.preventDefault() + await createUser({ ...form, is_active: true }) + setForm(emptyForm) + await load() + } + + async function toggleUserActive(user: SystemUser, checked: boolean) { + await updateUser(user.id, { is_active: checked }) + await load() + } + + const columns: AppDataTableColumn[] = [ + { + key: 'user', + header: '用户', + cell: (user) => ( +
+

{user.display_name || user.username}

+

{user.username}

+
+ ) + }, + { + key: 'email', + header: '邮箱', + cell: (user) => {user.email || '-'} + }, + { + key: 'roles', + header: '角色', + cell: (user) => ( +
+ {user.roles.length ? user.roles.map((role) => {role.name}) : '-'} +
+ ) + }, + { + key: 'groups', + header: '用户组', + cell: (user) => ( +
+ {user.user_groups.length + ? user.user_groups.map((group) => {group.name}) + : '-'} +
+ ) + }, + { + key: 'active', + header: '状态', + cell: (user) => ( + event.stopPropagation()} + onCheckedChange={(checked) => void toggleUserActive(user, checked)} + /> + ) + }, + { + key: 'actions', + header: '操作', + cell: (user) => ( + + ) + } + ] + + return ( +
+
+ + +
+ setForm({ ...form, username: event.target.value })} required /> + setForm({ ...form, display_name: event.target.value })} /> + setForm({ ...form, email: event.target.value })} /> + setForm({ ...form, password: event.target.value })} required /> + +
+ + + setQuery(event.target.value)} /> + + + {error ? : null} + {!error ? ( + user.id} + emptyTitle="暂无用户" + emptyDescription="创建账号后会显示在这里。" + footer={

当前显示 {filteredUsers.length} 个用户

} + /> + ) : null} + + setEditingUser(null)} + onSaved={load} + /> +
+
+ ) +} + +function UserAccessDialog({ + user, + roles, + groups, + onClose, + onSaved +}: { + user: SystemUser | null + roles: RoleDetail[] + groups: UserGroup[] + onClose: () => void + onSaved: () => Promise +}) { + const [roleIds, setRoleIds] = React.useState([]) + const [groupIds, setGroupIds] = React.useState([]) + + React.useEffect(() => { + setRoleIds(user?.roles.map((role) => role.id) ?? []) + setGroupIds(user?.user_groups.map((group) => group.id) ?? []) + }, [user]) + + async function handleSave() { + if (!user) return + await Promise.all([setUserRoles(user.id, roleIds), setUserGroups(user.id, groupIds)]) + await onSaved() + onClose() + } + + return ( + !open && onClose()}> + + + 用户授权 + 为 {user?.display_name || user?.username} 分配角色和用户组。 + +
+
+ +
+ {roles.map((role) => ( + + ))} +
+
+
+ +
+ {groups.map((group) => ( + + ))} +
+
+
+ + + + +
+
+ ) +} diff --git a/frontend/src/features/system/api.ts b/frontend/src/features/system/api.ts new file mode 100644 index 0000000..88970cf --- /dev/null +++ b/frontend/src/features/system/api.ts @@ -0,0 +1,133 @@ +import { apiRequest } from '@/lib/api' + +import type { AuditLog, Permission, RoleDetail, SystemMenu, SystemUser, UserGroup } from './types' + +export function listUsers() { + return apiRequest('/accounts/users') +} + +export function createUser(payload: { + username: string + display_name: string + email: string + password: string + is_active: boolean +}) { + return apiRequest('/accounts/users', { + method: 'POST', + body: JSON.stringify(payload) + }) +} + +export function updateUser( + userId: number, + payload: { + display_name?: string + email?: string + is_active?: boolean + } +) { + return apiRequest(`/accounts/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }) +} + +export function setUserRoles(userId: number, roleIds: number[]) { + return apiRequest(`/accounts/users/${userId}/roles`, { + method: 'POST', + body: JSON.stringify({ role_ids: roleIds }) + }) +} + +export function setUserGroups(userId: number, userGroupIds: number[]) { + return apiRequest(`/accounts/users/${userId}/user-groups`, { + method: 'POST', + body: JSON.stringify({ user_ids: userGroupIds }) + }) +} + +export function listUserGroups() { + return apiRequest('/accounts/user-groups') +} + +export function createUserGroup(payload: { + name: string + code: string + description: string + is_active: boolean +}) { + return apiRequest('/accounts/user-groups', { + method: 'POST', + body: JSON.stringify(payload) + }) +} + +export function updateUserGroup( + groupId: number, + payload: { + name?: string + description?: string + is_active?: boolean + } +) { + return apiRequest(`/accounts/user-groups/${groupId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }) +} + +export function listRoles() { + return apiRequest('/accounts/roles') +} + +export function createRole(payload: { name: string; permission_codes: string[] }) { + return apiRequest('/accounts/roles', { + method: 'POST', + body: JSON.stringify(payload) + }) +} + +export function updateRole(roleId: number, payload: { name?: string }) { + return apiRequest(`/accounts/roles/${roleId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }) +} + +export function setRolePermissions(roleId: number, permissionCodes: string[]) { + return apiRequest(`/accounts/roles/${roleId}/permissions`, { + method: 'POST', + body: JSON.stringify({ permission_codes: permissionCodes }) + }) +} + +export function listPermissions() { + return apiRequest('/accounts/permissions') +} + +export function listMenus() { + return apiRequest('/accounts/menus') +} + +export function updateMenu( + menuId: number, + payload: { + title?: string + path?: string + icon?: string + permission_code?: string + is_visible?: boolean + is_enabled?: boolean + sort_order?: number + } +) { + return apiRequest(`/accounts/menus/${menuId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }) +} + +export function listAuditLogs() { + return apiRequest('/audit/operation-logs') +} diff --git a/frontend/src/features/system/types.ts b/frontend/src/features/system/types.ts new file mode 100644 index 0000000..52b6b92 --- /dev/null +++ b/frontend/src/features/system/types.ts @@ -0,0 +1,63 @@ +export type RoleSummary = { + id: number + name: string +} + +export type UserGroup = { + id: number + name: string + code: string + description: string + is_active: boolean + created_at?: string | null + updated_at?: string | null +} + +export type SystemUser = { + id: number + username: string + display_name: string + email: string + is_active: boolean + is_staff: boolean + is_superuser: boolean + roles: RoleSummary[] + user_groups: UserGroup[] +} + +export type Permission = { + codename: string + name: string + module: string +} + +export type RoleDetail = { + id: number + name: string + permission_codes: string[] +} + +export type SystemMenu = { + id: number + code: string + title: string + path: string + icon: string + permission_code: string + is_visible: boolean + is_enabled: boolean + children: SystemMenu[] +} + +export type AuditLog = { + id: number + module: string + action: string + target_type: string + target_id: string + request_snapshot: Record + result: string + ip_address: string | null + user_agent: string + created_at: string +}