diff --git a/web/e2e/admin-console.spec.ts b/web/e2e/admin-console.spec.ts new file mode 100644 index 00000000..8f72376f --- /dev/null +++ b/web/e2e/admin-console.spec.ts @@ -0,0 +1,40 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * 系统管理台最小冒烟:admin 登录 → 进入管理台 → 分组导航渲染 → 返回工作台。 + * 与其他 e2e 一致:仅 RUN_UI_ACCEPTANCE=1 且对接真实 dev 栈时运行(无 mock), + * 需要 ADMIN_USERNAME / ADMIN_PASSWORD 环境变量。 + */ +const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; +const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; + +test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the real dev stack'); + +async function login(page: Page, username: string, password: string) { + await page.goto('/'); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +test('admin console smoke: enter, nav renders, exit back to workbench', async ({ page }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + await login(page, ADMIN_USER, ADMIN_PASS); + + // 进入系统管理台(LeftRail 管理盾牌按钮,仅管理员可见) + await page.click('button[title*="系统管理与准入治理控制台"]'); + await expect(page.getByText('系统管理', { exact: true })).toBeVisible({ timeout: 15_000 }); + + // 左侧分组导航渲染 + for (const label of ['系统运行总览', '知识库连接', '特性功能开关', '系统设置白名单', '工作流定义', 'RBAC 角色权限', '用户与准入治理', '文档与模板库']) { + await expect(page.getByRole('button', { name: label })).toBeVisible(); + } + + // 返回工作台 + await page.getByRole('button', { name: '返回工作台' }).first().click(); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 15_000 }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index bec94c67..262899d1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -99,7 +99,7 @@ import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard'; import { UserProjectRightsModal } from './components/user/UserProjectRightsModal'; import { UserSettingsModal } from './components/user/UserSettingsModal'; -import { AdminSettingsModal } from './components/admin/AdminSettingsModal'; +import { AdminConsoleView } from './components/admin/console/AdminConsoleView'; import { DocVisibilityModal } from './components/doc/DocVisibilityModal'; import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal'; import { ProjectMembersModal } from './components/project/ProjectMembersModal'; @@ -478,7 +478,8 @@ export default function App() { // 模态浮层控制 const [rightsModalOpen, setRightsModalOpen] = useState(false); const [userSettingsOpen, setUserSettingsOpen] = useState(false); - const [adminSettingsOpen, setAdminSettingsOpen] = useState(false); + // 顶层视图切换:单屏工作台 / 全屏系统管理台(仅管理员可进入) + const [view, setView] = useState<'workbench' | 'admin'>('workbench'); const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false); const [targetDocForVisibility, setTargetDocForVisibility] = useState(null); @@ -3746,6 +3747,22 @@ export default function App() { } }; + // 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell + if (view === 'admin') { + return ( + setView('workbench')} + onUsersChanged={async () => { + const users = await authClient.getAdminUsers(); + setAllUsers(users); + }} + onRolesChanged={(newRoles) => setRoles(newRoles)} + /> + ); + } + return ( <> {}} onOpenRightsModal={() => setRightsModalOpen(true)} onOpenUserSettings={() => setUserSettingsOpen(true)} - onOpenAdminSettings={() => setAdminSettingsOpen(true)} + onOpenAdminSettings={() => setView('admin')} onOpenProjectMembers={() => setProjectMembersModalOpen(true)} activeWorkflowKey={currentWfState.activeKey} workflowVersion={currentWfState.version} @@ -4260,32 +4277,6 @@ export default function App() { onLogout={handleLogout} /> - {/* 系统管理员独占治理控制台 (仅 role-admin 可入) */} - setAdminSettingsOpen(false)} - currentUser={currentUser} - roles={roles} - onSaveRoles={(newRoles) => setRoles(newRoles)} - allUsers={allUsers} - onUpdateUserRole={(userId, roleId) => { - const targetRole = roles.find((r) => r.id === roleId); - setAllUsers((prev) => - prev.map((u) => (u.id === userId ? { ...u, roleId, position: targetRole?.name || u.position } : u)) - ); - if (currentUser.id === userId) { - setCurrentUser((prev) => ({ ...prev, roleId, position: targetRole?.name || prev.position })); - } - }} - systemConfig={systemAuthConfig} - onUpdateSystemConfig={(cfg) => setSystemAuthConfig((prev) => ({ ...prev, ...cfg }))} - onRefreshUsers={async () => { - const users = await authClient.getAdminUsers(); - setAllUsers(users); - }} - env={systemEnv} - /> - {/* 单文档可见性覆盖弹窗 */} { + let err: Error; + try { + const text = await resp.text(); + err = text ? new Error(text.slice(0, 200)) : new Error(`${fallback} (HTTP ${resp.status})`); + } catch { + err = new Error(`${fallback} (HTTP ${resp.status})`); + } + (err as Error & { status?: number }).status = resp.status; + return err; +} + +async function getJson(path: string, fallback: string): Promise { + const resp = await authorizedFetch(path); + if (!resp.ok) throw await readError(resp, fallback); + return resp.json() as Promise; +} + +export async function fetchSystemOverview(): Promise { + return getJson(`${BASE}/overview`, '获取系统总览失败'); +} + +export async function fetchSystemKb(): Promise { + return getJson(`${BASE}/kb`, '获取知识库连接信息失败'); +} + +export async function fetchSystemFeatures(): Promise { + const data = await getJson<{ features?: SystemFeature[] }>(`${BASE}/features`, '获取特性开关失败'); + return data.features ?? []; +} + +export async function fetchSystemSettings(): Promise { + const data = await getJson<{ settings?: SystemSetting[] }>(`${BASE}/settings`, '获取系统设置失败'); + return data.settings ?? []; +} + +export async function updateSystemSetting( + key: string, + value: SystemConfigValue, +): Promise { + const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }); + if (!resp.ok) throw await readError(resp, '保存设置失败'); + const data = (await resp.json()) as { setting?: SystemSetting } & SystemSetting; + return data.setting ?? data; +} diff --git a/web/src/components/admin/AdminSettingsModal.tsx b/web/src/components/admin/AdminSettingsModal.tsx deleted file mode 100644 index 5699d793..00000000 --- a/web/src/components/admin/AdminSettingsModal.tsx +++ /dev/null @@ -1,1677 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - ShieldAlert, - Users, - UserCheck, - Plus, - Trash2, - Check, - X, - AlertCircle, - CheckCircle2, - Sliders, - Activity, - Shield, - Search, - UserPlus, - GitBranch, -} from 'lucide-react'; -import { UserProfile } from '../../types/docgov'; -import { RoleDefinition, PermissionKey } from '../../types/rbac'; -import { SystemAuthConfig, UserAccount } from '../../types/auth'; -import { authClient } from '../../api/auth'; -import { - listAdminWorkflows, - getAdminWorkflow, - validateWorkflowVersion, - publishWorkflowVersion, - listAdminTemplates, - WorkflowDefinitionSummary, - WorkflowVersionSummary, -} from '../../api/workflowAdmin'; -import { syncAdminUserDirectories } from '../../lib/adminUserDirectorySync'; - -export interface AdminSettingsModalProps { - isOpen: boolean; - onClose: () => void; - currentUser: UserProfile; - roles: RoleDefinition[]; - onSaveRoles: (roles: RoleDefinition[]) => void; - allUsers: UserProfile[]; - onUpdateUserRole: (userId: number, roleId: string) => void; - onRefreshUsers: () => void | Promise; - systemConfig: SystemAuthConfig; - onUpdateSystemConfig: (config: Partial) => void; - env?: string; -} - -const ALL_PERMISSION_ITEMS: { - key: PermissionKey; - label: string; - desc: string; - category: '项目与流程' | '权限与安全' | '文档与导出'; -}[] = [ - { - key: 'project:create', - label: '创建新项目/立项', - desc: '允许在协同工作台左侧栏发起新医疗器械立项项目并克隆模板', - category: '项目与流程', - }, - { - key: 'project:assign_lead', - label: '设置/变更文档责任人', - desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人', - category: '项目与流程', - }, - { - key: 'perm:manage', - label: '系统权限与角色管理', - desc: '管理员超级特权,可维护角色库、审批账号与配置准入策略', - category: '权限与安全', - }, - { - key: 'workflow:config_visibility', - label: '配置工作流默认可见性', - desc: '调整整个业务阶段中各受控交付物的基线查阅准入边界', - category: '权限与安全', - }, - { - key: 'doc:override_visibility', - label: '配置单文档可见性覆盖', - desc: '对特定涉密或专精文档设置独立白名单或责任人限制', - category: '权限与安全', - }, - { - key: 'workflow:view_all', - label: '查阅常规工作流受控文档', - desc: '是否可浏览项目内的受控工作流交付物大盘', - category: '文档与导出', - }, - { - key: 'doc:export', - label: '导出受控成果物 (DOCX/XLSX)', - desc: '允许将交付物编译导出为合规 DOCX 报告或计算 XLSX 表格', - category: '文档与导出', - }, -]; - -export const AdminSettingsModal: React.FC = ({ - isOpen, - onClose, - currentUser, - roles, - onSaveRoles, - allUsers: _allUsers, - onUpdateUserRole, - onRefreshUsers, - systemConfig, - onUpdateSystemConfig, - env = 'dev', -}) => { - const [activeTab, setActiveTab] = useState< - 'users' | 'approvals' | 'policy' | 'roles' | 'workflows' | 'status' - >('users'); - - // 全量账号列表(含待审核及禁用状态) - const [adminUsers, setAdminUsers] = useState([]); - const [userSearchText, setUserSearchText] = useState(''); - const [actionTip, setActionTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); - - // 角色库与编辑态 - const [editRoles, setEditRoles] = useState(roles); - const [selectedRoleId, setSelectedRoleId] = useState(roles[0]?.id || 'role-admin'); - const [isCreatingRole, setIsCreatingRole] = useState(false); - const [newRoleName, setNewRoleName] = useState(''); - const [newRoleCode, setNewRoleCode] = useState(''); - const [newRoleDesc, setNewRoleDesc] = useState(''); - - // 管理员新建用户表单 - const [showCreateUserModal, setShowCreateUserModal] = useState(false); - const [newUname, setNewUname] = useState(''); - const [newRealName, setNewRealName] = useState(''); - const [newInitPass, setNewInitPass] = useState('InitPass123'); - const [newRoleId, setNewRoleId] = useState('role-engineer'); - const [newPosition, setNewPosition] = useState('方案工程师'); - const [newDept, setNewDept] = useState('研发中心'); - - // 准入策略本地状态 - const [allowReg, setAllowReg] = useState(systemConfig.allowRegistration); - const [requireAppr, setRequireAppr] = useState(systemConfig.requireApproval); - - const [adminWorkflows, setAdminWorkflows] = useState([]); - const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); - const [workflowVersions, setWorkflowVersions] = useState([]); - const [adminTemplates, setAdminTemplates] = useState>([]); - const [validationMsg, setValidationMsg] = useState(''); - - // 刷新全量用户列表,并同步父级 App allUsers(立项成员候选依赖此目录) - const refreshUsers = async () => { - try { - await syncAdminUserDirectories({ - reloadLocalUsers: async () => { - const list = await authClient.listAllUsers(); - setAdminUsers(list); - }, - onRefreshUsers, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : '刷新/同步用户目录失败'; - setActionTip({ type: 'error', msg }); - throw err; - } - }; - - const refreshWorkflows = async () => { - try { - const list = await listAdminWorkflows(); - setAdminWorkflows(list); - const templates = await listAdminTemplates(); - setAdminTemplates(templates.map((t) => ({ asset_key: t.asset_key, name: t.name }))); - } catch { - setAdminWorkflows([]); - } - }; - - const loadWorkflowDetail = async (id: number) => { - setSelectedWorkflowId(id); - try { - const detail = await getAdminWorkflow(id); - setWorkflowVersions(detail.versions ?? []); - } catch { - setWorkflowVersions([]); - } - }; - - const handleValidateVersion = async (workflowId: number, version: number) => { - try { - const res = await validateWorkflowVersion(workflowId, version); - if (res.valid) { - setValidationMsg(`v${version} 校验通过`); - } else { - setValidationMsg(res.issues.map((i) => i.message).join(';')); - } - } catch (err: any) { - setValidationMsg(err.message || '校验失败'); - } - }; - - const handlePublishVersion = async (workflowId: number, version: number) => { - if (!window.confirm(`确认发布工作流版本 v${version}?发布后不可原地修改。`)) return; - try { - await publishWorkflowVersion(workflowId, version); - showNotification('success', `已发布 v${version}`); - refreshWorkflows(); - loadWorkflowDetail(workflowId); - } catch (err: any) { - showNotification('error', err.message || '发布失败'); - } - }; - - const refreshRoles = async () => { - try { - const list = await authClient.listRoles(); - setEditRoles(list); - } catch { - setEditRoles(roles); - } - }; - - useEffect(() => { - if (isOpen) { - void refreshUsers().catch(() => { - // 错误已写入 actionTip;打开时失败不阻断模态 - }); - refreshRoles(); - refreshWorkflows(); - setAllowReg(systemConfig.allowRegistration); - setRequireAppr(systemConfig.requireApproval); - } - }, [isOpen, roles, systemConfig]); - - if (!isOpen) return null; - - const showNotification = (type: 'success' | 'error', msg: string) => { - setActionTip({ type, msg }); - setTimeout(() => setActionTip(null), 3000); - }; - - // 1. 人员职位与角色分配 - const handleUserRoleChange = async (userId: number, roleId: string) => { - const role = editRoles.find((r) => r.id === roleId); - try { - await authClient.updateUserRole(userId, roleId, role?.name || '方案工程师'); - onUpdateUserRole(userId, roleId); - } catch (err: any) { - showNotification('error', err.message || '更新角色失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', '已成功变更该成员的所属职位与系统角色'); - } catch { - showNotification('error', '角色已更新,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 2. 账号状态(激活/停用) - const handleToggleUserStatus = async (user: UserAccount) => { - const nextStatus = user.status === 'active' ? 'disabled' : 'active'; - try { - await authClient.updateUserStatus(user.id, nextStatus); - } catch (err: any) { - showNotification('error', err.message || '切换状态失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已将账号【${user.name}】状态切换为:${nextStatus === 'active' ? '启用' : '停用'}`); - } catch { - showNotification('error', '账号状态已更新,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 3. 审批通过或驳回 - const handleApproveRegistration = async (user: UserAccount, targetRoleId: string) => { - const role = editRoles.find((r) => r.id === targetRoleId); - try { - await authClient.updateUserStatus(user.id, 'active', targetRoleId, role?.name || '方案工程师'); - } catch (err: any) { - showNotification('error', err.message || '审批操作失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已批准【${user.name}】注册申请,已正式授予【${role?.name}】职位`); - } catch { - showNotification('error', '审批已生效,但全局用户目录同步失败,请刷新后重试'); - } - }; - - const handleRejectRegistration = async (user: UserAccount) => { - if (!window.confirm(`确定要驳回【${user.name}】的注册申请吗?该账号将被置为禁用。`)) return; - try { - await authClient.updateUserStatus(user.id, 'disabled'); - } catch (err: any) { - showNotification('error', err.message || '驳回操作失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已驳回【${user.name}】的注册申请`); - } catch { - showNotification('error', '驳回已生效,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 4. 重置用户密码为初始并强制改密 - const handleResetUserPassword = async (user: UserAccount) => { - const tempPass = 'ResetPass123'; - if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return; - try { - await authClient.adminResetPassword(user.id, tempPass); - } catch (err: any) { - showNotification('error', err.message || '重置密码失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); - } catch { - showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 5. 管理员新建账号 - const handleCreateUserSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newUname.trim() || !newRealName.trim() || !newInitPass) { - showNotification('error', '请填写完整账号、姓名与初始密码'); - return; - } - try { - await authClient.createAdminUser({ - username: newUname.trim(), - name: newRealName.trim(), - initialPassword: newInitPass, - roleId: newRoleId, - position: newPosition, - department: newDept, - }); - setShowCreateUserModal(false); - setNewUname(''); - setNewRealName(''); - try { - await refreshUsers(); - showNotification('success', `已成功开通受控账号【${newRealName}】,已自动标记首次登录强制改密`); - } catch { - showNotification( - 'error', - `账号【${newRealName}】已开通,但全局用户目录同步失败,立项成员候选可能暂不可见该账号`, - ); - } - } catch (err: any) { - showNotification('error', err.message || '新建员工账号失败'); - } - }; - - // 6. 保存准入策略 - const handleSavePolicy = async () => { - try { - await authClient.updateSystemAuthConfig({ - allowRegistration: allowReg, - requireApproval: requireAppr, - }); - onUpdateSystemConfig({ - allowRegistration: allowReg, - requireApproval: requireAppr, - }); - showNotification('success', '系统准入策略已即刻全局生效'); - } catch (err: any) { - showNotification('error', err.message || '更新策略失败'); - } - }; - - // 7. 角色权限矩阵操作 - const currentSelectedRole = editRoles.find((r) => r.id === selectedRoleId) || editRoles[0]; - - const handleTogglePermission = async (permKey: PermissionKey) => { - if (!currentSelectedRole || currentSelectedRole.isSystem) return; - const exists = currentSelectedRole.permissions.includes(permKey); - const updatedPermissions = exists - ? currentSelectedRole.permissions.filter((k) => k !== permKey) - : [...currentSelectedRole.permissions, permKey]; - try { - const updated = await authClient.updateRole(currentSelectedRole.id, { - name: currentSelectedRole.name, - description: currentSelectedRole.description, - permissions: updatedPermissions, - }); - const updatedRoles = editRoles.map((r) => (r.id === updated.id ? updated : r)); - setEditRoles(updatedRoles); - onSaveRoles(updatedRoles); - showNotification('success', `已更新【${currentSelectedRole.name}】权限矩阵`); - } catch (err: any) { - showNotification('error', err.message || '更新权限失败'); - } - }; - - const handleCreateRole = async () => { - if (!newRoleName.trim() || !newRoleCode.trim()) return; - try { - const newRole = await authClient.createRole({ - code: newRoleCode.trim().toUpperCase(), - name: newRoleName.trim(), - description: newRoleDesc.trim() || '自定义受控职位角色', - permissions: ['workflow:view_all', 'doc:export'], - }); - const nextRoles = [...editRoles, newRole]; - setEditRoles(nextRoles); - onSaveRoles(nextRoles); - setSelectedRoleId(newRole.id); - setIsCreatingRole(false); - setNewRoleName(''); - setNewRoleCode(''); - setNewRoleDesc(''); - showNotification('success', `已创建新业务角色【${newRole.name}】`); - } catch (err: any) { - showNotification('error', err.message || '创建角色失败'); - } - }; - - const handleDeleteRole = async (roleId: string) => { - const roleToDelete = editRoles.find((r) => r.id === roleId); - if (!roleToDelete || roleToDelete.isSystem) return; - if (!window.confirm(`确定要删除角色【${roleToDelete.name}】吗?删除后该角色人员将自动退回普通工程师。`)) { - return; - } - try { - await authClient.deleteRole(roleId); - const nextRoles = editRoles.filter((r) => r.id !== roleId); - setEditRoles(nextRoles); - onSaveRoles(nextRoles); - setSelectedRoleId(nextRoles[0]?.id || 'role-admin'); - showNotification('success', `已删除角色【${roleToDelete.name}】`); - } catch (err: any) { - showNotification('error', err.message || '删除角色失败'); - } - }; - - // 待审批列表 - const pendingUsers = adminUsers.filter((u) => u.status === 'pending_approval'); - // 过滤后的用户列表 - const filteredUsers = adminUsers.filter( - (u) => - u.name.toLowerCase().includes(userSearchText.toLowerCase()) || - (u.username && u.username.toLowerCase().includes(userSearchText.toLowerCase())) || - (u.department && u.department.toLowerCase().includes(userSearchText.toLowerCase())) - ); - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
- {/* 顶部标题栏 */} -
-
-
- -
-
-
- - 系统管理控制台 - - - ADMIN 独占 - - - 运行环境: {env.toUpperCase()} - -
-
- 人员职位治理 · 注册审批中心 · 系统准入策略 · RBAC 角色权限矩阵 -
-
-
- -
- {actionTip && ( - - {actionTip.type === 'success' ? : } - {actionTip.msg} - - )} - - -
-
- - {/* 主体左右分栏 */} -
- {/* 左侧垂直导航 Tab */} -
-
- - - - - - - - - - - -
- - {/* 底部系统信息 */} -
-
水木东方医疗器械 AI 工作台
-
Auth & RBAC v2.4
-
-
- - {/* 右侧主视口 */} -
- {/* Tab 1: 人员与职位治理 */} - {activeTab === 'users' && ( -
-
-
- - setUserSearchText(e.target.value)} - placeholder="搜索姓名、用户名或部门..." - style={{ - width: '100%', - padding: '7px 10px 7px 32px', - borderRadius: '8px', - backgroundColor: 'var(--bg-app)', - border: '1px solid var(--border)', - color: 'var(--text-strong)', - fontSize: '12px', - boxSizing: 'border-box', - }} - /> -
- - -
- - {/* 用户列表表格 */} -
- - - - - - - - - - - - - {filteredUsers.map((u) => { - const isSelf = u.id === currentUser.id; - return ( - - - - - - - - - ); - })} - -
成员姓名登录账号 / 工号所属部门系统职位与角色账号状态操作治理
-
-
- {u.name.slice(0, 1)} -
- {u.name} - {isSelf && ( - - 当前账号 - - )} -
-
-
{u.username || '系统预设'}
-
{u.employeeId || 'EMP-未设定'}
-
- {u.department || '未分配部门'} - - - - - {u.status === 'active' ? '正常在职' : u.status === 'pending_approval' ? '待审核' : '已停用'} - - -
- - {!isSelf && ( - - )} -
-
-
-
- )} - - {/* Tab 2: 注册审批中心 */} - {activeTab === 'approvals' && ( -
-
-
- 新员工注册审批与职位指派中心 -
-
- 依据 NMPA 体系规范,自主注册申请需经系统管理员人工核准并正式授予业务职位。 -
-
- - {pendingUsers.length === 0 ? ( -
- -
- 当前没有待审批的注册申请 -
-
- 所有新注册员工均已完成合规审批并完成业务职位委派。 -
-
- ) : ( -
- {pendingUsers.map((u) => { - return ( -
-
-
- - {u.name} - - - 账号: {u.username} - - - 待审核 - -
-
- 部门: {u.department || '未填写'} - 工号: {u.employeeId || '未填写'} - 申请时间: {u.createdAt || '刚刚'} -
-
- -
- - - - - -
-
- ); - })} -
- )} -
- )} - - {/* Tab 3: 系统准入策略 */} - {activeTab === 'policy' && ( -
-
-
- 系统全局准入与注册审核策略 -
-
- 控制系统外部开放度,关闭自主注册或开启强制审核均实时阻断越权账号。 -
-
- - {/* 开关 1: 开放自主注册 */} -
-
-
- 允许新用户自主注册账号 -
-
- 开启后,登录页将呈现「用户自主注册」选项卡;关闭后,仅管理员可在控制台开通账号。 -
-
- setAllowReg(e.target.checked)} - style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} - /> -
- - {/* 开关 2: 注册需审核 */} -
-
-
- 新注册账号必须由管理员审核通过 -
-
- 开启后,新注册账号处于「待审核」状态禁止登录,直至管理员在审批中心核准并正式分配职位。 -
-
- setRequireAppr(e.target.checked)} - style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} - /> -
- -
- -
-
- )} - - {/* Tab 4: RBAC 角色权限矩阵 */} - {activeTab === 'roles' && ( -
- {/* 角色列表 */} -
-
-
- 全局受控角色库 ({editRoles.length}) -
- {editRoles.map((r) => { - const isSel = r.id === selectedRoleId; - return ( -
setSelectedRoleId(r.id)} - style={{ - padding: '8px 10px', - borderRadius: '6px', - backgroundColor: isSel ? 'var(--bg-panel)' : 'transparent', - border: isSel ? '1px solid var(--brand-500)' : '1px solid transparent', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }} - > -
-
- {r.name} -
-
- {r.code} · {r.permissions.length} 项权限 -
-
- {!r.isSystem && ( - - )} -
- ); - })} -
- - {/* 新增自定义角色 */} -
- {isCreatingRole ? ( -
- setNewRoleName(e.target.value)} - placeholder="角色名称 (如 临床协调员)" - style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} - /> - setNewRoleCode(e.target.value)} - placeholder="标识代号 (如 CRA)" - style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} - /> -
- - -
-
- ) : ( - - )} -
-
- - {/* 对应角色的权限复选矩阵 */} -
-
-
- - 【{currentSelectedRole?.name}】权限配置矩阵 - - {currentSelectedRole?.isSystem && ( - - 系统内置受控角色 (只读) - - )} -
-
- {currentSelectedRole?.description} -
-
- -
- {ALL_PERMISSION_ITEMS.map((item) => { - const isChecked = - currentSelectedRole?.code === 'ADMIN' || - currentSelectedRole?.permissions.includes(item.key); - const isLocked = currentSelectedRole?.isSystem; - - return ( -
!isLocked && handleTogglePermission(item.key)} - style={{ - padding: '12px 14px', - borderRadius: '8px', - backgroundColor: isChecked ? 'var(--bg-subtle)' : 'var(--bg-app)', - border: isChecked ? '1px solid var(--border)' : '1px dashed var(--border)', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - cursor: isLocked ? 'not-allowed' : 'pointer', - }} - > -
-
- - {item.label} - - - {item.category} - -
-
- {item.desc} -
-
- - {}} - style={{ width: '16px', height: '16px', accentColor: 'var(--brand-500)', cursor: isLocked ? 'not-allowed' : 'pointer' }} - /> -
- ); - })} -
-
-
- )} - - {/* Tab 5: 系统状态与审计 */} - {activeTab === 'workflows' && ( -
-

工作流定义(DB 权威)

-

- 已发布版本不可变;新项目选用固定版本快照(项目激活冻结见 Phase 3)。 -

-
-
- {adminWorkflows.map((wf) => ( - - ))} -
-
- {selectedWorkflowId && ( - <> -
版本列表
- {workflowVersions.map((v) => ( -
-
- v{v.version} · {v.status} -
-
- hash: {v.definition_hash.slice(0, 16)}… -
-
- - {v.status === 'draft' && ( - - )} -
-
- ))} - {validationMsg && ( -
{validationMsg}
- )} - - )} -
-
-
-
模板库 ({adminTemplates.length})
-
- {adminTemplates.map((t) => t.asset_key).join(', ') || '暂无模板(需 PG 环境 bootstrap)'} -
-
-
- )} - - {activeTab === 'status' && ( -
-
-
- 系统安全与运行状态 -
-
- 环境拓扑隔离与安全凭据审计。 -
-
- -
-
-
当前运行环境态
-
- {env.toUpperCase()} -
-
- {env === 'dev' ? '开发态 (允许测试快速身份切换)' : '正式受控态 (强制真实登出/登录)'} -
-
- -
-
密码加密算法
-
- Bcrypt (加盐哈希) -
-
- 电子签名与审计事件由服务端记录并防篡改校验 -
-
- -
-
当前系统人员总数
-
- {adminUsers.length} 人 -
-
- 在职正常: {adminUsers.filter((u) => u.status === 'active').length} 人 · 待审: {pendingUsers.length} 人 -
-
- -
-
超管凭据配置通道
-
- .env 动态环境变量 -
-
- ADMIN_USERNAME 与 ADMIN_PASSWORD 受控注入 -
-
-
-
- )} -
-
-
- - {/* 管理员直接开通员工账号的内层弹窗 */} - {showCreateUserModal && ( -
{ - if (e.target === e.currentTarget) setShowCreateUserModal(false); - }} - > -
-
- 开通受控员工账号 -
-
- 由管理员开通的员工,首次登录系统将被强制修改初始密码。 -
- -
-
- - setNewUname(e.target.value)} - placeholder="如 li_si" - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
- -
- - setNewRealName(e.target.value)} - placeholder="如 李四" - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
- -
- - -
- -
-
- - setNewDept(e.target.value)} - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
-
- - setNewInitPass(e.target.value)} - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
-
- -
- - -
-
-
-
- )} -
- ); -}; diff --git a/web/src/components/admin/console/AdminConsoleView.tsx b/web/src/components/admin/console/AdminConsoleView.tsx new file mode 100644 index 00000000..c32363b2 --- /dev/null +++ b/web/src/components/admin/console/AdminConsoleView.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useState } from 'react'; +import { + Activity, + ArrowLeft, + Database, + FileText, + GitBranch, + Shield, + ShieldAlert, + ShieldX, + Sliders, + ToggleLeft, + Users, +} from 'lucide-react'; +import { fetchSystemOverview } from '../../../api/systemAdmin'; +import { UserProfile } from '../../../types/docgov'; +import { RoleDefinition } from '../../../types/rbac'; +import { DocsSection } from './sections/DocsSection'; +import { FeaturesSection } from './sections/FeaturesSection'; +import { KbSection } from './sections/KbSection'; +import { OverviewSection } from './sections/OverviewSection'; +import { RolesSection } from './sections/RolesSection'; +import { SettingsSection } from './sections/SettingsSection'; +import { UsersSection } from './sections/UsersSection'; +import { WorkflowsSection } from './sections/WorkflowsSection'; + +export interface AdminConsoleViewProps { + currentUser: UserProfile; + env?: string; + onExit: () => void; + /** 账号目录变更后同步父级 App allUsers(立项成员候选依赖,见 UI-01) */ + onUsersChanged?: () => void | Promise; + /** 角色库变更后同步父级 App roles(工作台 RBAC 计算依赖) */ + onRolesChanged?: (roles: RoleDefinition[]) => void; +} + +type SectionKey = + | 'overview' + | 'kb' + | 'features' + | 'settings' + | 'workflows' + | 'roles' + | 'users' + | 'docs'; + +const NAV_ITEMS: Array<{ key: SectionKey; label: string; icon: React.ReactNode }> = [ + { key: 'overview', label: '系统运行总览', icon: }, + { key: 'kb', label: '知识库连接', icon: }, + { key: 'features', label: '特性功能开关', icon: }, + { key: 'settings', label: '系统设置白名单', icon: }, + { key: 'workflows', label: '工作流定义', icon: }, + { key: 'roles', label: 'RBAC 角色权限', icon: }, + { key: 'users', label: '用户与准入治理', icon: }, + { key: 'docs', label: '文档与模板库', icon: }, +]; + +type AuthzGate = 'checking' | 'ready' | 'denied'; + +export const AdminConsoleView: React.FC = ({ + currentUser, + env = 'dev', + onExit, + onUsersChanged, + onRolesChanged, +}) => { + const [activeSection, setActiveSection] = useState('overview'); + const [gate, setGate] = useState('checking'); + + // 挂载时调用 overview 做二次鉴权:403 视为非管理员,提示并退回工作台; + // 其余错误(端点未就绪/网络)不阻断进入,由各 section 自行呈现错误态。 + useEffect(() => { + let cancelled = false; + fetchSystemOverview() + .then(() => { + if (!cancelled) setGate('ready'); + }) + .catch((err) => { + if (cancelled) return; + const status = (err as { status?: number })?.status; + setGate(status === 403 ? 'denied' : 'ready'); + }); + return () => { + cancelled = true; + }; + }, []); + + const renderSection = () => { + switch (activeSection) { + case 'overview': + return ; + case 'kb': + return ; + case 'features': + return ; + case 'settings': + return ; + case 'workflows': + return ; + case 'roles': + return ; + case 'users': + return ; + case 'docs': + return ; + } + }; + + return ( +
+ {/* 顶部标题栏 */} +
+
+ + +
+ +
+
+
+ + 系统管理 + + + ADMIN 独占 + + + 运行环境: {(env || 'dev').toUpperCase()} + +
+
+ 运行总览 · 特性开关 · 设置白名单 · 工作流 · 角色权限 · 用户准入 · 文档模板 +
+
+
+
+ + {gate === 'denied' ? ( +
+
+ +
+ 当前账号无系统管理权限 +
+
+ 服务端已拒绝访问(403)。系统管理台仅对 role-admin 账号开放,请返回工作台。 +
+ +
+
+ ) : ( + /* 主体左右分栏 */ +
+ {/* 左侧分组导航 */} +
+
+ {NAV_ITEMS.map((item) => ( + + ))} +
+ + {/* 底部系统信息 */} +
+
水木东方医疗器械 AI 工作台
+
Auth & RBAC v2.4
+
+
+ + {/* 右侧内容区 */} +
+ {gate === 'checking' ? ( +
+ 正在校验管理员权限… +
+ ) : ( + renderSection() + )} +
+
+ )} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/DocsSection.tsx b/web/src/components/admin/console/sections/DocsSection.tsx new file mode 100644 index 00000000..0585f825 --- /dev/null +++ b/web/src/components/admin/console/sections/DocsSection.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { FileText } from 'lucide-react'; +import { listAdminTemplates } from '../../../../api/workflowAdmin'; +import { + SectionError, + SectionHeader, + SectionLoading, + sectionCardStyle, + sectionTableStyle, + sectionTdStyle, + sectionThStyle, +} from './sectionCommon'; + +type AdminTemplate = { id: number; asset_key: string; name: string; status: string }; + +export const DocsSection: React.FC = () => { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setTemplates(await listAdminTemplates()); + } catch (err) { + setTemplates([]); + setError(err instanceof Error ? err.message : '获取模板列表失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+ + + {loading ? ( + + ) : error ? ( + void load()} /> + ) : ( +
+ + + + + + + + + + {templates.length === 0 && ( + + + + )} + {templates.map((t) => ( + + + + + + ))} + +
模板名称资产标识 (asset_key)状态
+ 暂无模板(需 PG 环境 bootstrap) +
+
+ + {t.name} +
+
+ {t.asset_key} + {t.status || '-'}
+
+ )} + +
+ 一期仅提供模板库只读摘要。项目文档的编制、受控流转、版本与可见性治理均在 + 工作台右侧文档区 + 进行;点击右上角「返回工作台」即可回到协同工作界面。 +
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/FeaturesSection.tsx b/web/src/components/admin/console/sections/FeaturesSection.tsx new file mode 100644 index 00000000..5a9a28c1 --- /dev/null +++ b/web/src/components/admin/console/sections/FeaturesSection.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { fetchSystemFeatures } from '../../../../api/systemAdmin'; +import type { SystemFeature, SystemFeatureSource } from '../../../../types/systemAdmin'; +import { + RestartBadge, + SectionError, + SectionHeader, + SectionLoading, + formatConfigValue, + sectionTableStyle, + sectionTdStyle, + sectionThStyle, +} from './sectionCommon'; + +const SOURCE_META: Record = { + env: { label: '环境变量', color: 'var(--info)', bg: 'var(--info-bg)' }, + db: { label: '数据库', color: 'var(--success)', bg: 'var(--success-bg)' }, + default: { label: '默认值', color: 'var(--text-muted)', bg: 'var(--bg-subtle)' }, +}; + +export const FeaturesSection: React.FC = () => { + const [features, setFeatures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setFeatures(await fetchSystemFeatures()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取特性开关失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error) return void load()} />; + + return ( +
+ +
+ + + + + + + + + + + + {features.length === 0 && ( + + + + )} + {features.map((f) => { + const src = SOURCE_META[f.source] ?? SOURCE_META.default; + return ( + + + + + + + + ); + })} + +
名称分组当前值来源标注
+ 暂无特性开关数据 +
+
{f.label || f.key}
+
{f.key}
+ {f.description && ( +
+ {f.description} +
+ )} +
{f.group || '-'} + {formatConfigValue(f.value)} + + + {src.label} + + {f.requiresRestart && }
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/KbSection.tsx b/web/src/components/admin/console/sections/KbSection.tsx new file mode 100644 index 00000000..0895bd58 --- /dev/null +++ b/web/src/components/admin/console/sections/KbSection.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { fetchSystemKb } from '../../../../api/systemAdmin'; +import type { SystemKbInfo } from '../../../../types/systemAdmin'; +import { + SectionError, + SectionHeader, + SectionLoading, + StatusDot, + sectionCardStyle, + sectionTdStyle, +} from './sectionCommon'; + +export const KbSection: React.FC = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setData(await fetchSystemKb()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取知识库连接信息失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error || !data) return void load()} />; + + const rows: Array<{ label: string; value: React.ReactNode }> = [ + { + label: '在线状态', + value: ( + + + + {data.online ? '在线' : '离线'} + + + ), + }, + { label: '服务地址', value: `${data.host || '-'}:${data.port ?? '-'}` }, + { label: '数据库', value: data.database || '-' }, + { label: '连接账号', value: data.user || '-' }, + { label: 'Embedding 模型', value: data.embeddingModel || '-' }, + { label: 'Reranker 重排序', value: data.rerankerEnabled ? '已启用' : '未启用' }, + { label: 'SSL 模式', value: data.sslMode || '-' }, + ]; + + return ( +
+ +
+ + + {rows.map((row) => ( + + + + + ))} + +
+ {row.label} + {row.value}
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/OverviewSection.tsx b/web/src/components/admin/console/sections/OverviewSection.tsx new file mode 100644 index 00000000..fa52e3b0 --- /dev/null +++ b/web/src/components/admin/console/sections/OverviewSection.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { fetchSystemOverview } from '../../../../api/systemAdmin'; +import type { SystemOverview } from '../../../../types/systemAdmin'; +import { + SectionError, + SectionHeader, + SectionLoading, + StatusDot, + formatUptime, + sectionCardStyle, +} from './sectionCommon'; + +const OverviewCard: React.FC<{ + label: string; + value: React.ReactNode; + hint?: string; + ok?: boolean; +}> = ({ label, value, hint, ok }) => ( +
+
+ {typeof ok === 'boolean' && } + {label} +
+
+ {value} +
+ {hint && ( +
{hint}
+ )} +
+); + +export const OverviewSection: React.FC = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setData(await fetchSystemOverview()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取系统总览失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error || !data) return void load()} />; + + const healthy = data.status === 'ok' || data.status === 'healthy'; + return ( +
+ +
+ + + + + + +
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/RolesSection.tsx b/web/src/components/admin/console/sections/RolesSection.tsx new file mode 100644 index 00000000..122aa3e9 --- /dev/null +++ b/web/src/components/admin/console/sections/RolesSection.tsx @@ -0,0 +1,371 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { AlertCircle, CheckCircle2, Plus, Trash2 } from 'lucide-react'; +import { authClient } from '../../../../api/auth'; +import { PermissionKey, RoleDefinition } from '../../../../types/rbac'; +import { SectionError, SectionLoading } from './sectionCommon'; + +const ALL_PERMISSION_ITEMS: { + key: PermissionKey; + label: string; + desc: string; + category: '项目与流程' | '权限与安全' | '文档与导出'; +}[] = [ + { + key: 'project:create', + label: '创建新项目/立项', + desc: '允许在协同工作台左侧栏发起新医疗器械立项项目并克隆模板', + category: '项目与流程', + }, + { + key: 'project:assign_lead', + label: '设置/变更文档责任人', + desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人', + category: '项目与流程', + }, + { + key: 'perm:manage', + label: '系统权限与角色管理', + desc: '管理员超级特权,可维护角色库、审批账号与配置准入策略', + category: '权限与安全', + }, + { + key: 'workflow:config_visibility', + label: '配置工作流默认可见性', + desc: '调整整个业务阶段中各受控交付物的基线查阅准入边界', + category: '权限与安全', + }, + { + key: 'doc:override_visibility', + label: '配置单文档可见性覆盖', + desc: '对特定涉密或专精文档设置独立白名单或责任人限制', + category: '权限与安全', + }, + { + key: 'workflow:view_all', + label: '查阅常规工作流受控文档', + desc: '是否可浏览项目内的受控工作流交付物大盘', + category: '文档与导出', + }, + { + key: 'doc:export', + label: '导出受控成果物 (DOCX/XLSX)', + desc: '允许将交付物编译导出为合规 DOCX 报告或计算 XLSX 表格', + category: '文档与导出', + }, +]; + +export interface RolesSectionProps { + /** 角色库变更后同步父级 App roles(工作台 RBAC 计算依赖此目录) */ + onRolesChanged?: (roles: RoleDefinition[]) => void; +} + +export const RolesSection: React.FC = ({ onRolesChanged }) => { + const [editRoles, setEditRoles] = useState([]); + const [selectedRoleId, setSelectedRoleId] = useState('role-admin'); + const [isCreatingRole, setIsCreatingRole] = useState(false); + const [newRoleName, setNewRoleName] = useState(''); + const [newRoleCode, setNewRoleCode] = useState(''); + const [newRoleDesc, setNewRoleDesc] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tip, setTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const showTip = (type: 'success' | 'error', msg: string) => { + setTip({ type, msg }); + setTimeout(() => setTip(null), 3000); + }; + + const applyRoles = (next: RoleDefinition[]) => { + setEditRoles(next); + onRolesChanged?.(next); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await authClient.listRoles(); + setEditRoles(list); + setSelectedRoleId((prev) => (list.some((r) => r.id === prev) ? prev : list[0]?.id || 'role-admin')); + } catch (err) { + setError(err instanceof Error ? err.message : '获取角色库失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const currentSelectedRole = editRoles.find((r) => r.id === selectedRoleId) || editRoles[0]; + + const handleTogglePermission = async (permKey: PermissionKey) => { + if (!currentSelectedRole || currentSelectedRole.isSystem) return; + const exists = currentSelectedRole.permissions.includes(permKey); + const updatedPermissions = exists + ? currentSelectedRole.permissions.filter((k) => k !== permKey) + : [...currentSelectedRole.permissions, permKey]; + try { + const updated = await authClient.updateRole(currentSelectedRole.id, { + name: currentSelectedRole.name, + description: currentSelectedRole.description, + permissions: updatedPermissions, + }); + applyRoles(editRoles.map((r) => (r.id === updated.id ? updated : r))); + showTip('success', `已更新【${currentSelectedRole.name}】权限矩阵`); + } catch (err: any) { + showTip('error', err.message || '更新权限失败'); + } + }; + + const handleCreateRole = async () => { + if (!newRoleName.trim() || !newRoleCode.trim()) return; + try { + const newRole = await authClient.createRole({ + code: newRoleCode.trim().toUpperCase(), + name: newRoleName.trim(), + description: newRoleDesc.trim() || '自定义受控职位角色', + permissions: ['workflow:view_all', 'doc:export'], + }); + applyRoles([...editRoles, newRole]); + setSelectedRoleId(newRole.id); + setIsCreatingRole(false); + setNewRoleName(''); + setNewRoleCode(''); + setNewRoleDesc(''); + showTip('success', `已创建新业务角色【${newRole.name}】`); + } catch (err: any) { + showTip('error', err.message || '创建角色失败'); + } + }; + + const handleDeleteRole = async (roleId: string) => { + const roleToDelete = editRoles.find((r) => r.id === roleId); + if (!roleToDelete || roleToDelete.isSystem) return; + if (!window.confirm(`确定要删除角色【${roleToDelete.name}】吗?删除后该角色人员将自动退回普通工程师。`)) { + return; + } + try { + await authClient.deleteRole(roleId); + const nextRoles = editRoles.filter((r) => r.id !== roleId); + applyRoles(nextRoles); + setSelectedRoleId(nextRoles[0]?.id || 'role-admin'); + showTip('success', `已删除角色【${roleToDelete.name}】`); + } catch (err: any) { + showTip('error', err.message || '删除角色失败'); + } + }; + + if (loading) return ; + if (error) return void load()} />; + + return ( +
+ {tip && ( +
+ {tip.type === 'success' ? : } + {tip.msg} +
+ )} +
+ {/* 角色列表 */} +
+
+
+ 全局受控角色库 ({editRoles.length}) +
+ {editRoles.map((r) => { + const isSel = r.id === selectedRoleId; + return ( +
setSelectedRoleId(r.id)} + style={{ + padding: '8px 10px', + borderRadius: '6px', + backgroundColor: isSel ? 'var(--bg-panel)' : 'transparent', + border: isSel ? '1px solid var(--brand-500)' : '1px solid transparent', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > +
+
+ {r.name} +
+
+ {r.code} · {r.permissions.length} 项权限 +
+
+ {!r.isSystem && ( + + )} +
+ ); + })} +
+ + {/* 新增自定义角色 */} +
+ {isCreatingRole ? ( +
+ setNewRoleName(e.target.value)} + placeholder="角色名称 (如 临床协调员)" + style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} + /> + setNewRoleCode(e.target.value)} + placeholder="标识代号 (如 CRA)" + style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} + /> +
+ + +
+
+ ) : ( + + )} +
+
+ + {/* 对应角色的权限复选矩阵 */} +
+
+
+ + 【{currentSelectedRole?.name}】权限配置矩阵 + + {currentSelectedRole?.isSystem && ( + + 系统内置受控角色 (只读) + + )} +
+
+ {currentSelectedRole?.description} +
+
+ +
+ {ALL_PERMISSION_ITEMS.map((item) => { + const isChecked = + currentSelectedRole?.code === 'ADMIN' || + currentSelectedRole?.permissions.includes(item.key); + const isLocked = currentSelectedRole?.isSystem; + + return ( +
!isLocked && handleTogglePermission(item.key)} + style={{ + padding: '12px 14px', + borderRadius: '8px', + backgroundColor: isChecked ? 'var(--bg-subtle)' : 'var(--bg-app)', + border: isChecked ? '1px solid var(--border)' : '1px dashed var(--border)', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: isLocked ? 'not-allowed' : 'pointer', + }} + > +
+
+ + {item.label} + + + {item.category} + +
+
+ {item.desc} +
+
+ + {}} + style={{ width: '16px', height: '16px', accentColor: 'var(--brand-500)', cursor: isLocked ? 'not-allowed' : 'pointer' }} + /> +
+ ); + })} +
+
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/SettingsSection.tsx b/web/src/components/admin/console/sections/SettingsSection.tsx new file mode 100644 index 00000000..c93ba25d --- /dev/null +++ b/web/src/components/admin/console/sections/SettingsSection.tsx @@ -0,0 +1,212 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { fetchSystemSettings, updateSystemSetting } from '../../../../api/systemAdmin'; +import type { SystemConfigValue, SystemSetting } from '../../../../types/systemAdmin'; +import { + RestartBadge, + SectionError, + SectionHeader, + SectionLoading, + formatConfigValue, + sectionCardStyle, +} from './sectionCommon'; + +function isBooleanSetting(s: SystemSetting): boolean { + return s.type === 'boolean' || s.type === 'bool' || typeof s.value === 'boolean'; +} + +export const SettingsSection: React.FC = () => { + const [settings, setSettings] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [savingKey, setSavingKey] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tip, setTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const showTip = (type: 'success' | 'error', msg: string) => { + setTip({ type, msg }); + setTimeout(() => setTip(null), 3000); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await fetchSystemSettings(); + setSettings(list); + setDrafts(Object.fromEntries(list.map((s) => [s.key, s.value]))); + } catch (err) { + setError(err instanceof Error ? err.message : '获取系统设置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async (setting: SystemSetting) => { + setSavingKey(setting.key); + try { + const updated = await updateSystemSetting(setting.key, drafts[setting.key]); + setSettings((prev) => prev.map((s) => (s.key === updated.key ? updated : s))); + setDrafts((prev) => ({ ...prev, [updated.key]: updated.value })); + showTip( + 'success', + updated.requiresRestart ? `已保存【${updated.label || updated.key}】,重启服务后生效` : `已保存【${updated.label || updated.key}】`, + ); + } catch (err) { + showTip('error', err instanceof Error ? err.message : '保存设置失败'); + } finally { + setSavingKey(null); + } + }; + + if (loading) return ; + if (error) return void load()} />; + + const groups: Array<{ group: string; items: SystemSetting[] }> = []; + for (const s of settings) { + const g = s.group || '其他'; + const found = groups.find((x) => x.group === g); + if (found) found.items.push(s); + else groups.push({ group: g, items: [s] }); + } + + return ( +
+
+ + {tip && ( + + {tip.type === 'success' ? : } + {tip.msg} + + )} +
+ + {groups.length === 0 && } + + {groups.map((g) => ( +
+
{g.group}
+ {g.items.map((s) => { + const draft = drafts[s.key]; + const dirty = s.writable && draft !== s.value; + return ( +
+
+
+ + {s.label || s.key} + + {!s.writable && ( + + 只读 + + )} + {s.requiresRestart && } +
+ {s.description && ( +
+ {s.description} +
+ )} +
+ {s.key} · 默认值: {formatConfigValue(s.default)} + {s.updatedAt && ` · 更新于 ${s.updatedAt}${s.updatedBy ? ` by ${s.updatedBy}` : ''}`} +
+
+ +
+ {isBooleanSetting(s) ? ( + setDrafts((prev) => ({ ...prev, [s.key]: e.target.checked }))} + style={{ + width: '18px', + height: '18px', + cursor: s.writable ? 'pointer' : 'not-allowed', + accentColor: 'var(--brand-500)', + }} + /> + ) : ( + setDrafts((prev) => ({ ...prev, [s.key]: e.target.value }))} + style={{ + width: '220px', + padding: '6px 10px', + borderRadius: '6px', + backgroundColor: 'var(--bg-app)', + border: '1px solid var(--border)', + color: s.writable ? 'var(--text-strong)' : 'var(--text-muted)', + fontSize: '12px', + boxSizing: 'border-box', + cursor: s.writable ? 'text' : 'not-allowed', + }} + /> + )} + {s.writable && ( + + )} +
+
+ ); + })} +
+ ))} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/UsersSection.tsx b/web/src/components/admin/console/sections/UsersSection.tsx new file mode 100644 index 00000000..49b0a2f0 --- /dev/null +++ b/web/src/components/admin/console/sections/UsersSection.tsx @@ -0,0 +1,898 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + AlertCircle, + Check, + CheckCircle2, + Search, + Sliders, + UserCheck, + UserPlus, + Users, + X, +} from 'lucide-react'; +import { authClient } from '../../../../api/auth'; +import { syncAdminUserDirectories } from '../../../../lib/adminUserDirectorySync'; +import { UserAccount } from '../../../../types/auth'; +import { UserProfile } from '../../../../types/docgov'; +import { RoleDefinition } from '../../../../types/rbac'; +import { SectionError, SectionLoading } from './sectionCommon'; + +type UsersTab = 'users' | 'approvals' | 'policy'; + +export interface UsersSectionProps { + currentUser: UserProfile; + /** 账号变更后同步父级 App allUsers(立项成员候选依赖此目录,见 UI-01) */ + onRefreshUsers?: () => void | Promise; +} + +export const UsersSection: React.FC = ({ currentUser, onRefreshUsers }) => { + const [activeTab, setActiveTab] = useState('users'); + + // 全量账号列表(含待审核及禁用状态) + const [adminUsers, setAdminUsers] = useState([]); + const [editRoles, setEditRoles] = useState([]); + const [userSearchText, setUserSearchText] = useState(''); + const [actionTip, setActionTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 管理员新建用户表单 + const [showCreateUserModal, setShowCreateUserModal] = useState(false); + const [newUname, setNewUname] = useState(''); + const [newRealName, setNewRealName] = useState(''); + const [newInitPass, setNewInitPass] = useState('InitPass123'); + const [newRoleId, setNewRoleId] = useState('role-engineer'); + const [newPosition, setNewPosition] = useState('方案工程师'); + const [newDept, setNewDept] = useState('研发中心'); + + // 准入策略本地状态 + const [allowReg, setAllowReg] = useState(true); + const [requireAppr, setRequireAppr] = useState(false); + + const showNotification = (type: 'success' | 'error', msg: string) => { + setActionTip({ type, msg }); + setTimeout(() => setActionTip(null), 3000); + }; + + // 刷新全量用户列表,并同步父级 App allUsers(立项成员候选依赖此目录) + const refreshUsers = async () => { + try { + await syncAdminUserDirectories({ + reloadLocalUsers: async () => { + const list = await authClient.listAllUsers(); + setAdminUsers(list); + }, + onRefreshUsers, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '刷新/同步用户目录失败'; + setActionTip({ type: 'error', msg }); + throw err; + } + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [users, roles, cfg] = await Promise.all([ + authClient.listAllUsers(), + authClient.listRoles(), + authClient.getSystemAuthConfig().catch(() => null), + ]); + setAdminUsers(users); + setEditRoles(roles); + if (cfg) { + setAllowReg(cfg.allowRegistration); + setRequireAppr(cfg.requireApproval); + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取用户目录失败'); + } finally { + setLoading(false); + } + }, []); + + // 父级回调用 ref 持有:App 重渲染会产生新函数身份,避免 mount effect 被反复触发 + const onRefreshUsersRef = useRef(onRefreshUsers); + onRefreshUsersRef.current = onRefreshUsers; + + useEffect(() => { + void load().then(() => { + // 首屏也同步父级全局目录,保证立项成员候选为最新 + const cb = onRefreshUsersRef.current; + if (cb) void Promise.resolve(cb()).catch(() => {}); + }); + }, [load]); + + // 1. 人员职位与角色分配 + const handleUserRoleChange = async (userId: number, roleId: string) => { + const role = editRoles.find((r) => r.id === roleId); + try { + await authClient.updateUserRole(userId, roleId, role?.name || '方案工程师'); + } catch (err: any) { + showNotification('error', err.message || '更新角色失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', '已成功变更该成员的所属职位与系统角色'); + } catch { + showNotification('error', '角色已更新,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 2. 账号状态(激活/停用) + const handleToggleUserStatus = async (user: UserAccount) => { + const nextStatus = user.status === 'active' ? 'disabled' : 'active'; + try { + await authClient.updateUserStatus(user.id, nextStatus); + } catch (err: any) { + showNotification('error', err.message || '切换状态失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已将账号【${user.name}】状态切换为:${nextStatus === 'active' ? '启用' : '停用'}`); + } catch { + showNotification('error', '账号状态已更新,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 3. 审批通过或驳回 + const handleApproveRegistration = async (user: UserAccount, targetRoleId: string) => { + const role = editRoles.find((r) => r.id === targetRoleId); + try { + await authClient.updateUserStatus(user.id, 'active', targetRoleId, role?.name || '方案工程师'); + } catch (err: any) { + showNotification('error', err.message || '审批操作失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已批准【${user.name}】注册申请,已正式授予【${role?.name}】职位`); + } catch { + showNotification('error', '审批已生效,但全局用户目录同步失败,请刷新后重试'); + } + }; + + const handleRejectRegistration = async (user: UserAccount) => { + if (!window.confirm(`确定要驳回【${user.name}】的注册申请吗?该账号将被置为禁用。`)) return; + try { + await authClient.updateUserStatus(user.id, 'disabled'); + } catch (err: any) { + showNotification('error', err.message || '驳回操作失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已驳回【${user.name}】的注册申请`); + } catch { + showNotification('error', '驳回已生效,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 4. 重置用户密码为初始并强制改密 + const handleResetUserPassword = async (user: UserAccount) => { + const tempPass = 'ResetPass123'; + if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return; + try { + await authClient.adminResetPassword(user.id, tempPass); + } catch (err: any) { + showNotification('error', err.message || '重置密码失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); + } catch { + showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 5. 管理员新建账号 + const handleCreateUserSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newUname.trim() || !newRealName.trim() || !newInitPass) { + showNotification('error', '请填写完整账号、姓名与初始密码'); + return; + } + try { + await authClient.createAdminUser({ + username: newUname.trim(), + name: newRealName.trim(), + initialPassword: newInitPass, + roleId: newRoleId, + position: newPosition, + department: newDept, + }); + setShowCreateUserModal(false); + setNewUname(''); + setNewRealName(''); + try { + await refreshUsers(); + showNotification('success', `已成功开通受控账号【${newRealName}】,已自动标记首次登录强制改密`); + } catch { + showNotification( + 'error', + `账号【${newRealName}】已开通,但全局用户目录同步失败,立项成员候选可能暂不可见该账号`, + ); + } + } catch (err: any) { + showNotification('error', err.message || '新建员工账号失败'); + } + }; + + // 6. 保存准入策略 + const handleSavePolicy = async () => { + try { + await authClient.updateSystemAuthConfig({ + allowRegistration: allowReg, + requireApproval: requireAppr, + }); + showNotification('success', '系统准入策略已即刻全局生效'); + } catch (err: any) { + showNotification('error', err.message || '更新策略失败'); + } + }; + + // 待审批列表 + const pendingUsers = adminUsers.filter((u) => u.status === 'pending_approval'); + // 过滤后的用户列表 + const filteredUsers = adminUsers.filter( + (u) => + u.name.toLowerCase().includes(userSearchText.toLowerCase()) || + (u.username && u.username.toLowerCase().includes(userSearchText.toLowerCase())) || + (u.department && u.department.toLowerCase().includes(userSearchText.toLowerCase())) + ); + + if (loading) return ; + if (error) return void load()} />; + + const subTabButton = (tab: UsersTab, label: string, icon: React.ReactNode, badge?: number) => ( + + ); + + return ( +
+ {/* 子 Tab:人员治理 / 注册审批 / 准入策略 */} +
+
+ {subTabButton('users', '人员与职位治理', )} + {subTabButton('approvals', '注册审批中心', , pendingUsers.length)} + {subTabButton('policy', '系统准入策略', )} +
+ {actionTip && ( + + {actionTip.type === 'success' ? : } + {actionTip.msg} + + )} +
+ + {/* 子 Tab 1: 人员与职位治理 */} + {activeTab === 'users' && ( +
+
+
+ + setUserSearchText(e.target.value)} + placeholder="搜索姓名、用户名或部门..." + style={{ + width: '100%', + padding: '7px 10px 7px 32px', + borderRadius: '8px', + backgroundColor: 'var(--bg-app)', + border: '1px solid var(--border)', + color: 'var(--text-strong)', + fontSize: '12px', + boxSizing: 'border-box', + }} + /> +
+ + +
+ + {/* 用户列表表格 */} +
+ + + + + + + + + + + + + {filteredUsers.map((u) => { + const isSelf = u.id === currentUser.id; + return ( + + + + + + + + + ); + })} + +
成员姓名登录账号 / 工号所属部门系统职位与角色账号状态操作治理
+
+
+ {u.name.slice(0, 1)} +
+ {u.name} + {isSelf && ( + + 当前账号 + + )} +
+
+
{u.username || '系统预设'}
+
{u.employeeId || 'EMP-未设定'}
+
+ {u.department || '未分配部门'} + + + + + {u.status === 'active' ? '正常在职' : u.status === 'pending_approval' ? '待审核' : '已停用'} + + +
+ + {!isSelf && ( + + )} +
+
+
+
+ )} + + {/* 子 Tab 2: 注册审批中心 */} + {activeTab === 'approvals' && ( +
+
+
+ 新员工注册审批与职位指派中心 +
+
+ 依据 NMPA 体系规范,自主注册申请需经系统管理员人工核准并正式授予业务职位。 +
+
+ + {pendingUsers.length === 0 ? ( +
+ +
+ 当前没有待审批的注册申请 +
+
+ 所有新注册员工均已完成合规审批并完成业务职位委派。 +
+
+ ) : ( +
+ {pendingUsers.map((u) => { + return ( +
+
+
+ + {u.name} + + + 账号: {u.username} + + + 待审核 + +
+
+ 部门: {u.department || '未填写'} + 工号: {u.employeeId || '未填写'} + 申请时间: {u.createdAt || '刚刚'} +
+
+ +
+ + + + + +
+
+ ); + })} +
+ )} +
+ )} + + {/* 子 Tab 3: 系统准入策略 */} + {activeTab === 'policy' && ( +
+
+
+ 系统全局准入与注册审核策略 +
+
+ 控制系统外部开放度,关闭自主注册或开启强制审核均实时阻断越权账号。 +
+
+ + {/* 开关 1: 开放自主注册 */} +
+
+
+ 允许新用户自主注册账号 +
+
+ 开启后,登录页将呈现「用户自主注册」选项卡;关闭后,仅管理员可在控制台开通账号。 +
+
+ setAllowReg(e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} + /> +
+ + {/* 开关 2: 注册需审核 */} +
+
+
+ 新注册账号必须由管理员审核通过 +
+
+ 开启后,新注册账号处于「待审核」状态禁止登录,直至管理员在审批中心核准并正式分配职位。 +
+
+ setRequireAppr(e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} + /> +
+ +
+ +
+
+ )} + + {/* 管理员直接开通员工账号的内层弹窗 */} + {showCreateUserModal && ( +
{ + if (e.target === e.currentTarget) setShowCreateUserModal(false); + }} + > +
+
+ 开通受控员工账号 +
+
+ 由管理员开通的员工,首次登录系统将被强制修改初始密码。 +
+ +
+
+ + setNewUname(e.target.value)} + placeholder="如 li_si" + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+ +
+ + setNewRealName(e.target.value)} + placeholder="如 李四" + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+ +
+ + +
+ +
+
+ + setNewDept(e.target.value)} + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+
+ + setNewInitPass(e.target.value)} + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/WorkflowsSection.tsx b/web/src/components/admin/console/sections/WorkflowsSection.tsx new file mode 100644 index 00000000..47540d27 --- /dev/null +++ b/web/src/components/admin/console/sections/WorkflowsSection.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + getAdminWorkflow, + listAdminWorkflows, + publishWorkflowVersion, + validateWorkflowVersion, + WorkflowDefinitionSummary, + WorkflowVersionSummary, +} from '../../../../api/workflowAdmin'; +import { SectionError, SectionHeader, SectionLoading } from './sectionCommon'; + +export const WorkflowsSection: React.FC = () => { + const [adminWorkflows, setAdminWorkflows] = useState([]); + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + const [workflowVersions, setWorkflowVersions] = useState([]); + const [validationMsg, setValidationMsg] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refreshWorkflows = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await listAdminWorkflows(); + setAdminWorkflows(list); + } catch (err) { + setAdminWorkflows([]); + setError(err instanceof Error ? err.message : '获取工作流列表失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refreshWorkflows(); + }, [refreshWorkflows]); + + const loadWorkflowDetail = async (id: number) => { + setSelectedWorkflowId(id); + try { + const detail = await getAdminWorkflow(id); + setWorkflowVersions(detail.versions ?? []); + } catch { + setWorkflowVersions([]); + } + }; + + const handleValidateVersion = async (workflowId: number, version: number) => { + try { + const res = await validateWorkflowVersion(workflowId, version); + if (res.valid) { + setValidationMsg(`v${version} 校验通过`); + } else { + setValidationMsg(res.issues.map((i) => i.message).join(';')); + } + } catch (err: any) { + setValidationMsg(err.message || '校验失败'); + } + }; + + const handlePublishVersion = async (workflowId: number, version: number) => { + if (!window.confirm(`确认发布工作流版本 v${version}?发布后不可原地修改。`)) return; + try { + await publishWorkflowVersion(workflowId, version); + setValidationMsg(`已发布 v${version}`); + refreshWorkflows(); + loadWorkflowDetail(workflowId); + } catch (err: any) { + setValidationMsg(err.message || '发布失败'); + } + }; + + if (loading) return ; + if (error) return void refreshWorkflows()} />; + + return ( +
+ +
+
+ {adminWorkflows.map((wf) => ( + + ))} + {adminWorkflows.length === 0 && ( +
暂无工作流定义
+ )} +
+
+ {selectedWorkflowId && ( + <> +
版本列表
+ {workflowVersions.map((v) => ( +
+
+ v{v.version} · {v.status} +
+
+ hash: {v.definition_hash.slice(0, 16)}… +
+
+ + {v.status === 'draft' && ( + + )} +
+
+ ))} + {validationMsg && ( +
{validationMsg}
+ )} + + )} +
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/sectionCommon.tsx b/web/src/components/admin/console/sections/sectionCommon.tsx new file mode 100644 index 00000000..fa20a40f --- /dev/null +++ b/web/src/components/admin/console/sections/sectionCommon.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +export const sectionCardStyle: React.CSSProperties = { + padding: '16px', + borderRadius: '10px', + backgroundColor: 'var(--bg-subtle)', + border: '1px solid var(--border)', +}; + +export const sectionTableStyle: React.CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + textAlign: 'left', + fontSize: '12px', +}; + +export const sectionThStyle: React.CSSProperties = { + padding: '10px 14px', + fontWeight: 600, +}; + +export const sectionTdStyle: React.CSSProperties = { + padding: '10px 14px', + borderBottom: '1px solid var(--border-light)', +}; + +export const SectionHeader: React.FC<{ title: string; desc: string }> = ({ title, desc }) => ( +
+
{title}
+
{desc}
+
+); + +export const SectionLoading: React.FC<{ text?: string }> = ({ text = '加载中…' }) => ( +
+ {text} +
+); + +export const SectionError: React.FC<{ message: string; onRetry?: () => void }> = ({ message, onRetry }) => ( +
+
+ + {message} +
+ {onRetry && ( + + )} +
+); + +export const RestartBadge: React.FC = () => ( + + 需重启生效 + +); + +export const StatusDot: React.FC<{ ok: boolean }> = ({ ok }) => ( + +); + +export function formatConfigValue(value: string | number | boolean): string { + if (typeof value === 'boolean') return value ? '开启' : '关闭'; + return String(value); +} + +export function formatUptime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '-'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d} 天 ${h} 小时`; + if (h > 0) return `${h} 小时 ${m} 分`; + return `${m} 分 ${Math.floor(seconds % 60)} 秒`; +} diff --git a/web/src/types/systemAdmin.ts b/web/src/types/systemAdmin.ts new file mode 100644 index 00000000..5c2272ae --- /dev/null +++ b/web/src/types/systemAdmin.ts @@ -0,0 +1,54 @@ +/** + * 系统管理台(/api/v1/admin/system/*)契约类型。 + * 后端并行开发中,字段以此契约为准;全部端点需 admin Bearer token。 + */ + +export type SystemConfigValue = string | number | boolean; + +export interface SystemOverview { + status: string; + env: string; + agentBackend: string; + databaseReady: boolean; + schemaVersion: string; + taskStoreReady: boolean; + kbOnline: boolean; + uptimeSeconds: number; +} + +export interface SystemKbInfo { + online: boolean; + host: string; + port: number; + database: string; + user: string; + embeddingModel: string; + rerankerEnabled: boolean; + sslMode: string; +} + +export type SystemFeatureSource = 'env' | 'db' | 'default'; + +export interface SystemFeature { + key: string; + label: string; + group: string; + value: SystemConfigValue; + source: SystemFeatureSource; + requiresRestart: boolean; + description: string; +} + +export interface SystemSetting { + key: string; + label: string; + group: string; + type: string; + value: SystemConfigValue; + default: SystemConfigValue; + writable: boolean; + requiresRestart: boolean; + description: string; + updatedAt: string; + updatedBy: string; +}