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 ( + } + value={query} + aria-label="搜索 API Key" + placeholder="搜索名称、前缀或创建者" + onChange={setQuery} + /> + setStatusFilter(value as ApiKeyStatusFilter)} + /> + + +
+ 显示 {filteredItems.length} / {items.length} + +
+ + )} + > + {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 ? ( + + + + ) : ( + + )} + handleRevoke(row)} + > + + + + ) + }, + }, + ]} + /> { 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 ? ( - - - - - +
+ + + +
) : (
- setDraft({ ...draft, name: v })} placeholder="例如:ci-deploy-script" /> - - - 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 ? ( + + + + + )} + toolbar={( +
+
+ } + value={query} + aria-label="搜索用户" + placeholder="搜索用户名、名称或联系方式" + onChange={setQuery} + /> + setStatusFilter(value as UserStatusFilter)} + /> + +
+
+ 显示 {filteredItems.length} / {items.length} + +
+
+ )} + > + {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) => ( - - - {row.mfaEnabled && } - + {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 ( + + + {row.mfaEnabled ? ( + handleResetTwoFactor(row)} + > + + + ) : null} + {deleteDisabled ? ( + + + + + + ) : ( + handleDelete(row)} + > + + + )} + + ) + }, + }, + ]} + /> 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 })} /> - - - } /> } /> } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> } /> } />