diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index a5f50a4..87f1846 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -9,6 +9,7 @@ import { store, useStore } from '../store' import { EmptyState, Icon, type IconName } from '@ui' import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections' import { RdmAuditWorkspace } from './RdmAuditWorkspace' +import { RdmRolesPanel } from './adminRdmSections' import type { AdminZoneSection } from '../api' interface AdminSectionDef { @@ -27,6 +28,8 @@ function ProfilesPanelWrapper() { const SECTIONS: AdminSectionDef[] = [ { key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => }, + // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 + { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => }, { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => }, @@ -57,13 +60,14 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) { export function AdminZone() { const me = useStore(s => s.me) const declared = useStore(s => s.adminSections) + const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable) const [sectionKey, setSectionKey] = useState('users') const isAdmin = !!me?.isAdmin // 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部 // 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。 if (!isAdmin && declared.length === 0) return null const sections: AdminSectionDef[] = [ - ...(isAdmin ? SECTIONS : []), + ...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []), ...declared.map(s => ({ key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon), desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)', diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index 1b9a4e1..e1b0377 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -7,11 +7,15 @@ import { store, useStore } from '../store' import type { SettingDesc } from '../contracts' import { SettingsShell, Btn, EmptyState } from '@ui' import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal' +import { RdmMembersSection, RdmDutiesSection } from './adminRdmSections' export function ProjectSettingsModal() { const projectId = useStore(s => s.ui.projectSettingsId) const projects = useStore(s => s.projects) const project = projects.find(p => p.id === projectId) + const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable) + const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable) + const [tab, setTab] = useState('project') const [schema, setSchema] = useState([]) const [draft, setDraft] = useState>({}) const [loaded, setLoaded] = useState(false) @@ -49,16 +53,23 @@ export function ProjectSettingsModal() { return ( undefined} + active={tab} + onSelect={setTab} onClose={() => store.openProjectSettings(null)} - sections={[{ key: 'project', label: '本项目', icon: 'folder' }]} + sections={[ + { key: 'project', label: '本项目', icon: 'folder' }, + // U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。 + ...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []), + ...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []), + ]} footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} >
- {!loaded ? ( + {tab === 'members' && } + {tab === 'duties' && } + {tab !== 'members' && tab !== 'duties' && !loaded ? ( 加载中… - ) : ( + ) : tab !== 'members' && tab !== 'duties' ? ( <>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。 @@ -79,7 +90,7 @@ export function ProjectSettingsModal() { ))} 保存项目设置 - )} + ) : null}
) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx new file mode 100644 index 0000000..9d7d443 --- /dev/null +++ b/src/components/adminRdmSections.tsx @@ -0,0 +1,309 @@ +// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 / +// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力 +// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal +// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 +// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 +import { useEffect, useState } from 'react' +import { api } from '../api' +import { useStore } from '../store' +import { Btn, EmptyState, Icon } from '@ui' +import { + ROLE_ACTIONS, parseReviewers, + roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, + memberAddPayload, memberRemovePayload, + assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, + docFinalPayload, docStatusPayload, +} from '../rdmManagement' + +// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ---- + +interface RoleView { + roleId: string + name: string + disabled: boolean + version: number + actions: Record | null + accounts: string[] | null +} + +export function RdmRolesPanel() { + const me = useStore(s => s.me) + const [roles, setRoles] = useState(null) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const [newName, setNewName] = useState('') + // 动作编辑器 + const [editRole, setEditRole] = useState('') + const [editAction, setEditAction] = useState(ROLE_ACTIONS[0]) + const [editDef, setEditDef] = useState(false) + const [editCap, setEditCap] = useState(true) + // 绑定 + const [assignOwner, setAssignOwner] = useState('') + + const refresh = async () => { + try { + const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list') + setRoles(out.roles ?? []) + } catch (e) { setErr(String(e)) } + } + useEffect(() => { refresh() }, []) + + const run = async (label: string, fn: () => Promise) => { + setErr(''); setNotice(''); setBusy(true) + try { await fn(); setNotice(label); await refresh() } + catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + if (!me?.isAdmin) return 仅平台管理员可管理专业角色 + + const sel = roles?.find(r => r.roleId === editRole) + const editCurrent = sel?.actions?.[editAction] + + return ( +
+
+
+ setNewName(e.target.value)} /> + +
+

新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。

+
+ + + + + {(roles ?? []).map(r => ( + + + + + + + + + ))} + {roles !== null && roles.length === 0 && } + {roles === null && } + +
角色状态版本动作配置绑定账号
{r.name}
{r.roleId}
{r.disabled ? 已停用 : 生效}v{r.version}{Object.keys(r.actions ?? {}).length} 项{(r.accounts ?? []).join('、') || '—'}
暂无专业角色
加载中…
+ +

动作配置(默认 ∩ 上限)

+
+ + + + + +
+ {sel && editCurrent &&

当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}

} + +

绑定账号(每账号一个生效角色)

+
+ setAssignOwner(e.target.value)} /> + + +
+ + {notice &&
{notice}
} + {err &&
{err}
} +
+ ) +} + +// ---- 项目设置:成员 ---- + +interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string } + +export function RdmMembersSection({ projectId }: { projectId: string }) { + const [rows, setRows] = useState(null) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const [member, setMember] = useState('') + const [role, setRole] = useState<'member' | 'owner'>('member') + + const refresh = async () => { + try { + const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) + setRows(out.members ?? []) + } catch (e) { setErr(String(e)) } + } + useEffect(() => { refresh() }, [projectId]) + + const run = async (label: string, fn: () => Promise) => { + setErr(''); setNotice(''); setBusy(true) + try { await fn(); setNotice(label); await refresh() } + catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + return ( +
+
+
+ setMember(e.target.value)} /> + + +
+

加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。

+
+ + + + {(rows ?? []).map(m => ( + + + + + + + + ))} + {rows !== null && rows.length === 0 && } + {rows === null && } + +
账号项目角色添加人添加时间
{m.owner}{m.role === 'owner' ? OWNER : MEMBER}{m.addedBy || '—'}{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}
暂无成员
加载中…
+ {notice &&
{notice}
} + {err &&
{err}
} +
+ ) +} + +// ---- 项目设置:文档职责 · 整文审签 ---- + +interface DocSubmissionView { + round: number + state: string + digest: string + createdBy: string + createdAt: string + duties: { author: string; reviewers: string[]; approver: string } + reviews: Record + sections: Record +} + +export function RdmDutiesSection({ projectId }: { projectId: string }) { + const [docId, setDocId] = useState('') + const [sub, setSub] = useState(undefined) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const [author, setAuthor] = useState('') + const [reviewers, setReviewers] = useState('') + const [approver, setApprover] = useState('') + + const cmd = (id: string, params: Record) => api.rdmCommand(id, params) + + const refreshStatus = async (id: string) => { + if (!id) return + try { + const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id)) + setSub(out.submission) + } catch (e) { setErr(String(e)); setSub(undefined) } + } + + const run = async (label: string, fn: () => Promise) => { + setErr(''); setNotice(''); setBusy(true) + try { await fn(); setNotice(label); await refreshStatus(docId) } + catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + return ( +
+
+
+ setDocId(e.target.value)} /> + refreshStatus(docId)}>查看整文状态 +
+
+ + {sub === null && 该文档尚无整文提交记录} + {sub && ( +
+

第 {sub.round} 轮 · {sub.state === 'submitted' ? 审签在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

+
编制 {sub.duties.author} · 审核 {(sub.duties.reviewers ?? []).join('、')} · 批准 {sub.duties.approver}
+
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
+
{(sub.digest ?? '').slice(0, 16)}…
+
+ + + +
+
+ )} + + {sub === undefined && docId &&

输入文档 ID 后查看整文状态。

} + +

分配文档三权(整文)

+
+ setDocId(e.target.value)} /> + setAuthor(e.target.value)} /> + setReviewers(e.target.value)} /> + setApprover(e.target.value)} /> +
+
+ + +
+

三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。

+ + {notice &&
{notice}
} + {err &&
{err}
} +
+ ) +} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts new file mode 100644 index 0000000..9a9eb3d --- /dev/null +++ b/src/rdmManagement.ts @@ -0,0 +1,95 @@ +// U6/KTD5:RDM 管理与项目页面的纯逻辑层——能力门控(与 rdmAuditReady 同源语义: +// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与 +// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不 +// 携带任何身份字段(G1-5)。 + +/** 系统管理「RDM 角色权限」分区消费的命令面。 */ +export const RDM_ROLES_COMMANDS = [ + 'rdm.role.create', + 'rdm.role.action', + 'rdm.role.assign', + 'rdm.role.disable', + 'rdm.role.list', +] as const + +/** 项目设置「成员」页消费的命令面。 */ +export const RDM_MEMBER_COMMANDS = [ + 'rdm.project.member.list', + 'rdm.project.member.add', + 'rdm.project.member.remove', +] as const + +/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */ +export const RDM_DOC_REVIEW_COMMANDS = [ + 'rdm.review.assign.doc', + 'rdm.review.doc.submit', + 'rdm.review.doc.approve', + 'rdm.review.doc.reject', + 'rdm.review.doc.final', + 'rdm.review.doc.status', +] as const + +export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined + +function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean { + if (!Array.isArray(cmds)) return false + return need.every(n => cmds.some(c => c?.id === n && c.enabled === true)) +} + +/** 角色分区是否就绪(普通基座无 rdm.role.* → 隐藏)。 */ +export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) => + ready(cmds, RDM_ROLES_COMMANDS) + +/** 项目成员页是否就绪。 */ +export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) => + ready(cmds, RDM_MEMBER_COMMANDS) + +/** 文档职责/整文审签页是否就绪。 */ +export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) => + ready(cmds, RDM_DOC_REVIEW_COMMANDS) + +/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */ +export const ROLE_ACTIONS = [ + 'project.create', 'project.archive', 'member.manage', 'duty.assign', + 'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate', + 'trace.edit', 'knowledge.publish', 'project.settings', +] as const + +// ---- 载荷构造(字段名与 RDM 命令 schema 一一对应) ---- + +export const roleCreatePayload = (name: string) => ({ name }) +export const roleActionPayload = (roleId: string, action: string, def: boolean, cap: boolean) => + ({ roleId, action, default: def, cap }) +export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId }) +export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled }) + +export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') => + ({ projectId, member, role }) +export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member }) + +export const assignDocDutiesPayload = ( + projectId: string, docId: string, author: string, reviewers: string[], approver: string, +) => ({ projectId, docId, author, reviewers, approver }) + +export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId }) +export const docApprovePayload = (projectId: string, docId: string, note?: string) => + note ? { projectId, docId, note } : { projectId, docId } +export const docRejectPayload = (projectId: string, docId: string, reason?: string) => + reason ? { projectId, docId, reason } : { projectId, docId } +/** round=0(未指定)不携带——服务端只认在途轮次,旧轮次传参即拒。 */ +export const docFinalPayload = (projectId: string, docId: string, round = 0) => + round > 0 ? { projectId, docId, round } : { projectId, docId } +export const docStatusPayload = (projectId: string, docId: string) => ({ projectId, docId }) + +/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */ +export function parseReviewers(csv: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const part of csv.split(',')) { + const v = part.trim() + if (!v || seen.has(v)) continue + seen.add(v) + out.push(v) + } + return out +} diff --git a/src/store.ts b/src/store.ts index d78fbbb..53a07ee 100644 --- a/src/store.ts +++ b/src/store.ts @@ -9,6 +9,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate' import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { rdmAuditReady } from './workspaces' +import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady } from './rdmManagement' type FontSizeChoice = 13 | 14 | 16 | 18 const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18] @@ -115,6 +116,10 @@ interface AppState { serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] /** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */ rdmAuditAvailable: boolean + /** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */ + rdmRolesAvailable: boolean + rdmMembersAvailable: boolean + rdmDocReviewAvailable: boolean /** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */ adminSections: AdminZoneSection[] ui: UIState @@ -181,6 +186,7 @@ let state: AppState = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], rdmAuditAvailable: false, + rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, adminSections: [], ui: loadUI(), connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, @@ -794,7 +800,12 @@ export const store = { // 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。 try { const cmds = await api.listCommands() - set({ rdmAuditAvailable: rdmAuditReady(cmds) }) + set({ + rdmAuditAvailable: rdmAuditReady(cmds), + rdmRolesAvailable: rdmRolesReady(cmds), + rdmMembersAvailable: rdmMembersReady(cmds), + rdmDocReviewAvailable: rdmDocReviewReady(cmds), + }) } catch { set({ rdmAuditAvailable: false }) } try { const sections = await api.adminZoneSections() diff --git a/test/rdm-management.test.ts b/test/rdm-management.test.ts index 4fe890e..1b2cc6e 100644 --- a/test/rdm-management.test.ts +++ b/test/rdm-management.test.ts @@ -37,7 +37,7 @@ describe('RDM 页面能力门控', () => { describe('RDM 动作清单(12 项,与 perms.AllActions 对齐)', () => { it('精确等于 RDM 动作枚举且不重复', () => { expect([...ROLE_ACTIONS].sort()).toEqual([ - 'duty.assign', 'doc.publish', 'doc.read', 'doc.write', 'knowledge.publish', + 'doc.publish', 'doc.read', 'doc.write', 'duty.assign', 'knowledge.publish', 'member.manage', 'project.archive', 'project.create', 'project.settings', 'review.participate', 'stage.advance', 'trace.edit', ])