feat(web): full-screen system admin console, absorb AdminSettingsModal

- top-level view switch workbench|admin in App.tsx (no router; hash #turn
  anchors untouched)
- new admin/console/AdminConsoleView with left group nav in the existing
  token/inline-style idiom, plus 8 self-fetching sections: overview, kb,
  features, settings, workflows, roles, users, docs
- absorb the six AdminSettingsModal tabs into sections and delete the modal
- new api/systemAdmin.ts + types for /api/v1/admin/system/* endpoints
- playwright smoke spec web/e2e/admin-console.spec.ts (skip by default)
This commit is contained in:
weichen
2026-09-17 06:57:03 +00:00
parent f1404d5bd2
commit 0eaca61545
15 changed files with 2673 additions and 1706 deletions
+40
View File
@@ -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 });
});
+20 -29
View File
@@ -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<DeliverableItem | null>(null);
@@ -3746,6 +3747,22 @@ export default function App() {
}
};
// 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell
if (view === 'admin') {
return (
<AdminConsoleView
currentUser={currentUser}
env={systemEnv}
onExit={() => setView('workbench')}
onUsersChanged={async () => {
const users = await authClient.getAdminUsers();
setAllUsers(users);
}}
onRolesChanged={(newRoles) => setRoles(newRoles)}
/>
);
}
return (
<>
<AppShell
@@ -3773,7 +3790,7 @@ export default function App() {
onChangeRole={(_, __) => {}}
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 可入) */}
<AdminSettingsModal
isOpen={adminSettingsOpen}
onClose={() => 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}
/>
{/* 单文档可见性覆盖弹窗 */}
<DocVisibilityModal
isOpen={docVisibilityModalOpen}
+60
View File
@@ -0,0 +1,60 @@
import { authorizedFetch } from './authHeaders';
import type {
SystemConfigValue,
SystemFeature,
SystemKbInfo,
SystemOverview,
SystemSetting,
} from '../types/systemAdmin';
const BASE = '/api/v1/admin/system';
async function readError(resp: Response, fallback: string): Promise<Error> {
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<T>(path: string, fallback: string): Promise<T> {
const resp = await authorizedFetch(path);
if (!resp.ok) throw await readError(resp, fallback);
return resp.json() as Promise<T>;
}
export async function fetchSystemOverview(): Promise<SystemOverview> {
return getJson<SystemOverview>(`${BASE}/overview`, '获取系统总览失败');
}
export async function fetchSystemKb(): Promise<SystemKbInfo> {
return getJson<SystemKbInfo>(`${BASE}/kb`, '获取知识库连接信息失败');
}
export async function fetchSystemFeatures(): Promise<SystemFeature[]> {
const data = await getJson<{ features?: SystemFeature[] }>(`${BASE}/features`, '获取特性开关失败');
return data.features ?? [];
}
export async function fetchSystemSettings(): Promise<SystemSetting[]> {
const data = await getJson<{ settings?: SystemSetting[] }>(`${BASE}/settings`, '获取系统设置失败');
return data.settings ?? [];
}
export async function updateSystemSetting(
key: string,
value: SystemConfigValue,
): Promise<SystemSetting> {
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;
}
File diff suppressed because it is too large Load Diff
@@ -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<void>;
/** 角色库变更后同步父级 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: <Activity size={16} /> },
{ key: 'kb', label: '知识库连接', icon: <Database size={16} /> },
{ key: 'features', label: '特性功能开关', icon: <ToggleLeft size={16} /> },
{ key: 'settings', label: '系统设置白名单', icon: <Sliders size={16} /> },
{ key: 'workflows', label: '工作流定义', icon: <GitBranch size={16} /> },
{ key: 'roles', label: 'RBAC 角色权限', icon: <Shield size={16} /> },
{ key: 'users', label: '用户与准入治理', icon: <Users size={16} /> },
{ key: 'docs', label: '文档与模板库', icon: <FileText size={16} /> },
];
type AuthzGate = 'checking' | 'ready' | 'denied';
export const AdminConsoleView: React.FC<AdminConsoleViewProps> = ({
currentUser,
env = 'dev',
onExit,
onUsersChanged,
onRolesChanged,
}) => {
const [activeSection, setActiveSection] = useState<SectionKey>('overview');
const [gate, setGate] = useState<AuthzGate>('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 <OverviewSection />;
case 'kb':
return <KbSection />;
case 'features':
return <FeaturesSection />;
case 'settings':
return <SettingsSection />;
case 'workflows':
return <WorkflowsSection />;
case 'roles':
return <RolesSection onRolesChanged={onRolesChanged} />;
case 'users':
return <UsersSection currentUser={currentUser} onRefreshUsers={onUsersChanged} />;
case 'docs':
return <DocsSection />;
}
};
return (
<div
style={{
height: '100vh',
width: '100%',
backgroundColor: 'var(--bg-app)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
fontFamily: 'var(--font-sans)',
}}
>
{/* 顶部标题栏 */}
<div
style={{
padding: '14px 22px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'var(--bg-subtle, #1A212C)',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
type="button"
onClick={onExit}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '7px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-panel)',
color: 'var(--text-muted)',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
}}
>
<ArrowLeft size={15} />
<span>返回工作台</span>
</button>
<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 || 'dev').toUpperCase()}
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
运行总览 · 特性开关 · 设置白名单 · 工作流 · 角色权限 · 用户准入 · 文档模板
</div>
</div>
</div>
</div>
{gate === 'denied' ? (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--bg-panel, #131820)',
}}
>
<div
style={{
padding: '32px 40px',
borderRadius: '12px',
backgroundColor: 'var(--bg-subtle)',
border: '1px solid var(--border)',
textAlign: 'center',
maxWidth: '420px',
}}
>
<ShieldX size={36} style={{ color: 'var(--danger)', margin: '0 auto 12px' }} />
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
当前账号无系统管理权限
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '6px' }}>
服务端已拒绝访问(403)。系统管理台仅对 role-admin 账号开放,请返回工作台。
</div>
<button
type="button"
onClick={onExit}
style={{
marginTop: '16px',
padding: '8px 24px',
borderRadius: '8px',
background: 'var(--brand-grad)',
color: '#FFFFFF',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
}}
>
返回工作台
</button>
</div>
</div>
) : (
/* 主体左右分栏 */
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* 左侧分组导航 */}
<div
style={{
width: '210px',
backgroundColor: 'var(--bg-subtle, #1A212C)',
borderRight: '1px solid var(--border)',
padding: '16px 12px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{NAV_ITEMS.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setActiveSection(item.key)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 500,
backgroundColor: activeSection === item.key ? 'var(--bg-panel)' : 'transparent',
color: activeSection === item.key ? 'var(--brand-500)' : 'var(--text)',
border:
activeSection === item.key ? '1px solid var(--border)' : '1px solid transparent',
cursor: 'pointer',
textAlign: 'left',
}}
>
{item.icon}
<span>{item.label}</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)',
}}
>
{gate === 'checking' ? (
<div style={{ fontSize: '13px', color: 'var(--text-muted)', padding: '48px 20px', textAlign: 'center' }}>
正在校验管理员权限…
</div>
) : (
renderSection()
)}
</div>
</div>
)}
</div>
);
};
@@ -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<AdminTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<SectionHeader
title="文档与模板库"
desc="受控交付物模板清单(只读摘要)。文档的查阅、编制与治理请回到工作台右侧文档区操作。"
/>
{loading ? (
<SectionLoading text="正在获取模板库…" />
) : error ? (
<SectionError message={error} onRetry={() => void load()} />
) : (
<div style={{ borderRadius: '8px', border: '1px solid var(--border)', overflow: 'hidden' }}>
<table style={sectionTableStyle}>
<thead>
<tr
style={{
backgroundColor: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
color: 'var(--text-muted)',
}}
>
<th style={sectionThStyle}>模板名称</th>
<th style={sectionThStyle}>资产标识 (asset_key)</th>
<th style={sectionThStyle}>状态</th>
</tr>
</thead>
<tbody>
{templates.length === 0 && (
<tr>
<td colSpan={3} style={{ ...sectionTdStyle, textAlign: 'center', color: 'var(--text-faint)' }}>
暂无模板(需 PG 环境 bootstrap)
</td>
</tr>
)}
{templates.map((t) => (
<tr key={t.id || t.asset_key}>
<td style={sectionTdStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<FileText size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontWeight: 600, color: 'var(--text-strong)' }}>{t.name}</span>
</div>
</td>
<td style={{ ...sectionTdStyle, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{t.asset_key}
</td>
<td style={{ ...sectionTdStyle, color: 'var(--text)' }}>{t.status || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ ...sectionCardStyle, fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.7 }}>
一期仅提供模板库只读摘要。项目文档的编制、受控流转、版本与可见性治理均在
<span style={{ color: 'var(--brand-500)', fontWeight: 600 }}> 工作台右侧文档区 </span>
进行;点击右上角「返回工作台」即可回到协同工作界面。
</div>
</div>
);
};
@@ -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<SystemFeatureSource, { label: string; color: string; bg: string }> = {
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<SystemFeature[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取特性与功能开关…" />;
if (error) return <SectionError message={error} onRetry={() => void load()} />;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<SectionHeader
title="特性与功能开关"
desc="全量功能开关清单及其生效来源;来源为环境变量的项需在部署层调整后重启服务。"
/>
<div style={{ borderRadius: '8px', border: '1px solid var(--border)', overflow: 'hidden' }}>
<table style={sectionTableStyle}>
<thead>
<tr
style={{
backgroundColor: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
color: 'var(--text-muted)',
}}
>
<th style={sectionThStyle}>名称</th>
<th style={sectionThStyle}>分组</th>
<th style={sectionThStyle}>当前值</th>
<th style={sectionThStyle}>来源</th>
<th style={sectionThStyle}>标注</th>
</tr>
</thead>
<tbody>
{features.length === 0 && (
<tr>
<td colSpan={5} style={{ ...sectionTdStyle, textAlign: 'center', color: 'var(--text-faint)' }}>
暂无特性开关数据
</td>
</tr>
)}
{features.map((f) => {
const src = SOURCE_META[f.source] ?? SOURCE_META.default;
return (
<tr key={f.key}>
<td style={sectionTdStyle}>
<div style={{ fontWeight: 600, color: 'var(--text-strong)' }}>{f.label || f.key}</div>
<div style={{ fontSize: '11px', color: 'var(--text-faint)' }}>{f.key}</div>
{f.description && (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
{f.description}
</div>
)}
</td>
<td style={{ ...sectionTdStyle, color: 'var(--text)' }}>{f.group || '-'}</td>
<td style={{ ...sectionTdStyle, color: 'var(--brand-500)', fontWeight: 600 }}>
{formatConfigValue(f.value)}
</td>
<td style={sectionTdStyle}>
<span
style={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '999px',
fontWeight: 600,
backgroundColor: src.bg,
color: src.color,
}}
>
{src.label}
</span>
</td>
<td style={sectionTdStyle}>{f.requiresRestart && <RestartBadge />}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
@@ -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<SystemKbInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取知识库连接信息…" />;
if (error || !data) return <SectionError message={error || '获取知识库连接信息失败'} onRetry={() => void load()} />;
const rows: Array<{ label: string; value: React.ReactNode }> = [
{
label: '在线状态',
value: (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<StatusDot ok={data.online} />
<span style={{ color: data.online ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}>
{data.online ? '在线' : '离线'}
</span>
</span>
),
},
{ 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<SectionHeader
title="知识库连接信息"
desc="RAG 知识库服务的连接拓扑与模型配置(只读,由环境变量受控注入)。"
/>
<div style={{ ...sectionCardStyle, padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<tbody>
{rows.map((row) => (
<tr key={row.label}>
<td style={{ ...sectionTdStyle, width: '180px', color: 'var(--text-muted)', fontWeight: 600 }}>
{row.label}
</td>
<td style={{ ...sectionTdStyle, color: 'var(--text-strong)' }}>{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
@@ -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 }) => (
<div style={sectionCardStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{typeof ok === 'boolean' && <StatusDot ok={ok} />}
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{label}</span>
</div>
<div style={{ fontSize: '18px', fontWeight: 700, color: 'var(--text-strong)', marginTop: '6px' }}>
{value}
</div>
{hint && (
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>{hint}</div>
)}
</div>
);
export const OverviewSection: React.FC = () => {
const [data, setData] = useState<SystemOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取系统运行总览…" />;
if (error || !data) return <SectionError message={error || '获取系统总览失败'} onRetry={() => void load()} />;
const healthy = data.status === 'ok' || data.status === 'healthy';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
<SectionHeader
title="系统运行总览"
desc="服务健康、运行环境与核心依赖(数据库 / 知识库 / 任务存储)实时状态。"
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<OverviewCard
label="系统健康"
ok={healthy}
value={healthy ? '运行正常' : data.status}
hint={`已持续运行 ${formatUptime(data.uptimeSeconds)}`}
/>
<OverviewCard
label="运行环境"
value={(data.env || 'dev').toUpperCase()}
hint={data.env === 'dev' ? '开发态 (允许测试快速身份切换)' : '受控环境态'}
/>
<OverviewCard label="Agent 执行后端" value={data.agentBackend || '-'} hint="对话与任务流的执行通道" />
<OverviewCard
label="数据库"
ok={data.databaseReady}
value={data.databaseReady ? '已就绪' : '未就绪'}
hint={`Schema 版本: ${data.schemaVersion || '-'}`}
/>
<OverviewCard
label="知识库 (RAG)"
ok={data.kbOnline}
value={data.kbOnline ? '在线' : '离线'}
hint="检索增强问答依赖的知识库服务"
/>
<OverviewCard
label="任务存储"
ok={data.taskStoreReady}
value={data.taskStoreReady ? '已就绪' : '未就绪'}
hint="异步任务与运行投影的持久化存储"
/>
</div>
</div>
);
};
@@ -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<RolesSectionProps> = ({ onRolesChanged }) => {
const [editRoles, setEditRoles] = useState<RoleDefinition[]>([]);
const [selectedRoleId, setSelectedRoleId] = useState<string>('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<string | null>(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 <SectionLoading text="正在获取角色库…" />;
if (error) return <SectionError message={error} onRetry={() => void load()} />;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', height: '100%' }}>
{tip && (
<div
style={{
fontSize: '12px',
color: tip.type === 'success' ? 'var(--success)' : 'var(--danger)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
{tip.type === 'success' ? <CheckCircle2 size={14} /> : <AlertCircle size={14} />}
<span>{tip.msg}</span>
</div>
)}
<div style={{ display: 'flex', flex: 1, minHeight: 0, 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',
overflowY: 'auto',
}}
>
<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>
</div>
);
};
@@ -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<SystemSetting[]>([]);
const [drafts, setDrafts] = useState<Record<string, SystemConfigValue>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取系统设置…" />;
if (error) return <SectionError message={error} onRetry={() => 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }}>
<SectionHeader
title="系统设置白名单"
desc="仅白名单内的设置允许在线修改;标注「需重启生效」的项保存后需重启服务方可应用。"
/>
{tip && (
<span
style={{
fontSize: '12px',
color: tip.type === 'success' ? 'var(--success)' : 'var(--danger)',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
}}
>
{tip.type === 'success' ? <CheckCircle2 size={14} /> : <AlertCircle size={14} />}
<span>{tip.msg}</span>
</span>
)}
</div>
{groups.length === 0 && <SectionLoading text="暂无可展示的系统设置" />}
{groups.map((g) => (
<div key={g.group} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{g.group}</div>
{g.items.map((s) => {
const draft = drafts[s.key];
const dirty = s.writable && draft !== s.value;
return (
<div
key={s.key}
style={{
...sectionCardStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-strong)' }}>
{s.label || s.key}
</span>
{!s.writable && (
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '4px',
backgroundColor: 'var(--bg-panel)',
color: 'var(--text-muted)',
border: '1px solid var(--border)',
}}
>
只读
</span>
)}
{s.requiresRestart && <RestartBadge />}
</div>
{s.description && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
{s.description}
</div>
)}
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
{s.key} · 默认值: {formatConfigValue(s.default)}
{s.updatedAt && ` · 更新于 ${s.updatedAt}${s.updatedBy ? ` by ${s.updatedBy}` : ''}`}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexShrink: 0 }}>
{isBooleanSetting(s) ? (
<input
type="checkbox"
checked={Boolean(draft)}
disabled={!s.writable}
onChange={(e) => setDrafts((prev) => ({ ...prev, [s.key]: e.target.checked }))}
style={{
width: '18px',
height: '18px',
cursor: s.writable ? 'pointer' : 'not-allowed',
accentColor: 'var(--brand-500)',
}}
/>
) : (
<input
type="text"
value={String(draft ?? '')}
disabled={!s.writable}
onChange={(e) => 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 && (
<button
type="button"
disabled={!dirty || savingKey === s.key}
onClick={() => void handleSave(s)}
style={{
padding: '6px 16px',
borderRadius: '6px',
background: dirty ? 'var(--brand-grad)' : 'var(--bg-app)',
color: dirty ? '#FFFFFF' : 'var(--text-faint)',
border: dirty ? 'none' : '1px solid var(--border)',
fontSize: '12px',
fontWeight: 600,
cursor: dirty && savingKey !== s.key ? 'pointer' : 'not-allowed',
}}
>
{savingKey === s.key ? '保存中…' : '保存'}
</button>
)}
</div>
</div>
);
})}
</div>
))}
</div>
);
};
@@ -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<void>;
}
export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefreshUsers }) => {
const [activeTab, setActiveTab] = useState<UsersTab>('users');
// 全量账号列表(含待审核及禁用状态)
const [adminUsers, setAdminUsers] = useState<UserAccount[]>([]);
const [editRoles, setEditRoles] = useState<RoleDefinition[]>([]);
const [userSearchText, setUserSearchText] = useState('');
const [actionTip, setActionTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取用户目录…" />;
if (error) return <SectionError message={error} onRetry={() => void load()} />;
const subTabButton = (tab: UsersTab, label: string, icon: React.ReactNode, badge?: number) => (
<button
type="button"
onClick={() => setActiveTab(tab)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 14px',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 500,
backgroundColor: activeTab === tab ? 'var(--bg-panel)' : 'transparent',
color: activeTab === tab ? 'var(--brand-500)' : 'var(--text)',
border: activeTab === tab ? '1px solid var(--border)' : '1px solid transparent',
cursor: 'pointer',
}}
>
{icon}
<span>{label}</span>
{typeof badge === 'number' && badge > 0 && (
<span
style={{
fontSize: '10px',
fontWeight: 700,
padding: '1px 6px',
borderRadius: '999px',
backgroundColor: 'var(--amber-500)',
color: '#000000',
}}
>
{badge}
</span>
)}
</button>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 子 Tab:人员治理 / 注册审批 / 准入策略 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
padding: '6px',
borderRadius: '10px',
backgroundColor: 'var(--bg-subtle)',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{subTabButton('users', '人员与职位治理', <Users size={15} />)}
{subTabButton('approvals', '注册审批中心', <UserCheck size={15} />, pendingUsers.length)}
{subTabButton('policy', '系统准入策略', <Sliders size={15} />)}
</div>
{actionTip && (
<span
style={{
fontSize: '12px',
color: actionTip.type === 'success' ? 'var(--success)' : 'var(--danger)',
display: 'flex',
alignItems: 'center',
gap: '4px',
paddingRight: '8px',
}}
>
{actionTip.type === 'success' ? <CheckCircle2 size={14} /> : <AlertCircle size={14} />}
<span>{actionTip.msg}</span>
</span>
)}
</div>
{/* 子 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>
)}
{/* 管理员直接开通员工账号的内层弹窗 */}
{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>
);
};
@@ -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<WorkflowDefinitionSummary[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<number | null>(null);
const [workflowVersions, setWorkflowVersions] = useState<WorkflowVersionSummary[]>([]);
const [validationMsg, setValidationMsg] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <SectionLoading text="正在获取工作流定义…" />;
if (error) return <SectionError message={error} onRetry={() => void refreshWorkflows()} />;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<SectionHeader
title="工作流定义(DB 权威)"
desc="已发布版本不可变;新项目选用固定版本快照(项目激活冻结见 Phase 3)。"
/>
<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>
))}
{adminWorkflows.length === 0 && (
<div style={{ fontSize: '12px', color: 'var(--text-faint)' }}>暂无工作流定义</div>
)}
</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>
);
};
@@ -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 }) => (
<div style={{ paddingBottom: '12px', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>{title}</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>{desc}</div>
</div>
);
export const SectionLoading: React.FC<{ text?: string }> = ({ text = '加载中…' }) => (
<div
style={{
padding: '48px 20px',
textAlign: 'center',
borderRadius: '10px',
backgroundColor: 'var(--bg-subtle)',
border: '1px dashed var(--border)',
color: 'var(--text-muted)',
fontSize: '13px',
}}
>
{text}
</div>
);
export const SectionError: React.FC<{ message: string; onRetry?: () => void }> = ({ message, onRetry }) => (
<div
style={{
padding: '24px 20px',
borderRadius: '10px',
backgroundColor: 'var(--danger-bg)',
border: '1px solid rgba(228, 87, 75, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--danger)', fontSize: '13px' }}>
<AlertCircle size={16} />
<span>{message}</span>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '5px 12px',
borderRadius: '6px',
border: '1px solid rgba(228, 87, 75, 0.3)',
color: 'var(--danger)',
fontSize: '12px',
cursor: 'pointer',
backgroundColor: 'transparent',
}}
>
<RefreshCw size={13} />
<span>重试</span>
</button>
)}
</div>
);
export const RestartBadge: React.FC = () => (
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '4px',
backgroundColor: 'rgba(242, 169, 59, 0.12)',
color: 'var(--amber-500)',
border: '1px solid rgba(242, 169, 59, 0.3)',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
需重启生效
</span>
);
export const StatusDot: React.FC<{ ok: boolean }> = ({ ok }) => (
<span
style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: ok ? 'var(--success)' : 'var(--danger)',
boxShadow: ok ? '0 0 6px rgba(53, 199, 126, 0.6)' : '0 0 6px rgba(228, 87, 75, 0.6)',
flexShrink: 0,
}}
/>
);
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)} 秒`;
}
+54
View File
@@ -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;
}