feat: add system governance pages
This commit is contained in:
@@ -2,6 +2,11 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
||||||
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
|
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 { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
|
||||||
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
|
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
|
||||||
import { TicketListPage } from '@/features/tickets/TicketListPage'
|
import { TicketListPage } from '@/features/tickets/TicketListPage'
|
||||||
@@ -24,6 +29,26 @@ export function Router() {
|
|||||||
return <DesignSystemPreview />
|
return <DesignSystemPreview />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route === '/audit') {
|
||||||
|
return <AuditLogPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === '/system/users') {
|
||||||
|
return <SystemUsersPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === '/system/user-groups') {
|
||||||
|
return <SystemUserGroupsPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === '/system/roles') {
|
||||||
|
return <SystemRolesPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === '/system/menus') {
|
||||||
|
return <SystemMenusPage />
|
||||||
|
}
|
||||||
|
|
||||||
if (route === '/') {
|
if (route === '/') {
|
||||||
return <WorkbenchPage />
|
return <WorkbenchPage />
|
||||||
}
|
}
|
||||||
|
|||||||
90
frontend/src/features/system/AuditLogPage.tsx
Normal file
90
frontend/src/features/system/AuditLogPage.tsx
Normal file
@@ -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<AuditLog[]>([])
|
||||||
|
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<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
header: '时间',
|
||||||
|
cell: (log) => <span className="text-muted-foreground">{formatDateTime(log.created_at)}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'module',
|
||||||
|
header: '模块',
|
||||||
|
cell: (log) => <Badge variant="secondary">{log.module}</Badge>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
header: '动作',
|
||||||
|
cell: (log) => <span className="font-medium text-foreground">{log.action}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'target',
|
||||||
|
header: '对象',
|
||||||
|
cell: (log) => <span className="text-muted-foreground">{log.target_type} #{log.target_id || '-'}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'result',
|
||||||
|
header: '结果',
|
||||||
|
cell: (log) => <Badge variant={log.result === 'success' ? 'success' : 'destructive'}>{log.result}</Badge>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
header: '来源 IP',
|
||||||
|
cell: (log) => <span className="text-muted-foreground">{log.ip_address || '-'}</span>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
|
<AppPageHeader title="操作审计" description="查看平台关键操作记录,辅助权限治理和问题追踪。" />
|
||||||
|
|
||||||
|
<AppFilterBar>
|
||||||
|
<Input placeholder="搜索模块、动作、对象或结果" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
</AppFilterBar>
|
||||||
|
|
||||||
|
{error ? <AppEmptyState title="无法加载审计日志" description={error} /> : null}
|
||||||
|
{!error ? (
|
||||||
|
<AppDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={filteredLogs}
|
||||||
|
getRowKey={(log) => log.id}
|
||||||
|
emptyTitle="暂无审计日志"
|
||||||
|
emptyDescription="产生关键操作后,审计记录会显示在这里。"
|
||||||
|
footer={<p className="text-sm text-muted-foreground">当前显示 {filteredLogs.length} 条记录</p>}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
frontend/src/features/system/SystemMenusPage.tsx
Normal file
103
frontend/src/features/system/SystemMenusPage.tsx
Normal file
@@ -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<SystemMenu[]>([])
|
||||||
|
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<SystemMenu>[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
header: '菜单',
|
||||||
|
cell: (menu) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{menu.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{menu.code}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'path',
|
||||||
|
header: '路径',
|
||||||
|
cell: (menu) => <span className="text-muted-foreground">{menu.path || '-'}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permission',
|
||||||
|
header: '权限码',
|
||||||
|
cell: (menu) => menu.permission_code ? <Badge variant="secondary">{menu.permission_code}</Badge> : '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'visible',
|
||||||
|
header: '展示',
|
||||||
|
cell: (menu) => <Switch checked={menu.is_visible} onCheckedChange={(checked) => void toggleVisible(menu, checked)} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: '启用',
|
||||||
|
cell: (menu) => <Switch checked={menu.is_enabled} onCheckedChange={(checked) => void toggleEnabled(menu, checked)} />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
|
<AppPageHeader title="菜单权限" description="按后端菜单配置控制前端导航展示和可用状态。" />
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-3">
|
||||||
|
<Input placeholder="搜索菜单、路径或权限码" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <AppEmptyState title="无法加载菜单" description={error} /> : null}
|
||||||
|
{!error ? (
|
||||||
|
<AppDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={filteredMenus}
|
||||||
|
getRowKey={(menu) => menu.id}
|
||||||
|
emptyTitle="暂无菜单"
|
||||||
|
emptyDescription="执行治理初始化后,系统菜单会显示在这里。"
|
||||||
|
footer={<p className="text-sm text-muted-foreground">当前显示 {filteredMenus.length} 个菜单项</p>}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
frontend/src/features/system/SystemRolesPage.tsx
Normal file
177
frontend/src/features/system/SystemRolesPage.tsx
Normal file
@@ -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<RoleDetail[]>([])
|
||||||
|
const [permissions, setPermissions] = React.useState<Permission[]>([])
|
||||||
|
const [roleName, setRoleName] = React.useState('')
|
||||||
|
const [editingRole, setEditingRole] = React.useState<RoleDetail | null>(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<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
await createRole({ name: roleName, permission_codes: [] })
|
||||||
|
setRoleName('')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: AppDataTableColumn<RoleDetail>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: '角色',
|
||||||
|
cell: (role) => <span className="font-medium text-foreground">{role.name}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permissions',
|
||||||
|
header: '权限数量',
|
||||||
|
cell: (role) => <Badge variant="info">{role.permission_codes.length}</Badge>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'summary',
|
||||||
|
header: '权限摘要',
|
||||||
|
cell: (role) => (
|
||||||
|
<div className="flex max-w-[520px] flex-wrap gap-1">
|
||||||
|
{role.permission_codes.slice(0, 6).map((code) => <Badge key={code} variant="secondary">{code}</Badge>)}
|
||||||
|
{role.permission_codes.length > 6 ? <Badge variant="outline">+{role.permission_codes.length - 6}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '操作',
|
||||||
|
cell: (role) => <Button variant="ghost" onClick={() => setEditingRole(role)}>配置权限</Button>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
|
<AppPageHeader title="角色权限" description="定义后台角色,并将系统权限授予对应角色。" />
|
||||||
|
|
||||||
|
<form className="grid gap-3 rounded-lg border border-border bg-surface p-3 md:grid-cols-[minmax(0,360px)_auto]" onSubmit={handleCreate}>
|
||||||
|
<Input placeholder="角色名称" value={roleName} onChange={(event) => setRoleName(event.target.value)} required />
|
||||||
|
<Button type="submit">创建角色</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? <AppEmptyState title="无法加载角色" description={error} /> : null}
|
||||||
|
{!error ? (
|
||||||
|
<AppDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={roles}
|
||||||
|
getRowKey={(role) => role.id}
|
||||||
|
emptyTitle="暂无角色"
|
||||||
|
emptyDescription="创建角色后可继续配置权限。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RolePermissionDialog
|
||||||
|
role={editingRole}
|
||||||
|
permissions={permissions}
|
||||||
|
onClose={() => setEditingRole(null)}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolePermissionDialog({
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
role: RoleDetail | null
|
||||||
|
permissions: Permission[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = React.useState<string[]>([])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelected(role?.permission_codes ?? [])
|
||||||
|
}, [role])
|
||||||
|
|
||||||
|
const groupedPermissions = React.useMemo(() => {
|
||||||
|
return permissions.reduce<Record<string, Permission[]>>((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 (
|
||||||
|
<Dialog open={Boolean(role)} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>配置角色权限</DialogTitle>
|
||||||
|
<DialogDescription>{role?.name} 可访问的菜单和接口能力。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid max-h-[56vh] gap-4 overflow-auto pr-1">
|
||||||
|
{Object.entries(groupedPermissions).map(([module, modulePermissions]) => (
|
||||||
|
<section key={module} className="rounded-md border border-border p-3">
|
||||||
|
<p className="mb-3 text-sm font-medium text-foreground">{module}</p>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{modulePermissions.map((permission) => (
|
||||||
|
<label key={permission.codename} className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(permission.codename)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSelected((current) =>
|
||||||
|
checked
|
||||||
|
? [...current, permission.codename]
|
||||||
|
: current.filter((code) => code !== permission.codename)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-foreground">{permission.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{permission.codename}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||||
|
<Button onClick={() => void handleSave()}>保存权限</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
frontend/src/features/system/SystemUserGroupsPage.tsx
Normal file
106
frontend/src/features/system/SystemUserGroupsPage.tsx
Normal file
@@ -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<UserGroup[]>([])
|
||||||
|
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<HTMLFormElement>) {
|
||||||
|
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<UserGroup>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: '用户组',
|
||||||
|
cell: (group) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{group.name}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{group.code}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
header: '说明',
|
||||||
|
cell: (group) => <span className="text-muted-foreground">{group.description || '-'}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '状态',
|
||||||
|
cell: (group) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={group.is_active} onCheckedChange={(checked) => void toggleActive(group, checked)} />
|
||||||
|
<Badge variant={group.is_active ? 'success' : 'secondary'}>{group.is_active ? '启用' : '停用'}</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updated_at',
|
||||||
|
header: '更新时间',
|
||||||
|
cell: (group) => (
|
||||||
|
<span className="text-muted-foreground">{group.updated_at ? formatDateTime(group.updated_at) : '-'}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
|
<AppPageHeader title="用户组管理" description="按业务团队、系统域或支持队列组织用户。" />
|
||||||
|
|
||||||
|
<form className="grid gap-3 rounded-lg border border-border bg-surface p-3 md:grid-cols-[1fr_1fr_2fr_auto]" onSubmit={handleCreate}>
|
||||||
|
<Input placeholder="用户组名称" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} required />
|
||||||
|
<Input placeholder="编码" value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} required />
|
||||||
|
<Input placeholder="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||||
|
<Button type="submit">创建用户组</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? <AppEmptyState title="无法加载用户组" description={error} /> : null}
|
||||||
|
{!error ? (
|
||||||
|
<AppDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={groups}
|
||||||
|
getRowKey={(group) => group.id}
|
||||||
|
emptyTitle="暂无用户组"
|
||||||
|
emptyDescription="创建用户组后会显示在这里。"
|
||||||
|
footer={<p className="text-sm text-muted-foreground">共 {groups.length} 个用户组</p>}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
frontend/src/features/system/SystemUsersPage.tsx
Normal file
256
frontend/src/features/system/SystemUsersPage.tsx
Normal file
@@ -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<SystemUser[]>([])
|
||||||
|
const [roles, setRoles] = React.useState<RoleDetail[]>([])
|
||||||
|
const [groups, setGroups] = React.useState<UserGroup[]>([])
|
||||||
|
const [query, setQuery] = React.useState('')
|
||||||
|
const [form, setForm] = React.useState<UserForm>(emptyForm)
|
||||||
|
const [editingUser, setEditingUser] = React.useState<SystemUser | null>(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<HTMLFormElement>) {
|
||||||
|
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<SystemUser>[] = [
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
header: '用户',
|
||||||
|
cell: (user) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{user.display_name || user.username}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{user.username}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
header: '邮箱',
|
||||||
|
cell: (user) => <span className="text-muted-foreground">{user.email || '-'}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
header: '角色',
|
||||||
|
cell: (user) => (
|
||||||
|
<div className="flex max-w-[260px] flex-wrap gap-1">
|
||||||
|
{user.roles.length ? user.roles.map((role) => <Badge key={role.id} variant="secondary">{role.name}</Badge>) : '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'groups',
|
||||||
|
header: '用户组',
|
||||||
|
cell: (user) => (
|
||||||
|
<div className="flex max-w-[260px] flex-wrap gap-1">
|
||||||
|
{user.user_groups.length
|
||||||
|
? user.user_groups.map((group) => <Badge key={group.id} variant="info">{group.name}</Badge>)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: '状态',
|
||||||
|
cell: (user) => (
|
||||||
|
<Switch
|
||||||
|
checked={user.is_active}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onCheckedChange={(checked) => void toggleUserActive(user, checked)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '操作',
|
||||||
|
cell: (user) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setEditingUser(user)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
授权
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
|
<AppPageHeader title="用户管理" description="维护平台账号、启用状态、角色和业务用户组。" />
|
||||||
|
|
||||||
|
<form className="grid gap-3 rounded-lg border border-border bg-surface p-3 md:grid-cols-[1fr_1fr_1fr_1fr_auto]" onSubmit={handleCreateUser}>
|
||||||
|
<Input placeholder="账号" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} required />
|
||||||
|
<Input placeholder="显示名称" value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} />
|
||||||
|
<Input placeholder="邮箱" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
|
||||||
|
<Input placeholder="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} required />
|
||||||
|
<Button type="submit">创建用户</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<AppFilterBar>
|
||||||
|
<Input placeholder="搜索账号、姓名或邮箱" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
</AppFilterBar>
|
||||||
|
|
||||||
|
{error ? <AppEmptyState title="无法加载用户" description={error} /> : null}
|
||||||
|
{!error ? (
|
||||||
|
<AppDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={filteredUsers}
|
||||||
|
getRowKey={(user) => user.id}
|
||||||
|
emptyTitle="暂无用户"
|
||||||
|
emptyDescription="创建账号后会显示在这里。"
|
||||||
|
footer={<p className="text-sm text-muted-foreground">当前显示 {filteredUsers.length} 个用户</p>}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<UserAccessDialog
|
||||||
|
user={editingUser}
|
||||||
|
roles={roles}
|
||||||
|
groups={groups}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserAccessDialog({
|
||||||
|
user,
|
||||||
|
roles,
|
||||||
|
groups,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
user: SystemUser | null
|
||||||
|
roles: RoleDetail[]
|
||||||
|
groups: UserGroup[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [roleIds, setRoleIds] = React.useState<number[]>([])
|
||||||
|
const [groupIds, setGroupIds] = React.useState<number[]>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={Boolean(user)} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>用户授权</DialogTitle>
|
||||||
|
<DialogDescription>为 {user?.display_name || user?.username} 分配角色和用户组。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>角色</Label>
|
||||||
|
<div className="grid max-h-64 gap-2 overflow-auto rounded-md border border-border p-3">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={roleIds.includes(role.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRoleIds((current) => checked ? [...current, role.id] : current.filter((id) => id !== role.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{role.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>用户组</Label>
|
||||||
|
<div className="grid max-h-64 gap-2 overflow-auto rounded-md border border-border p-3">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<label key={group.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={groupIds.includes(group.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setGroupIds((current) => checked ? [...current, group.id] : current.filter((id) => id !== group.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{group.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||||
|
<Button onClick={() => void handleSave()}>保存授权</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
frontend/src/features/system/api.ts
Normal file
133
frontend/src/features/system/api.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
|
import type { AuditLog, Permission, RoleDetail, SystemMenu, SystemUser, UserGroup } from './types'
|
||||||
|
|
||||||
|
export function listUsers() {
|
||||||
|
return apiRequest<SystemUser[]>('/accounts/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(payload: {
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
is_active: boolean
|
||||||
|
}) {
|
||||||
|
return apiRequest<SystemUser>('/accounts/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(
|
||||||
|
userId: number,
|
||||||
|
payload: {
|
||||||
|
display_name?: string
|
||||||
|
email?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return apiRequest<SystemUser>(`/accounts/users/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserRoles(userId: number, roleIds: number[]) {
|
||||||
|
return apiRequest<SystemUser>(`/accounts/users/${userId}/roles`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ role_ids: roleIds })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserGroups(userId: number, userGroupIds: number[]) {
|
||||||
|
return apiRequest<SystemUser>(`/accounts/users/${userId}/user-groups`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ user_ids: userGroupIds })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUserGroups() {
|
||||||
|
return apiRequest<UserGroup[]>('/accounts/user-groups')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserGroup(payload: {
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
description: string
|
||||||
|
is_active: boolean
|
||||||
|
}) {
|
||||||
|
return apiRequest<UserGroup>('/accounts/user-groups', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserGroup(
|
||||||
|
groupId: number,
|
||||||
|
payload: {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return apiRequest<UserGroup>(`/accounts/user-groups/${groupId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRoles() {
|
||||||
|
return apiRequest<RoleDetail[]>('/accounts/roles')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRole(payload: { name: string; permission_codes: string[] }) {
|
||||||
|
return apiRequest<RoleDetail>('/accounts/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRole(roleId: number, payload: { name?: string }) {
|
||||||
|
return apiRequest<RoleDetail>(`/accounts/roles/${roleId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRolePermissions(roleId: number, permissionCodes: string[]) {
|
||||||
|
return apiRequest<RoleDetail>(`/accounts/roles/${roleId}/permissions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ permission_codes: permissionCodes })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPermissions() {
|
||||||
|
return apiRequest<Permission[]>('/accounts/permissions')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMenus() {
|
||||||
|
return apiRequest<SystemMenu[]>('/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<SystemMenu>(`/accounts/menus/${menuId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAuditLogs() {
|
||||||
|
return apiRequest<AuditLog[]>('/audit/operation-logs')
|
||||||
|
}
|
||||||
63
frontend/src/features/system/types.ts
Normal file
63
frontend/src/features/system/types.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
result: string
|
||||||
|
ip_address: string | null
|
||||||
|
user_agent: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user