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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)} 秒`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user