AdminSettingsModal previously left onRefreshUsers as a dead prop, so newly created users never reached App allUsers/localStorage and Create Project member candidates stayed stale (UI-01). Wire syncAdminUserDirectories, surface refresh errors, and document the residual cross-device directory gap. Co-authored-by: Cursor <cursoragent@cursor.com>
1678 lines
72 KiB
TypeScript
1678 lines
72 KiB
TypeScript
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<void>;
|
||
systemConfig: SystemAuthConfig;
|
||
onUpdateSystemConfig: (config: Partial<SystemAuthConfig>) => 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<AdminSettingsModalProps> = ({
|
||
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<UserAccount[]>([]);
|
||
const [userSearchText, setUserSearchText] = useState('');
|
||
const [actionTip, setActionTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
||
|
||
// 角色库与编辑态
|
||
const [editRoles, setEditRoles] = useState<RoleDefinition[]>(roles);
|
||
const [selectedRoleId, setSelectedRoleId] = useState<string>(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<WorkflowDefinitionSummary[]>([]);
|
||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<number | null>(null);
|
||
const [workflowVersions, setWorkflowVersions] = useState<WorkflowVersionSummary[]>([]);
|
||
const [adminTemplates, setAdminTemplates] = useState<Array<{ asset_key: string; name: string }>>([]);
|
||
const [validationMsg, setValidationMsg] = useState<string>('');
|
||
|
||
// 刷新全量用户列表,并同步父级 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 (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
backgroundColor: 'rgba(11, 14, 20, 0.75)',
|
||
backdropFilter: 'blur(8px)',
|
||
zIndex: 1000,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '20px',
|
||
fontFamily: 'var(--font-sans)',
|
||
}}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '980px',
|
||
maxWidth: '96vw',
|
||
height: '660px',
|
||
maxHeight: '92vh',
|
||
backgroundColor: 'var(--bg-panel, #131820)',
|
||
borderRadius: '14px',
|
||
border: '1px solid var(--border-strong)',
|
||
boxShadow: 'var(--shadow-lg)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{/* 顶部标题栏 */}
|
||
<div
|
||
style={{
|
||
padding: '14px 22px',
|
||
borderBottom: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
backgroundColor: 'var(--bg-subtle, #1A212C)',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||
<div
|
||
style={{
|
||
width: '36px',
|
||
height: '36px',
|
||
borderRadius: '8px',
|
||
backgroundColor: 'rgba(242, 169, 59, 0.15)',
|
||
color: 'var(--amber-500)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<ShieldAlert size={20} />
|
||
</div>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
系统管理控制台
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: '11px',
|
||
padding: '2px 8px',
|
||
borderRadius: '999px',
|
||
backgroundColor: 'rgba(242, 169, 59, 0.12)',
|
||
color: 'var(--amber-500)',
|
||
border: '1px solid rgba(242, 169, 59, 0.3)',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
ADMIN 独占
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: '11px',
|
||
padding: '2px 8px',
|
||
borderRadius: '999px',
|
||
backgroundColor: 'var(--bg-panel)',
|
||
color: 'var(--text-muted)',
|
||
border: '1px solid var(--border)',
|
||
}}
|
||
>
|
||
运行环境: {env.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||
人员职位治理 · 注册审批中心 · 系统准入策略 · RBAC 角色权限矩阵
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||
{actionTip && (
|
||
<span
|
||
style={{
|
||
fontSize: '12px',
|
||
color: actionTip.type === 'success' ? 'var(--success)' : 'var(--danger)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
}}
|
||
>
|
||
{actionTip.type === 'success' ? <CheckCircle2 size={14} /> : <AlertCircle size={14} />}
|
||
<span>{actionTip.msg}</span>
|
||
</span>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
style={{
|
||
padding: '6px',
|
||
borderRadius: '6px',
|
||
color: 'var(--text-muted)',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主体左右分栏 */}
|
||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||
{/* 左侧垂直导航 Tab */}
|
||
<div
|
||
style={{
|
||
width: '210px',
|
||
backgroundColor: 'var(--bg-subtle, #1A212C)',
|
||
borderRight: '1px solid var(--border)',
|
||
padding: '16px 12px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('users')}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
fontSize: '13px',
|
||
fontWeight: 500,
|
||
backgroundColor: activeTab === 'users' ? 'var(--bg-panel)' : 'transparent',
|
||
color: activeTab === 'users' ? 'var(--brand-500)' : 'var(--text)',
|
||
border: activeTab === 'users' ? '1px solid var(--border)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<Users size={16} />
|
||
<span>人员与职位治理</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('approvals')}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
fontSize: '13px',
|
||
fontWeight: 500,
|
||
backgroundColor: activeTab === 'approvals' ? 'var(--bg-panel)' : 'transparent',
|
||
color: activeTab === 'approvals' ? 'var(--brand-500)' : 'var(--text)',
|
||
border: activeTab === 'approvals' ? '1px solid var(--border)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
<UserCheck size={16} />
|
||
<span>注册审批中心</span>
|
||
</div>
|
||
{pendingUsers.length > 0 && (
|
||
<span
|
||
style={{
|
||
fontSize: '10px',
|
||
fontWeight: 700,
|
||
padding: '1px 6px',
|
||
borderRadius: '999px',
|
||
backgroundColor: 'var(--amber-500)',
|
||
color: '#000000',
|
||
}}
|
||
>
|
||
{pendingUsers.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('policy')}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
fontSize: '13px',
|
||
fontWeight: 500,
|
||
backgroundColor: activeTab === 'policy' ? 'var(--bg-panel)' : 'transparent',
|
||
color: activeTab === 'policy' ? 'var(--brand-500)' : 'var(--text)',
|
||
border: activeTab === 'policy' ? '1px solid var(--border)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<Sliders size={16} />
|
||
<span>系统准入策略</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('roles')}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
fontSize: '13px',
|
||
fontWeight: 500,
|
||
backgroundColor: activeTab === 'roles' ? 'var(--bg-panel)' : 'transparent',
|
||
color: activeTab === 'roles' ? 'var(--brand-500)' : 'var(--text)',
|
||
border: activeTab === 'roles' ? '1px solid var(--border)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<Shield size={16} />
|
||
<span>RBAC 角色权限矩阵</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('workflows')}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
fontSize: '13px',
|
||
fontWeight: 500,
|
||
backgroundColor: activeTab === 'workflows' ? 'var(--bg-panel)' : 'transparent',
|
||
color: activeTab === 'workflows' ? 'var(--brand-500)' : 'var(--text)',
|
||
border: activeTab === 'workflows' ? '1px solid var(--border)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<GitBranch size={16} />
|
||
<span>工作流与模板库</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('status')}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
fontSize: '13px',
|
||
fontWeight: 500,
|
||
backgroundColor: activeTab === 'status' ? 'var(--bg-panel)' : 'transparent',
|
||
color: activeTab === 'status' ? 'var(--brand-500)' : 'var(--text)',
|
||
border: activeTab === 'status' ? '1px solid var(--border)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<Activity size={16} />
|
||
<span>系统状态与审计</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 底部系统信息 */}
|
||
<div
|
||
style={{
|
||
padding: '10px',
|
||
borderRadius: '8px',
|
||
backgroundColor: 'var(--bg-card)',
|
||
border: '1px solid var(--border)',
|
||
fontSize: '11px',
|
||
color: 'var(--text-muted)',
|
||
}}
|
||
>
|
||
<div>水木东方医疗器械 AI 工作台</div>
|
||
<div style={{ color: 'var(--text-faint)', marginTop: '2px' }}>Auth & RBAC v2.4</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧主视口 */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
padding: '20px 24px',
|
||
overflowY: 'auto',
|
||
backgroundColor: 'var(--bg-panel, #131820)',
|
||
}}
|
||
>
|
||
{/* Tab 1: 人员与职位治理 */}
|
||
{activeTab === 'users' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ position: 'relative', width: '280px' }}>
|
||
<Search
|
||
size={15}
|
||
style={{
|
||
position: 'absolute',
|
||
left: '10px',
|
||
top: '50%',
|
||
transform: 'translateY(-50%)',
|
||
color: 'var(--text-muted)',
|
||
}}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={userSearchText}
|
||
onChange={(e) => 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',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCreateUserModal(true)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
padding: '7px 14px',
|
||
borderRadius: '8px',
|
||
background: 'var(--brand-grad)',
|
||
color: '#FFFFFF',
|
||
fontSize: '12px',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)',
|
||
}}
|
||
>
|
||
<UserPlus size={15} />
|
||
<span>开通受控员工账号</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 用户列表表格 */}
|
||
<div
|
||
style={{
|
||
borderRadius: '8px',
|
||
border: '1px solid var(--border)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '12px' }}>
|
||
<thead>
|
||
<tr style={{ backgroundColor: 'var(--bg-subtle)', borderBottom: '1px solid var(--border)', color: 'var(--text-muted)' }}>
|
||
<th style={{ padding: '10px 14px', fontWeight: 600 }}>成员姓名</th>
|
||
<th style={{ padding: '10px 14px', fontWeight: 600 }}>登录账号 / 工号</th>
|
||
<th style={{ padding: '10px 14px', fontWeight: 600 }}>所属部门</th>
|
||
<th style={{ padding: '10px 14px', fontWeight: 600 }}>系统职位与角色</th>
|
||
<th style={{ padding: '10px 14px', fontWeight: 600 }}>账号状态</th>
|
||
<th style={{ padding: '10px 14px', fontWeight: 600, textAlign: 'right' }}>操作治理</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredUsers.map((u) => {
|
||
const isSelf = u.id === currentUser.id;
|
||
return (
|
||
<tr
|
||
key={u.id}
|
||
style={{
|
||
borderBottom: '1px solid var(--border-light)',
|
||
backgroundColor: 'transparent',
|
||
}}
|
||
>
|
||
<td style={{ padding: '10px 14px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<div
|
||
style={{
|
||
width: '28px',
|
||
height: '28px',
|
||
borderRadius: '50%',
|
||
backgroundColor: 'rgba(79, 107, 255, 0.15)',
|
||
color: 'var(--brand-500)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontWeight: 700,
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
{u.name.slice(0, 1)}
|
||
</div>
|
||
<span style={{ fontWeight: 600, color: 'var(--text-strong)' }}>{u.name}</span>
|
||
{isSelf && (
|
||
<span style={{ fontSize: '10px', color: 'var(--amber-500)', padding: '1px 4px', borderRadius: '4px', backgroundColor: 'rgba(242, 169, 59, 0.1)' }}>
|
||
当前账号
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '10px 14px', color: 'var(--text-muted)' }}>
|
||
<div>{u.username || '系统预设'}</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-faint)' }}>{u.employeeId || 'EMP-未设定'}</div>
|
||
</td>
|
||
<td style={{ padding: '10px 14px', color: 'var(--text)' }}>
|
||
{u.department || '未分配部门'}
|
||
</td>
|
||
<td style={{ padding: '10px 14px' }}>
|
||
<select
|
||
value={u.roleId || 'role-engineer'}
|
||
disabled={isSelf}
|
||
onChange={(e) => handleUserRoleChange(u.id, e.target.value)}
|
||
style={{
|
||
padding: '4px 8px',
|
||
borderRadius: '6px',
|
||
backgroundColor: 'var(--bg-app)',
|
||
border: '1px solid var(--border)',
|
||
color: 'var(--brand-500)',
|
||
fontSize: '12px',
|
||
fontWeight: 500,
|
||
cursor: isSelf ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
{editRoles.map((r) => (
|
||
<option key={r.id} value={r.id}>
|
||
{r.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
<td style={{ padding: '10px 14px' }}>
|
||
<span
|
||
style={{
|
||
fontSize: '11px',
|
||
padding: '2px 8px',
|
||
borderRadius: '999px',
|
||
fontWeight: 600,
|
||
backgroundColor:
|
||
u.status === 'active'
|
||
? 'rgba(53, 199, 126, 0.12)'
|
||
: u.status === 'pending_approval'
|
||
? 'rgba(242, 169, 59, 0.15)'
|
||
: 'rgba(228, 87, 75, 0.12)',
|
||
color:
|
||
u.status === 'active'
|
||
? 'var(--success)'
|
||
: u.status === 'pending_approval'
|
||
? 'var(--amber-500)'
|
||
: 'var(--danger)',
|
||
}}
|
||
>
|
||
{u.status === 'active' ? '正常在职' : u.status === 'pending_approval' ? '待审核' : '已停用'}
|
||
</span>
|
||
</td>
|
||
<td style={{ padding: '10px 14px', textAlign: 'right' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px' }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleResetUserPassword(u)}
|
||
style={{
|
||
fontSize: '11px',
|
||
color: 'var(--text-muted)',
|
||
padding: '3px 6px',
|
||
borderRadius: '4px',
|
||
border: '1px solid var(--border)',
|
||
cursor: 'pointer',
|
||
}}
|
||
title="重置初始密码并强制下次改密"
|
||
>
|
||
重置密码
|
||
</button>
|
||
{!isSelf && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleToggleUserStatus(u)}
|
||
style={{
|
||
fontSize: '11px',
|
||
color: u.status === 'active' ? 'var(--danger)' : 'var(--success)',
|
||
padding: '3px 6px',
|
||
borderRadius: '4px',
|
||
border: '1px solid var(--border)',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{u.status === 'active' ? '停用账号' : '恢复激活'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 2: 注册审批中心 */}
|
||
{activeTab === 'approvals' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||
<div style={{ paddingBottom: '12px', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
新员工注册审批与职位指派中心
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||
依据 NMPA 体系规范,自主注册申请需经系统管理员人工核准并正式授予业务职位。
|
||
</div>
|
||
</div>
|
||
|
||
{pendingUsers.length === 0 ? (
|
||
<div
|
||
style={{
|
||
padding: '48px 20px',
|
||
textAlign: 'center',
|
||
borderRadius: '10px',
|
||
backgroundColor: 'var(--bg-subtle)',
|
||
border: '1px dashed var(--border)',
|
||
}}
|
||
>
|
||
<CheckCircle2 size={36} style={{ color: 'var(--success)', margin: '0 auto 12px' }} />
|
||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
当前没有待审批的注册申请
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||
所有新注册员工均已完成合规审批并完成业务职位委派。
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
{pendingUsers.map((u) => {
|
||
return (
|
||
<div
|
||
key={u.id}
|
||
style={{
|
||
padding: '16px 20px',
|
||
borderRadius: '10px',
|
||
backgroundColor: 'var(--bg-subtle)',
|
||
border: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
<span style={{ fontSize: '14px', fontWeight: 700, color: 'var(--text-strong)' }}>
|
||
{u.name}
|
||
</span>
|
||
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
||
账号: {u.username}
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: '11px',
|
||
padding: '2px 8px',
|
||
borderRadius: '999px',
|
||
backgroundColor: 'rgba(242, 169, 59, 0.15)',
|
||
color: 'var(--amber-500)',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
待审核
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '6px', display: 'flex', gap: '16px' }}>
|
||
<span>部门: {u.department || '未填写'}</span>
|
||
<span>工号: {u.employeeId || '未填写'}</span>
|
||
<span>申请时间: {u.createdAt || '刚刚'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
<select
|
||
id={`role-select-${u.id}`}
|
||
defaultValue="role-engineer"
|
||
style={{
|
||
padding: '6px 10px',
|
||
borderRadius: '6px',
|
||
backgroundColor: 'var(--bg-app)',
|
||
border: '1px solid var(--border)',
|
||
color: 'var(--brand-500)',
|
||
fontSize: '12px',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{editRoles.map((r) => (
|
||
<option key={r.id} value={r.id}>
|
||
授予: {r.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const sel = document.getElementById(`role-select-${u.id}`) as HTMLSelectElement;
|
||
handleApproveRegistration(u, sel?.value || 'role-engineer');
|
||
}}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
padding: '6px 12px',
|
||
borderRadius: '6px',
|
||
backgroundColor: 'var(--success)',
|
||
color: '#FFFFFF',
|
||
fontSize: '12px',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<Check size={14} />
|
||
<span>通过并指派</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRejectRegistration(u)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
padding: '6px 12px',
|
||
borderRadius: '6px',
|
||
backgroundColor: 'rgba(228, 87, 75, 0.1)',
|
||
border: '1px solid rgba(228, 87, 75, 0.3)',
|
||
color: 'var(--danger)',
|
||
fontSize: '12px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<X size={14} />
|
||
<span>驳回</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 3: 系统准入策略 */}
|
||
{activeTab === 'policy' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '22px' }}>
|
||
<div style={{ paddingBottom: '12px', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
系统全局准入与注册审核策略
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||
控制系统外部开放度,关闭自主注册或开启强制审核均实时阻断越权账号。
|
||
</div>
|
||
</div>
|
||
|
||
{/* 开关 1: 开放自主注册 */}
|
||
<div
|
||
style={{
|
||
padding: '16px 20px',
|
||
borderRadius: '10px',
|
||
backgroundColor: 'var(--bg-subtle)',
|
||
border: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
允许新用户自主注册账号
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||
开启后,登录页将呈现「用户自主注册」选项卡;关闭后,仅管理员可在控制台开通账号。
|
||
</div>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={allowReg}
|
||
onChange={(e) => setAllowReg(e.target.checked)}
|
||
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 开关 2: 注册需审核 */}
|
||
<div
|
||
style={{
|
||
padding: '16px 20px',
|
||
borderRadius: '10px',
|
||
backgroundColor: 'var(--bg-subtle)',
|
||
border: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
新注册账号必须由管理员审核通过
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||
开启后,新注册账号处于「待审核」状态禁止登录,直至管理员在审批中心核准并正式分配职位。
|
||
</div>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={requireAppr}
|
||
onChange={(e) => setRequireAppr(e.target.checked)}
|
||
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={handleSavePolicy}
|
||
style={{
|
||
padding: '9px 24px',
|
||
borderRadius: '8px',
|
||
background: 'var(--brand-grad)',
|
||
color: '#FFFFFF',
|
||
fontSize: '13px',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)',
|
||
}}
|
||
>
|
||
保存并立即应用准入策略
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 4: RBAC 角色权限矩阵 */}
|
||
{activeTab === 'roles' && (
|
||
<div style={{ display: 'flex', height: '100%', gap: '16px' }}>
|
||
{/* 角色列表 */}
|
||
<div
|
||
style={{
|
||
width: '240px',
|
||
backgroundColor: 'var(--bg-subtle)',
|
||
borderRadius: '8px',
|
||
border: '1px solid var(--border)',
|
||
padding: '12px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||
全局受控角色库 ({editRoles.length})
|
||
</div>
|
||
{editRoles.map((r) => {
|
||
const isSel = r.id === selectedRoleId;
|
||
return (
|
||
<div
|
||
key={r.id}
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: isSel ? 'var(--brand-500)' : 'var(--text-strong)' }}>
|
||
{r.name}
|
||
</div>
|
||
<div style={{ fontSize: '10px', color: 'var(--text-faint)' }}>
|
||
{r.code} · {r.permissions.length} 项权限
|
||
</div>
|
||
</div>
|
||
{!r.isSystem && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteRole(r.id);
|
||
}}
|
||
style={{ color: 'var(--text-faint)', cursor: 'pointer', padding: '2px' }}
|
||
title="删除自定义角色"
|
||
>
|
||
<Trash2 size={13} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 新增自定义角色 */}
|
||
<div style={{ paddingTop: '10px', borderTop: '1px solid var(--border)' }}>
|
||
{isCreatingRole ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<input
|
||
type="text"
|
||
value={newRoleName}
|
||
onChange={(e) => 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' }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={newRoleCode}
|
||
onChange={(e) => 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' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
onClick={handleCreateRole}
|
||
style={{ flex: 1, padding: '4px', borderRadius: '4px', background: 'var(--brand-grad)', color: '#FFFFFF', fontSize: '11px', fontWeight: 600, cursor: 'pointer' }}
|
||
>
|
||
保存
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCreatingRole(false)}
|
||
style={{ padding: '4px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', color: 'var(--text-muted)', fontSize: '11px', cursor: 'pointer' }}
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCreatingRole(true)}
|
||
style={{
|
||
width: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '6px',
|
||
padding: '6px',
|
||
borderRadius: '6px',
|
||
backgroundColor: 'var(--bg-panel)',
|
||
border: '1px dashed var(--border)',
|
||
color: 'var(--brand-500)',
|
||
fontSize: '12px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<Plus size={14} />
|
||
<span>新增受控角色</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 对应角色的权限复选矩阵 */}
|
||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||
<div style={{ paddingBottom: '12px', borderBottom: '1px solid var(--border)', marginBottom: '14px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-strong)' }}>
|
||
【{currentSelectedRole?.name}】权限配置矩阵
|
||
</span>
|
||
{currentSelectedRole?.isSystem && (
|
||
<span style={{ fontSize: '11px', padding: '1px 6px', borderRadius: '4px', backgroundColor: 'rgba(79, 107, 255, 0.1)', color: 'var(--brand-500)' }}>
|
||
系统内置受控角色 (只读)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||
{currentSelectedRole?.description}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||
{ALL_PERMISSION_ITEMS.map((item) => {
|
||
const isChecked =
|
||
currentSelectedRole?.code === 'ADMIN' ||
|
||
currentSelectedRole?.permissions.includes(item.key);
|
||
const isLocked = currentSelectedRole?.isSystem;
|
||
|
||
return (
|
||
<div
|
||
key={item.key}
|
||
onClick={() => !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',
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '13px', fontWeight: 600, color: isChecked ? 'var(--text-strong)' : 'var(--text-faint)' }}>
|
||
{item.label}
|
||
</span>
|
||
<span style={{ fontSize: '10px', padding: '1px 6px', borderRadius: '4px', backgroundColor: 'var(--bg-panel)', color: 'var(--text-muted)' }}>
|
||
{item.category}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '3px' }}>
|
||
{item.desc}
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
type="checkbox"
|
||
checked={isChecked}
|
||
disabled={isLocked}
|
||
onChange={() => {}}
|
||
style={{ width: '16px', height: '16px', accentColor: 'var(--brand-500)', cursor: isLocked ? 'not-allowed' : 'pointer' }}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 5: 系统状态与审计 */}
|
||
{activeTab === 'workflows' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||
<h3 style={{ margin: 0, fontSize: '15px', color: 'var(--text-strong)' }}>工作流定义(DB 权威)</h3>
|
||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text-muted)' }}>
|
||
已发布版本不可变;新项目选用固定版本快照(项目激活冻结见 Phase 3)。
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||
<div>
|
||
{adminWorkflows.map((wf) => (
|
||
<button
|
||
key={wf.id}
|
||
type="button"
|
||
onClick={() => loadWorkflowDetail(wf.id)}
|
||
style={{
|
||
display: 'block',
|
||
width: '100%',
|
||
textAlign: 'left',
|
||
padding: '10px 12px',
|
||
marginBottom: '8px',
|
||
borderRadius: '8px',
|
||
border:
|
||
selectedWorkflowId === wf.id
|
||
? '1px solid var(--brand-500)'
|
||
: '1px solid var(--border)',
|
||
background: 'var(--bg-card)',
|
||
cursor: 'pointer',
|
||
color: 'var(--text)',
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 600 }}>{wf.name}</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
||
{wf.key} · 最新发布 v{wf.latest_published_version || '-'}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div>
|
||
{selectedWorkflowId && (
|
||
<>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px' }}>版本列表</div>
|
||
{workflowVersions.map((v) => (
|
||
<div
|
||
key={v.id}
|
||
style={{
|
||
padding: '10px',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: '8px',
|
||
marginBottom: '8px',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
<div>
|
||
v{v.version} · {v.status}
|
||
</div>
|
||
<div style={{ color: 'var(--text-muted)', wordBreak: 'break-all' }}>
|
||
hash: {v.definition_hash.slice(0, 16)}…
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||
<button type="button" onClick={() => handleValidateVersion(selectedWorkflowId, v.version)}>
|
||
校验
|
||
</button>
|
||
{v.status === 'draft' && (
|
||
<button type="button" onClick={() => handlePublishVersion(selectedWorkflowId, v.version)}>
|
||
发布
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{validationMsg && (
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{validationMsg}</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px' }}>模板库 ({adminTemplates.length})</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
||
{adminTemplates.map((t) => t.asset_key).join(', ') || '暂无模板(需 PG 环境 bootstrap)'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'status' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
||
<div style={{ paddingBottom: '12px', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||
系统安全与运行状态
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||
环境拓扑隔离与安全凭据审计。
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||
<div style={{ padding: '16px', borderRadius: '10px', backgroundColor: 'var(--bg-subtle)', border: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>当前运行环境态</div>
|
||
<div style={{ fontSize: '18px', fontWeight: 700, color: 'var(--brand-500)', marginTop: '4px' }}>
|
||
{env.toUpperCase()}
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
|
||
{env === 'dev' ? '开发态 (允许测试快速身份切换)' : '正式受控态 (强制真实登出/登录)'}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '16px', borderRadius: '10px', backgroundColor: 'var(--bg-subtle)', border: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>密码加密算法</div>
|
||
<div style={{ fontSize: '18px', fontWeight: 700, color: 'var(--success)', marginTop: '4px' }}>
|
||
Bcrypt (加盐哈希)
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
|
||
电子签名与审计事件由服务端记录并防篡改校验
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '16px', borderRadius: '10px', backgroundColor: 'var(--bg-subtle)', border: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>当前系统人员总数</div>
|
||
<div style={{ fontSize: '18px', fontWeight: 700, color: 'var(--text-strong)', marginTop: '4px' }}>
|
||
{adminUsers.length} 人
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
|
||
在职正常: {adminUsers.filter((u) => u.status === 'active').length} 人 · 待审: {pendingUsers.length} 人
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '16px', borderRadius: '10px', backgroundColor: 'var(--bg-subtle)', border: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>超管凭据配置通道</div>
|
||
<div style={{ fontSize: '18px', fontWeight: 700, color: 'var(--amber-500)', marginTop: '4px' }}>
|
||
.env 动态环境变量
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
|
||
ADMIN_USERNAME 与 ADMIN_PASSWORD 受控注入
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 管理员直接开通员工账号的内层弹窗 */}
|
||
{showCreateUserModal && (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||
zIndex: 1100,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '20px',
|
||
}}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setShowCreateUserModal(false);
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '460px',
|
||
backgroundColor: 'var(--bg-panel)',
|
||
borderRadius: '12px',
|
||
border: '1px solid var(--border-strong)',
|
||
boxShadow: 'var(--shadow-lg)',
|
||
padding: '24px',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-strong)', marginBottom: '4px' }}>
|
||
开通受控员工账号
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '18px' }}>
|
||
由管理员开通的员工,首次登录系统将被强制修改初始密码。
|
||
</div>
|
||
|
||
<form onSubmit={handleCreateUserSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
|
||
用户名 (系统账号) *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newUname}
|
||
onChange={(e) => 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' }}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
|
||
真实姓名 *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newRealName}
|
||
onChange={(e) => 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' }}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
|
||
分配职位与角色
|
||
</label>
|
||
<select
|
||
value={newRoleId}
|
||
onChange={(e) => {
|
||
setNewRoleId(e.target.value);
|
||
const r = editRoles.find((role) => role.id === e.target.value);
|
||
if (r) setNewPosition(r.name);
|
||
}}
|
||
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' }}
|
||
>
|
||
{editRoles.map((r) => (
|
||
<option key={r.id} value={r.id}>
|
||
{r.name} ({r.code})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||
部门
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newDept}
|
||
onChange={(e) => 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' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||
临时初始密码
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newInitPass}
|
||
onChange={(e) => 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' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '10px' }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCreateUserModal(false)}
|
||
style={{ padding: '7px 14px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-muted)', fontSize: '12px', cursor: 'pointer' }}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
style={{ padding: '7px 18px', borderRadius: '6px', background: 'var(--brand-grad)', color: '#FFFFFF', fontSize: '12px', fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)' }}
|
||
>
|
||
确认开通账号
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|