From ed13194de1f5ffe80d8b5036c648276422f8508a Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Tue, 11 Aug 2026 11:37:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BC=98=E5=8C=96=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E7=AE=A1=E7=90=86=E5=B8=83=E5=B1=80=E4=B8=8E=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/admin/AdminDataSection.tsx | 50 ++ web/src/components/admin/AdminRoleSelect.tsx | 34 ++ web/src/layouts/AppLayout.tsx | 11 +- web/src/pages/admin/AdminLayout.test.tsx | 60 +++ web/src/pages/admin/AdminLayout.tsx | 55 +++ web/src/pages/admin/ApiKeysPage.test.ts | 31 ++ web/src/pages/admin/ApiKeysPage.tsx | 385 +++++++++++---- web/src/pages/admin/UsersPage.tsx | 437 ++++++++++++++---- web/src/pages/admin/admin.css | 178 +++++++ web/src/pages/audit/AuditLogsPage.tsx | 19 +- web/src/router/index.tsx | 8 +- 11 files changed, 1064 insertions(+), 204 deletions(-) create mode 100644 web/src/components/admin/AdminDataSection.tsx create mode 100644 web/src/components/admin/AdminRoleSelect.tsx create mode 100644 web/src/pages/admin/AdminLayout.test.tsx create mode 100644 web/src/pages/admin/AdminLayout.tsx create mode 100644 web/src/pages/admin/ApiKeysPage.test.ts create mode 100644 web/src/pages/admin/admin.css diff --git a/web/src/components/admin/AdminDataSection.tsx b/web/src/components/admin/AdminDataSection.tsx new file mode 100644 index 0000000..b1ae313 --- /dev/null +++ b/web/src/components/admin/AdminDataSection.tsx @@ -0,0 +1,50 @@ +import { Typography } from '@arco-design/web-react' +import type { ReactNode } from 'react' + +export interface AdminMetric { + label: string + value: ReactNode + detail: string +} + +interface AdminDataSectionProps { + title: string + description: string + actions?: ReactNode + metrics: AdminMetric[] + toolbar: ReactNode + children: ReactNode +} + +export function AdminDataSection({ title, description, actions, metrics, toolbar, children }: AdminDataSectionProps) { + return ( + + + + + {title} + + + {description} + + + {actions} + + + + {metrics.map((metric) => ( + + {metric.label} + {metric.value} + {metric.detail} + + ))} + + + + {toolbar} + {children} + + + ) +} diff --git a/web/src/components/admin/AdminRoleSelect.tsx b/web/src/components/admin/AdminRoleSelect.tsx new file mode 100644 index 0000000..37859c8 --- /dev/null +++ b/web/src/components/admin/AdminRoleSelect.tsx @@ -0,0 +1,34 @@ +import { Select } from '@arco-design/web-react' +import type { CSSProperties } from 'react' +import type { UserRole } from '../../services/users' + +export const adminRoleOptions = [ + { label: '管理员 (admin)', value: 'admin' }, + { label: '运维 (operator)', value: 'operator' }, + { label: '只读 (viewer)', value: 'viewer' }, +] + +export const adminRoleDescriptions: Record = { + admin: '拥有系统配置、账号与访问凭据的完整管理权限。', + operator: '可执行日常备份、恢复和节点运维操作。', + viewer: '仅可查看仪表盘和允许读取的数据。', +} + +interface AdminRoleSelectProps { + value: UserRole + onChange: (role: UserRole) => void + disabled?: boolean + style?: CSSProperties +} + +export function AdminRoleSelect({ value, onChange, disabled, style }: AdminRoleSelectProps) { + return ( + onChange(role as UserRole)} + /> + ) +} diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index d042549..f1d6458 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -9,7 +9,6 @@ import { IconCopy, IconBook, IconUser, - IconCommand, IconNotification, IconSettings, IconMenuFold, @@ -86,11 +85,8 @@ function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/task-templates')) { return '/task-templates' } - if (pathname.startsWith('/admin/users')) { - return '/admin/users' - } - if (pathname.startsWith('/admin/api-keys')) { - return '/admin/api-keys' + if (pathname.startsWith('/admin')) { + return '/admin' } if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) { return '/settings' @@ -117,8 +113,7 @@ const menuItems: MenuItemConfig[] = [ { key: '/storage-targets', label: '存储目标', icon: }, { key: '/nodes', label: '节点管理', icon: }, { key: '/settings/notifications', label: '通知配置', icon: }, - { key: '/admin/users', label: '用户管理', icon: , adminOnly: true }, - { key: '/admin/api-keys', label: 'API Key', icon: , adminOnly: true }, + { key: '/admin', label: '访问管理', icon: , adminOnly: true }, { key: '/audit', label: '审计日志', icon: }, { key: '/settings', label: '系统设置', icon: }, ] diff --git a/web/src/pages/admin/AdminLayout.test.tsx b/web/src/pages/admin/AdminLayout.test.tsx new file mode 100644 index 0000000..6ec0dbe --- /dev/null +++ b/web/src/pages/admin/AdminLayout.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it } from 'vitest' +import { useAuthStore } from '../../stores/auth' +import { AdminLayout } from './AdminLayout' + +describe('AdminLayout', () => { + beforeEach(() => { + useAuthStore.setState({ + token: 'test-token', + user: { id: 1, username: 'admin', displayName: 'Admin', role: 'admin' }, + status: 'authenticated', + bootstrapped: true, + }) + }) + + it('keeps user and API key management in one navigable admin area', async () => { + const actor = userEvent.setup() + render( + + + }> + user management content} /> + api key management content} /> + + audit content} /> + + , + ) + + expect(screen.getByText('user management content')).toBeInTheDocument() + expect(screen.getByRole('navigation', { name: '访问管理分区' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: 'API Key' })) + expect(screen.getByText('api key management content')).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '访问审计' })) + expect(screen.getByText('audit content')).toBeInTheDocument() + }) + + it('blocks non-admin users before rendering management content', () => { + useAuthStore.setState({ + user: { id: 2, username: 'viewer', displayName: 'Viewer', role: 'viewer' }, + }) + + render( + + + }> + restricted content} /> + + + , + ) + + expect(screen.getByText('当前账号无权进入访问管理(仅管理员)')).toBeInTheDocument() + expect(screen.queryByText('restricted content')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/admin/AdminLayout.tsx b/web/src/pages/admin/AdminLayout.tsx new file mode 100644 index 0000000..cddabf0 --- /dev/null +++ b/web/src/pages/admin/AdminLayout.tsx @@ -0,0 +1,55 @@ +import { Alert, Button, PageHeader } from '@arco-design/web-react' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' +import { IconCommand, IconList, IconUser } from '../../components/icons' +import { useAuthStore } from '../../stores/auth' +import { isAdmin } from '../../utils/permissions' +import './admin.css' + +const sections = [ + { path: '/admin/users', label: '用户账号', icon: }, + { path: '/admin/api-keys', label: 'API Key', icon: }, +] + +export function AdminLayout() { + const user = useAuthStore((state) => state.user) + const location = useLocation() + const navigate = useNavigate() + + if (!isAdmin(user)) { + return + } + + return ( + + } onClick={() => navigate('/audit')}> + 访问审计 + + )} + /> + + + {sections.map((section) => { + const selected = location.pathname.startsWith(section.path) + return ( + navigate(section.path)} + > + {section.label} + + ) + })} + + + + + ) +} diff --git a/web/src/pages/admin/ApiKeysPage.test.ts b/web/src/pages/admin/ApiKeysPage.test.ts new file mode 100644 index 0000000..0675e03 --- /dev/null +++ b/web/src/pages/admin/ApiKeysPage.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import type { ApiKeySummary } from '../../services/api-keys' +import { resolveApiKeyStatus } from './ApiKeysPage' + +const baseKey: ApiKeySummary = { + id: 1, + name: 'automation', + role: 'viewer', + prefix: 'bax_example', + createdBy: 'admin', + disabled: false, + createdAt: '2026-08-01T00:00:00Z', +} + +describe('resolveApiKeyStatus', () => { + const now = new Date('2026-08-07T00:00:00Z').getTime() + + it('derives active, disabled, and expired states from the credential lifecycle', () => { + expect(resolveApiKeyStatus(baseKey, now)).toBe('active') + expect(resolveApiKeyStatus({ ...baseKey, disabled: true }, now)).toBe('disabled') + expect(resolveApiKeyStatus({ ...baseKey, expiresAt: '2026-08-06T23:59:59Z' }, now)).toBe('expired') + }) + + it('keeps expiration authoritative when an expired key is also disabled', () => { + expect(resolveApiKeyStatus({ + ...baseKey, + disabled: true, + expiresAt: '2026-08-01T00:00:00Z', + }, now)).toBe('expired') + }) +}) diff --git a/web/src/pages/admin/ApiKeysPage.tsx b/web/src/pages/admin/ApiKeysPage.tsx index 56c9b56..77495ad 100644 --- a/web/src/pages/admin/ApiKeysPage.tsx +++ b/web/src/pages/admin/ApiKeysPage.tsx @@ -1,37 +1,70 @@ -import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react' -import { useCallback, useEffect, useState } from 'react' -import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys' +import { + Alert, + Button, + Empty, + Form, + Grid, + Input, + InputNumber, + Message, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, +} from '@arco-design/web-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { AdminDataSection } from '../../components/admin/AdminDataSection' +import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect' +import { IconCopy, IconDelete, IconList, IconPlus, IconRefresh, IconSearch } from '../../components/icons' +import { + createApiKey, + listApiKeys, + revokeApiKey, + toggleApiKey, + type ApiKeyCreateInput, + type ApiKeySummary, +} from '../../services/api-keys' import type { UserRole } from '../../services/users' -import { useAuthStore } from '../../stores/auth' import { resolveErrorMessage } from '../../utils/error' -import { isAdmin, roleLabel } from '../../utils/permissions' import { formatDateTime } from '../../utils/format' +import { roleLabel } from '../../utils/permissions' -const roleOptions = [ - { label: '管理员 (admin)', value: 'admin' }, - { label: '运维 (operator)', value: 'operator' }, - { label: '只读 (viewer)', value: 'viewer' }, -] +type ApiKeyStatus = 'active' | 'disabled' | 'expired' +type ApiKeyStatusFilter = ApiKeyStatus | 'all' + +export function resolveApiKeyStatus(item: ApiKeySummary, now: number): ApiKeyStatus { + if (item.expiresAt && new Date(item.expiresAt).getTime() <= now) { + return 'expired' + } + return item.disabled ? 'disabled' : 'active' +} -// ApiKeysPage API Key 管理(admin 专属)。 -// 新创建的 Key 明文只返回一次,需要用户立即保存。 export function ApiKeysPage() { - const user = useAuthStore((s) => s.user) + const navigate = useNavigate() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [query, setQuery] = useState('') + const [roleFilter, setRoleFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') const [modalVisible, setModalVisible] = useState(false) const [draft, setDraft] = useState({ name: '', role: 'viewer', ttlHours: 0 }) const [submitting, setSubmitting] = useState(false) - const [plainKey, setPlainKey] = useState('') + const [rowAction, setRowAction] = useState('') + const [plainKey, setPlainKey] = useState('') const load = useCallback(async () => { setLoading(true) try { setItems(await listApiKeys()) setError('') - } catch (e) { - setError(resolveErrorMessage(e, '加载 API Key 失败')) + } catch (loadError) { + setError(resolveErrorMessage(loadError, '加载 API Key 失败')) } finally { setLoading(false) } @@ -41,47 +74,81 @@ export function ApiKeysPage() { void load() }, [load]) + const now = useMemo(() => Date.now(), [items]) + const filteredItems = useMemo(() => { + const keyword = query.trim().toLowerCase() + return items.filter((item) => { + const matchesQuery = !keyword || [item.name, item.prefix, item.createdBy] + .some((value) => value?.toLowerCase().includes(keyword)) + const matchesRole = roleFilter === 'all' || item.role === roleFilter + const matchesStatus = statusFilter === 'all' || resolveApiKeyStatus(item, now) === statusFilter + return matchesQuery && matchesRole && matchesStatus + }) + }, [items, now, query, roleFilter, statusFilter]) + + const activeCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'active').length + const disabledCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'disabled').length + const expiredCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'expired').length + const usedCount = items.filter((item) => Boolean(item.lastUsedAt)).length + const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all' + function openCreate() { setDraft({ name: '', role: 'viewer', ttlHours: 0 }) setPlainKey('') setModalVisible(true) } + function closeModal() { + setModalVisible(false) + setPlainKey('') + } + async function handleSubmit() { - if (!draft.name.trim()) { + const payload = { ...draft, name: draft.name.trim(), ttlHours: Number(draft.ttlHours ?? 0) } + if (!payload.name) { Message.error('名称不能为空') return } + if (payload.ttlHours < 0 || payload.ttlHours > 87600) { + Message.error('有效期需要在 0 到 87600 小时之间') + return + } + setSubmitting(true) try { - const result = await createApiKey(draft) + const result = await createApiKey(payload) setPlainKey(result.plainKey) await load() - } catch (e) { - Message.error(resolveErrorMessage(e, '创建失败')) + } catch (submitError) { + Message.error(resolveErrorMessage(submitError, '创建失败')) } finally { setSubmitting(false) } } async function handleToggle(item: ApiKeySummary) { + setRowAction(`toggle:${item.id}`) try { await toggleApiKey(item.id, !item.disabled) - Message.success(item.disabled ? '已启用' : '已停用') + Message.success(item.disabled ? 'API Key 已启用' : 'API Key 已停用') await load() - } catch (e) { - Message.error(resolveErrorMessage(e, '操作失败')) + } catch (toggleError) { + Message.error(resolveErrorMessage(toggleError, '操作失败')) + } finally { + setRowAction('') } } async function handleRevoke(item: ApiKeySummary) { - if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return + setRowAction(`revoke:${item.id}`) try { await revokeApiKey(item.id) - Message.success('已撤销') + Message.success('API Key 已撤销') await load() - } catch (e) { - Message.error(resolveErrorMessage(e, '撤销失败')) + } catch (revokeError) { + Message.error(resolveErrorMessage(revokeError, '撤销失败')) + } finally { + setRowAction('') } } @@ -95,83 +162,221 @@ export function ApiKeysPage() { } } - if (!isAdmin(user)) { - return - } - return ( - - - API Key - - 签发 API Key 供 CI/CD、监控脚本等非交互式场景访问 BackupX。在请求头加 Authorization: Bearer bax_xxx 或 X-Api-Key: bax_xxx 即可。 - - - - - 生成 API Key - - - {error ? {error} : null} - - - } - columns={[ - { title: '名称', dataIndex: 'name' }, - { title: '角色', dataIndex: 'role', render: (v: string) => {roleLabel(v)} }, - { title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => {v}… }, - { title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' }, - { title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' }, - { title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' }, - { title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? 已停用 : 启用 }, - { title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => ( - - void handleToggle(row)}>{row.disabled ? '启用' : '停用'} - void handleRevoke(row)}>撤销 - - ) }, - ]} - /> - + + } onClick={() => navigate('/audit?category=api_key')}>密钥审计 + } onClick={openCreate}>生成 API Key + + )} + toolbar={( + + + } + value={query} + aria-label="搜索 API Key" + placeholder="搜索名称、前缀或创建者" + onChange={setQuery} + /> + setRoleFilter(value as UserRole | 'all')} + /> + setStatusFilter(value as ApiKeyStatusFilter)} + /> + { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }} + > + 清除筛选 + + + + 显示 {filteredItems.length} / {items.length} + } loading={loading} onClick={() => void load()}>刷新 + + + )} + > + {error ? ( + + + + ) : null} + 10 ? { pageSize: 10 } : false} + noDataElement={} + columns={[ + { + title: '名称', + dataIndex: 'name', + width: 190, + render: (value: string, row: ApiKeySummary) => ( + + {value} + + 由 {row.createdBy || '-'} 创建 · {formatDateTime(row.createdAt)} + + + ), + }, + { + title: '角色', + dataIndex: 'role', + width: 90, + render: (value: string) => {roleLabel(value)}, + }, + { + title: 'Key 前缀', + dataIndex: 'prefix', + width: 135, + render: (value: string) => {value}…, + }, + { + title: '最近使用', + dataIndex: 'lastUsedAt', + width: 160, + render: (value?: string) => value ? {formatDateTime(value)} : '从未使用', + }, + { + title: '有效期', + dataIndex: 'expiresAt', + width: 160, + render: (value?: string) => value ? {formatDateTime(value)} : '永不过期', + }, + { + title: '状态', + dataIndex: 'disabled', + width: 90, + render: (_: boolean, row: ApiKeySummary) => { + const status = resolveApiKeyStatus(row, now) + if (status === 'expired') return 已过期 + if (status === 'disabled') return 已停用 + return 当前可用 + }, + }, + { + title: '操作', + width: 150, + render: (_: unknown, row: ApiKeySummary) => { + const expired = resolveApiKeyStatus(row, now) === 'expired' + return ( + + {expired ? ( + + 启用 + + ) : ( + void handleToggle(row)} + > + {row.disabled ? '启用' : '停用'} + + )} + handleRevoke(row)} + > + } + loading={rowAction === `revoke:${row.id}`} + > + 撤销 + + + + ) + }, + }, + ]} + /> { setModalVisible(false); setPlainKey('') }} - onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit} - okText={plainKey ? '完成' : '生成'} + title={plainKey ? '保存 API Key' : '生成 API Key'} + style={{ width: 640 }} + onCancel={closeModal} + onOk={plainKey ? closeModal : handleSubmit} + okText={plainKey ? '我已保存' : '生成'} confirmLoading={submitting} unmountOnExit > {plainKey ? ( - - - - void copyPlainKey()}>复制到剪贴板 - + + + + } onClick={() => void copyPlainKey()}>复制到剪贴板 + ) : ( - setDraft({ ...draft, name: v })} placeholder="例如:ci-deploy-script" /> - - - setDraft({ ...draft, role: v })} /> - - - setDraft({ ...draft, ttlHours: Number(v ?? 0) })} /> + setDraft({ ...draft, name: value })} + /> + + + + setDraft({ ...draft, role })} /> + {adminRoleDescriptions[draft.role]} + + + + + setDraft({ ...draft, ttlHours: Number(value ?? 0) })} + /> + 0 表示永不过期;自动化凭据建议设置明确有效期。 + + + )} - + ) } - -// 避免未使用告警 -void Switch diff --git a/web/src/pages/admin/UsersPage.tsx b/web/src/pages/admin/UsersPage.tsx index 61b18c8..d45f9ca 100644 --- a/web/src/pages/admin/UsersPage.tsx +++ b/web/src/pages/admin/UsersPage.tsx @@ -1,40 +1,71 @@ -import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react' -import { useCallback, useEffect, useState } from 'react' -import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users' +import { + Alert, + Button, + Empty, + Form, + Grid, + Input, + Message, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + Tooltip, + Typography, +} from '@arco-design/web-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { AdminDataSection } from '../../components/admin/AdminDataSection' +import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect' +import { IconDelete, IconEdit, IconList, IconPlus, IconRefresh, IconSafe, IconSearch } from '../../components/icons' import { clearTrustedDeviceToken } from '../../services/auth' +import { + createUser, + deleteUser, + listUsers, + resetUserTwoFactor, + updateUser, + type UserRole, + type UserSummary, + type UserUpsertPayload, +} from '../../services/users' import { useAuthStore } from '../../stores/auth' import { resolveErrorMessage } from '../../utils/error' -import { isAdmin, roleLabel } from '../../utils/permissions' +import { formatDateTime } from '../../utils/format' +import { roleLabel } from '../../utils/permissions' -const roleOptions = [ - { label: '管理员 (admin)', value: 'admin' }, - { label: '运维 (operator)', value: 'operator' }, - { label: '只读 (viewer)', value: 'viewer' }, -] +type UserStatusFilter = 'all' | 'enabled' | 'disabled' function createEmpty(): UserUpsertPayload { return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false } } -// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。 export function UsersPage() { - const user = useAuthStore((s) => s.user) - const setUser = useAuthStore((s) => s.setUser) + const navigate = useNavigate() + const user = useAuthStore((state) => state.user) + const setUser = useAuthStore((state) => state.setUser) const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [query, setQuery] = useState('') + const [roleFilter, setRoleFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') const [editing, setEditing] = useState(null) const [modalVisible, setModalVisible] = useState(false) const [draft, setDraft] = useState(createEmpty()) const [submitting, setSubmitting] = useState(false) + const [rowAction, setRowAction] = useState('') const load = useCallback(async () => { setLoading(true) try { setItems(await listUsers()) setError('') - } catch (e) { - setError(resolveErrorMessage(e, '加载用户失败')) + } catch (loadError) { + setError(resolveErrorMessage(loadError, '加载用户失败')) } finally { setLoading(false) } @@ -44,6 +75,24 @@ export function UsersPage() { void load() }, [load]) + const filteredItems = useMemo(() => { + const keyword = query.trim().toLowerCase() + return items.filter((item) => { + const matchesQuery = !keyword || [item.username, item.displayName, item.email, item.phone] + .some((value) => value?.toLowerCase().includes(keyword)) + const matchesRole = roleFilter === 'all' || item.role === roleFilter + const matchesStatus = statusFilter === 'all' + || (statusFilter === 'enabled' && !item.disabled) + || (statusFilter === 'disabled' && item.disabled) + return matchesQuery && matchesRole && matchesStatus + }) + }, [items, query, roleFilter, statusFilter]) + + const enabledCount = items.filter((item) => !item.disabled).length + const adminCount = items.filter((item) => item.role === 'admin').length + const mfaCount = items.filter((item) => !item.disabled && item.mfaEnabled).length + const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all' + function openCreate() { setEditing(null) setDraft(createEmpty()) @@ -65,51 +114,65 @@ export function UsersPage() { } async function handleSubmit() { - if (!draft.username.trim() || !draft.displayName.trim()) { - Message.error('用户名与显示名称不能为空') + const payload: UserUpsertPayload = { + ...draft, + username: draft.username.trim(), + displayName: draft.displayName.trim(), + email: draft.email?.trim(), + phone: draft.phone?.trim(), + } + if (payload.username.length < 3) { + Message.error('用户名至少需要 3 个字符') + return + } + if (!payload.displayName) { + Message.error('显示名称不能为空') return } - if (!editing && !draft.password?.trim()) { - Message.error('创建用户必须设置初始密码') + if ((!editing || payload.password?.trim()) && (payload.password?.length ?? 0) < 8) { + Message.error(editing ? '新密码至少需要 8 个字符' : '初始密码至少需要 8 个字符') return } + setSubmitting(true) try { if (editing) { - const updated = await updateUser(editing.id, draft) + const updated = await updateUser(editing.id, payload) if (updated.id === user?.id) { - if (draft.password?.trim()) { + if (payload.password?.trim()) { clearTrustedDeviceToken(updated.username) } setUser(updated) } Message.success('用户已更新') } else { - await createUser(draft) + await createUser(payload) Message.success('用户已创建') } setModalVisible(false) await load() - } catch (e) { - Message.error(resolveErrorMessage(e, '保存失败')) + } catch (submitError) { + Message.error(resolveErrorMessage(submitError, '保存失败')) } finally { setSubmitting(false) } } async function handleDelete(item: UserSummary) { - if (!window.confirm(`确定删除用户「${item.username}」吗?`)) return + setRowAction(`delete:${item.id}`) try { await deleteUser(item.id) - Message.success('已删除') + Message.success('用户已删除') await load() - } catch (e) { - Message.error(resolveErrorMessage(e, '删除失败')) + } catch (deleteError) { + Message.error(resolveErrorMessage(deleteError, '删除失败')) + } finally { + setRowAction('') } } async function handleResetTwoFactor(item: UserSummary) { - if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return + setRowAction(`mfa:${item.id}`) try { const updated = await resetUserTwoFactor(item.id) if (updated.id === user?.id) { @@ -118,104 +181,274 @@ export function UsersPage() { } Message.success('MFA 已重置') await load() - } catch (e) { - Message.error(resolveErrorMessage(e, '重置 MFA 失败')) + } catch (resetError) { + Message.error(resolveErrorMessage(resetError, '重置 MFA 失败')) + } finally { + setRowAction('') } } - if (!isAdmin(user)) { - return - } + const editingSelf = editing?.id === user?.id return ( - - - 用户管理 - 管理系统账号。角色分为管理员(全权)、运维(日常运维)、只读(仪表盘)。 - - - - 新建用户 - - - {error ? {error} : null} - - - } - columns={[ - { title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => ( - - {value} - {row.displayName} - - ) }, - { title: '角色', dataIndex: 'role', render: (value: string) => {roleLabel(value)} }, - { title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => ( - - {row.email || '-'} - {row.phone || '-'} - - ) }, - { title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? 已停用 : 启用 }, - { title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? ( + + } onClick={() => navigate('/audit?category=user')}>用户审计 + } onClick={openCreate}>新建用户 + + )} + toolbar={( + + + } + value={query} + aria-label="搜索用户" + placeholder="搜索用户名、名称或联系方式" + onChange={setQuery} + /> + setRoleFilter(value as UserRole | 'all')} + /> + setStatusFilter(value as UserStatusFilter)} + /> + { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }} + > + 清除筛选 + + + + 显示 {filteredItems.length} / {items.length} + } loading={loading} onClick={() => void load()}>刷新 + + + )} + > + {error ? ( + + + + ) : null} + 10 ? { pageSize: 10 } : false} + noDataElement={} + columns={[ + { + title: '用户', + dataIndex: 'username', + width: 170, + render: (value: string, row: UserSummary) => ( + + + {value} + {row.id === user?.id ? 当前账号 : null} + + {row.displayName} + + 创建于 {formatDateTime(row.createdAt)} + + + ), + }, + { + title: '角色', + dataIndex: 'role', + width: 80, + render: (value: string) => {roleLabel(value)}, + }, + { + title: '联系方式', + dataIndex: 'email', + width: 190, + render: (_: string, row: UserSummary) => ( + + {row.email || '未配置邮箱'} + {row.phone || '未配置手机号'} + + ), + }, + { + title: '状态', + dataIndex: 'disabled', + width: 80, + render: (disabled: boolean) => disabled + ? 已停用 + : 已启用, + }, + { + title: '多因素认证', + dataIndex: 'mfaEnabled', + width: 210, + render: (_: boolean, row: UserSummary) => row.mfaEnabled ? ( {row.twoFactorEnabled ? TOTP : null} {row.webAuthnEnabled ? Passkey {row.webAuthnCredentialCount} : null} {row.emailOtpEnabled ? 邮件 : null} {row.smsOtpEnabled ? 短信 : null} - {row.twoFactorEnabled ? 恢复码 {row.twoFactorRecoveryCodesRemaining} : null} - - ) : 未启用 }, - { title: '创建时间', dataIndex: 'createdAt' }, - { title: '操作', width: 260, render: (_: unknown, row: UserSummary) => ( - - openEdit(row)}>编辑 - {row.mfaEnabled && void handleResetTwoFactor(row)}>重置 MFA} - void handleDelete(row)} disabled={row.id === user?.id}>删除 + {row.trustedDeviceCount > 0 ? 可信设备 {row.trustedDeviceCount} : null} + {row.twoFactorEnabled ? 恢复码 {row.twoFactorRecoveryCodesRemaining} : null} - ) }, - ]} - /> - + ) : 未启用, + }, + { + title: '操作', + width: 270, + render: (_: unknown, row: UserSummary) => { + const deleteDisabled = row.id === user?.id || (row.role === 'admin' && adminCount <= 1) + const deleteReason = row.id === user?.id ? '不能删除当前登录账号' : '不能删除系统最后一个管理员' + return ( + + } onClick={() => openEdit(row)}>编辑 + {row.mfaEnabled ? ( + handleResetTwoFactor(row)} + > + } + loading={rowAction === `mfa:${row.id}`} + > + 重置 MFA + + + ) : null} + {deleteDisabled ? ( + + + } disabled>删除 + + + ) : ( + handleDelete(row)} + > + } + loading={rowAction === `delete:${row.id}`} + > + 删除 + + + )} + + ) + }, + }, + ]} + /> setModalVisible(false)} onOk={handleSubmit} confirmLoading={submitting} unmountOnExit > - - setDraft({ ...draft, username: v })} disabled={!!editing} /> - - - setDraft({ ...draft, displayName: v })} /> - - - setDraft({ ...draft, email: v })} /> - - - setDraft({ ...draft, phone: v })} /> - + + + + setDraft({ ...draft, username: value })} + /> + + + + + setDraft({ ...draft, displayName: value })} /> + + + + + + + setDraft({ ...draft, email: value })} /> + + + + + setDraft({ ...draft, phone: value })} /> + + + - setDraft({ ...draft, password: v })} /> - - - setDraft({ ...draft, role: v })} /> - - - setDraft({ ...draft, disabled: v })} /> + setDraft({ ...draft, password: value })} + /> + + + + setDraft({ ...draft, role })} + /> + + {editingSelf ? '当前登录账号不能在此修改自身角色。' : adminRoleDescriptions[draft.role]} + + + + + + + setDraft({ ...draft, disabled: !enabled })} + /> + {draft.disabled ? '已停用' : '已启用'} + + {editingSelf ? 当前登录账号不能停用自身。 : null} + + + - + ) } diff --git a/web/src/pages/admin/admin.css b/web/src/pages/admin/admin.css new file mode 100644 index 0000000..e84d917 --- /dev/null +++ b/web/src/pages/admin/admin.css @@ -0,0 +1,178 @@ +.admin-page { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; +} + +.admin-page__header { + padding: 0; +} + +.admin-page__nav { + display: flex; + gap: 8px; + padding: 8px; + border: 1px solid var(--color-border-2); + border-radius: 4px; + background: var(--color-bg-2); +} + +.admin-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.admin-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.admin-section__title { + margin: 0 0 4px; +} + +.admin-section__description { + max-width: 760px; + margin: 0; +} + +.admin-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 24px; + padding: 16px 20px; + border: 1px solid var(--color-border-2); + border-radius: 4px; + background: var(--color-bg-2); +} + +.admin-summary__item { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.admin-summary__value { + color: var(--color-text-1); + font-size: 24px; + line-height: 32px; +} + +.admin-summary__detail { + overflow: hidden; + color: var(--color-text-3); + font-size: 12px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-data-panel { + overflow: hidden; + border: 1px solid var(--color-border-2); + border-radius: 4px; + background: var(--color-bg-2); +} + +.admin-data-panel__alert { + padding: 12px 16px 0; +} + +.admin-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border-2); +} + +.admin-toolbar__filters, +.admin-toolbar__status { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-identity { + display: flex; + flex-direction: column; + gap: 2px; +} + +.admin-identity__secondary, +.admin-contact__secondary { + overflow: hidden; + color: var(--color-text-3); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-identity > .arco-typography { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-contact { + display: flex; + flex-direction: column; + gap: 2px; +} + +.admin-contact > .arco-typography { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-date, +.admin-key-prefix { + font-size: 12px; + white-space: nowrap; +} + +.admin-form-note { + display: block; + margin-top: 6px; + color: var(--color-text-3); + font-size: 12px; + line-height: 20px; +} + +.admin-switch-field { + display: flex; + align-items: center; + min-height: 32px; + gap: 8px; +} + +.admin-key-result { + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (max-width: 1440px) { + .admin-summary { + gap: 16px; + } + + .admin-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .admin-toolbar__status { + align-self: stretch; + justify-content: flex-end; + } +} diff --git a/web/src/pages/audit/AuditLogsPage.tsx b/web/src/pages/audit/AuditLogsPage.tsx index aeabf0b..942b18d 100644 --- a/web/src/pages/audit/AuditLogsPage.tsx +++ b/web/src/pages/audit/AuditLogsPage.tsx @@ -1,6 +1,7 @@ import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react' import type { ColumnProps } from '@arco-design/web-react/es/Table' import { useCallback, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { exportAuditLogs, listAuditLogs } from '../../services/audit' import { fetchSettings, updateSettings } from '../../services/system' import { useAuthStore } from '../../stores/auth' @@ -16,6 +17,8 @@ const categoryOptions = [ { label: '备份任务', value: 'backup_task' }, { label: '备份记录', value: 'backup_record' }, { label: '系统设置', value: 'settings' }, + { label: '用户账号', value: 'user' }, + { label: 'API Key', value: 'api_key' }, ] const categoryLabels: Record = { @@ -24,6 +27,8 @@ const categoryLabels: Record = { backup_task: '备份任务', backup_record: '备份记录', settings: '系统设置', + user: '用户账号', + api_key: 'API Key', } const actionLabels: Record = { @@ -53,6 +58,7 @@ const actionLabels: Record = { delete: '删除', enable: '启用', disable: '停用', + revoke: '撤销', run: '执行', restore: '恢复', } @@ -105,11 +111,15 @@ const columns: ColumnProps[] = [ ] export function AuditLogsPage() { + const [searchParams, setSearchParams] = useSearchParams() + const requestedCategory = searchParams.get('category') ?? '' const [logs, setLogs] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [category, setCategory] = useState('') + const [category, setCategory] = useState( + categoryOptions.some((option) => option.value === requestedCategory) ? requestedCategory : '', + ) const [username, setUsername] = useState('') const [keyword, setKeyword] = useState('') const [dateRange, setDateRange] = useState(null) @@ -190,6 +200,7 @@ export function AuditLogsPage() { function handleReset() { setCategory('') + setSearchParams({}, { replace: true }) setUsername('') setKeyword('') setDateRange(null) @@ -230,7 +241,11 @@ export function AuditLogsPage() { style={{ width: 160 }} value={category} options={categoryOptions} - onChange={(v) => { setCategory(v); setPage(1) }} + onChange={(v) => { + setCategory(v) + setSearchParams(v ? { category: v } : {}, { replace: true }) + setPage(1) + }} placeholder="分类" /> } /> } /> } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> } /> } />