From f96a59112351d80b0065463220739d51c0046d97 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 05:59:57 +0000 Subject: [PATCH 1/3] Add admin system settings panel for managed model and service config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P2 前端入口——系统受管项(模型/密钥/Tavily/遥测/运行基础设施)需要唯一的管理员写入面;系统管理新增「模型与服务配置」分区消费 GET/PUT /api/settings/system。 Evidence: vitest design-guard 6/6 通过;npm run build 成功;面板仅渲染 scope=system 项,保存后重 GET 回填,403 如实提示。 Impact: contracts 加 SettingDesc.scope;api 加 get/putSystemSettings;AdminZone 注册新分区;无既有面板变更。 Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2;Backend PR 188 --- src/api.ts | 4 ++ src/components/AdminZone.tsx | 4 +- src/components/adminSections.tsx | 73 ++++++++++++++++++++++++++++++++ src/contracts.ts | 2 + 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index e416742..6f24c64 100644 --- a/src/api.ts +++ b/src/api.ts @@ -126,6 +126,10 @@ export const api = { json<{ schema: SettingDesc[]; defaults: Record; user: Record; project: Record }>(r)), putUserSettings: (values: Record) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), + // P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。 + getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r => + json<{ schema: SettingDesc[]; defaults: Record; system: Record }>(r)), + putSystemSettings: (values: Record) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()), listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()), diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index 87f1846..ae9762a 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -7,7 +7,7 @@ import { useState } from 'react' import { store, useStore } from '../store' import { EmptyState, Icon, type IconName } from '@ui' -import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections' +import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' import { RdmAuditWorkspace } from './RdmAuditWorkspace' import { RdmRolesPanel } from './adminRdmSections' import type { AdminZoneSection } from '../api' @@ -31,6 +31,8 @@ const SECTIONS: AdminSectionDef[] = [ // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, + // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 + { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => }, { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => }, { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => }, ] diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 973047c..4e56ab8 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -6,6 +6,8 @@ import { api } from '../api' import { store, useStore } from '../store' import type { AgentProfile } from '../contracts' import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui' +import { SettingInput, flatten, unflatten } from './SettingsModal' +import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' import { randomPassword } from '../passwords' @@ -293,3 +295,74 @@ export function PluginWorkspacesPanel() { ) } + +// ---- 模型与服务配置(P2 系统受管项的唯一写入面)---- + +/** + * SystemSettingsPanel:消费 GET/PUT /api/settings/system。 + * - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施); + * 非受管项属于用户/项目偏好,不在本面板出现。 + * - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符 + * 原样传回,由服务端视为未修改。 + * - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。 + */ +export function SystemSettingsPanel() { + const [schema, setSchema] = useState(null) + const [draft, setDraft] = useState>({}) + const [loaded, setLoaded] = useState(false) + const [forbidden, setForbidden] = useState(false) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + + const load = async () => { + setErr(''); setForbidden(false) + try { + const out = await api.getSystemSettings() + setSchema(out.schema.filter(d => d.scope === 'system')) + setDraft(flatten(out.system)) + setLoaded(true) + } catch (e) { + if (String(e).includes('403')) { setForbidden(true); setLoaded(true) } + else setErr(String(e)) + } + } + useEffect(() => { load() }, []) + + const groups: Record = {} + for (const d of schema ?? []) (groups[d.group] ??= []).push(d) + + const save = async () => { + setErr(''); setNotice(''); setBusy(true) + try { + await api.putSystemSettings(unflatten(draft)) + setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)') + await load() + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + if (!loaded) return 加载系统设置… + if (forbidden) return 系统设置仅管理员可配置 + + return ( +
+

+ 模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。 +

+ {Object.entries(groups).map(([group, descs]) => ( + + {descs.map(d => ( + + setDraft(prev => ({ ...prev, [d.key]: v }))} /> + + ))} + + ))} +
+ 保存系统配置 +
+ {notice &&
{notice}
} + {err &&
{err}
} +
+ ) +} diff --git a/src/contracts.ts b/src/contracts.ts index da69011..70348ef 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -270,6 +270,8 @@ export interface SettingDesc { default?: unknown; options?: string[]; effect: 'hot' | 'new-run' | 'restart'; secret?: boolean; + /** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */ + scope?: 'system'; } // ---- 插件 ---- From dfcea5c988017f7f620d9e4956cc1be10be7920e Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:34:52 +0000 Subject: [PATCH 2/3] Wire members section to Picker with explicit role changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P7-A——成员添加从手填账号升级为可搜索人员选择器(@ui Picker:搜索/浏览/键盘/分页),仅提交稳定账号;候选「已加入」标记杜绝隐式身份变更;成员表拆分「专业角色」与「项目身份」(中文标签)两列;身份调整走 rdm.project.member.setRole 显式命令(升/降按钮)。 Evidence: tsc 通过;design-guard 6/6;npm run build 成功;Picker 行为由 UI #53 的 7 用例覆盖。 Impact: RdmMembersSection 重构(member.add/setRole/remove 三命令消费);rdmManagement 增 setRole payload;account.list 适配层 query 透传+旧基座首页本地过滤降级。 Refs: 提案 r1 §3/P7;UI #53;RDM #345 --- src/components/adminRdmSections.tsx | 90 +++++++++++++++++++++-------- src/rdmManagement.ts | 1 + 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index a0cab8d..16d0fa2 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -3,14 +3,14 @@ // 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal // 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef, useCallback } from 'react' import { api } from '../api' import { useStore } from '../store' -import { Btn, EmptyState, Icon } from '@ui' +import { Btn, EmptyState, Icon, Picker} from '@ui' import { ROLE_ACTIONS, parseReviewers, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, - memberAddPayload, memberRemovePayload, + memberAddPayload, memberRemovePayload, memberSetRolePayload, assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload, permCheckPayload, describeSource, rolesListPlaceholder, describeRoleListError, @@ -144,17 +144,43 @@ export function RdmRolesPanel() { ) } -// ---- 项目设置:成员 ---- +// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- -interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string } +interface MemberRow { + owner: string; role: string; addedBy: string; addedAt: string + profRole?: { roleId: string; name: string } | null +} 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 [picked, setPicked] = useState([]) const [role, setRole] = useState<'member' | 'owner'>('member') + // 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。 + const rowsRef = useRef(null) + rowsRef.current = rows + + /** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。 + * query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时 + * 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */ + const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => { + const out = await api.rdmCommand<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>( + 'rdm.account.list', { projectId, cursor, limit, query }) + const joined = new Set((rowsRef.current ?? []).map(m => m.owner)) + let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({ + id: a.owner, + title: a.name || a.owner, + subtitle: a.owner, + badge: joined.has(a.owner) ? '已加入' : undefined, + })) + if (query !== '' && cursor === '') { + const q = query.toLowerCase() + items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q)) + } + return { items, nextCursor: out.next || undefined } + }, [projectId]) const refresh = async () => { try { @@ -162,7 +188,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { setRows(out.members ?? []) } catch (e) { setErr(String(e)) } } - useEffect(() => { refresh() }, [projectId]) + useEffect(() => { setPicked([]); refresh() }, [projectId]) const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) @@ -170,40 +196,56 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { catch (e) { setErr(String(e)) } finally { setBusy(false) } } + const pickedId = picked[0] ?? '' + return (
- setMember(e.target.value)} /> + -
-

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

+

候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

- + {(rows ?? []).map(m => ( - - + + + - + ))} - {rows !== null && rows.length === 0 && } - {rows === null && } + {rows !== null && rows.length === 0 && } + {rows === null && }
账号项目角色添加人添加时间
成员专业角色项目身份添加人添加时间
{m.owner}{m.role === 'owner' ? OWNER : MEMBER}{m.owner}{m.profRole?.name ? · {m.profRole.name} : null}{m.profRole?.name || '—'}{m.role === 'owner' ? 项目负责人 : 项目成员} {m.addedBy || '—'} {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'} + {' '} + +
暂无成员
加载中…
暂无成员
加载中…
{notice &&
{notice}
} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 220b3c0..18fa78e 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -76,6 +76,7 @@ export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ role 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 memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role }) export const assignDocDutiesPayload = ( projectId: string, docId: string, author: string, reviewers: string[], approver: string, From 880eff74f8d78b9c698e442f68b5ce665193788c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:56:32 +0000 Subject: [PATCH 3/3] Rebuild duties and permission pages on catalog, matrix and tri-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P7-B/C——文档职责页升级为「文档清单+选中职责详情」(duty.catalog/get:名称/类型/阶段/在途轮次,当前职责经三个项目成员 Picker 回填并与冻结名单分列;在途轮次失效确认;批量勾选+统一三权走 assign.doc.batch,整批拒绝列原因);权限页升级为「单成员详情+多人矩阵」双视图(中文标签、系统范围动作折叠为技术详情、矩阵单元格悬停解释、行展开三态覆盖——沿用默认/项目内允许/项目内禁止,恢复默认走 perm.unset 实际撤销)。 Evidence: tsc、design-guard 6/6、npm run build 通过;服务端语义由 RDM #346(标签/矩阵)与 #349(unset)测试覆盖;折叠使用 @ui Collapsible 原语。 Impact: RdmDutiesSection/RdmPermMatrixSection 重构;无跨页变更。 Refs: 提案 r1 §4/§5/P7;RDM #346/#349 --- src/components/adminRdmSections.tsx | 396 ++++++++++++++++++++++------ 1 file changed, 318 insertions(+), 78 deletions(-) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 16d0fa2..c72852d 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -6,9 +6,9 @@ import { useEffect, useState, useRef, useCallback } from 'react' import { api } from '../api' import { useStore } from '../store' -import { Btn, EmptyState, Icon, Picker} from '@ui' +import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui' import { - ROLE_ACTIONS, parseReviewers, + ROLE_ACTIONS, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, memberAddPayload, memberRemovePayload, memberSetRolePayload, assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, @@ -254,7 +254,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { ) } -// ---- 项目设置:文档职责 · 整文审签 ---- +// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)---- interface DocSubmissionView { round: number @@ -267,87 +267,194 @@ interface DocSubmissionView { sections: Record } +interface DutyCatalogDoc { + docId: string; typeKey: string; title: string; stage?: string; sections: number + assigned: boolean; author?: string; reviewers?: string[]; approver?: string; activeRound?: number +} + +interface DutyGetOut { + docId: string; title: string; assigned: boolean + roles: { author: string; reviewers: string[]; approver: string } + active?: { round: number; state: string; createdBy: string } | null + sections: { sectionId: string; title: string; overridden: boolean }[] +} + +/** 职责候选 = 当前项目成员(方案 §4:候选只能来自项目成员)。 + * member.list 为小全量列表:本地 query 过滤 + 游标分页。 */ +function useMemberPickerPage(members: { owner: string; role: string }[] | null) { + return useCallback(async (query: string, cursor: string, limit: number) => { + const q = query.trim().toLowerCase() + const all = (members ?? []) + .filter(m => q === '' || m.owner.toLowerCase().includes(q)) + .map(m => ({ id: m.owner, title: m.owner, subtitle: m.role === 'owner' ? '项目负责人' : '项目成员' })) + const start = cursor === '' ? 0 : all.findIndex(x => x.id === cursor) + 1 + const page = all.slice(start, start + limit) + return { + items: page, + nextCursor: start + limit < all.length ? page[page.length - 1]?.id : undefined, + } + }, [members]) +} + export function RdmDutiesSection({ projectId }: { projectId: string }) { + const [docs, setDocs] = useState(null) + const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null) const [docId, setDocId] = useState('') + const [detail, setDetail] = useState(undefined) + const [author, setAuthor] = useState([]) + const [reviewers, setReviewers] = useState([]) + const [approver, setApprover] = useState([]) const [sub, setSub] = useState(undefined) + const [batchSel, setBatchSel] = useState>(new Set()) + const [batchAuthor, setBatchAuthor] = useState([]) + const [batchReviewers, setBatchReviewers] = useState([]) + const [batchApprover, setBatchApprover] = useState([]) 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 memberPage = useMemberPickerPage(members) + const refreshCatalog = async () => { + try { + const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId }) + setDocs(out.docs ?? []) + } catch (e) { setErr(String(e)); setDocs([]) } + } const refreshStatus = async (id: string) => { - if (!id) return + if (!id) { setSub(undefined); 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) } + } catch (e) { setSub(undefined) } } + /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ + const selectDoc = async (id: string) => { + setErr(''); setNotice(''); setDocId(id); setDetail(undefined); setBatchSel(new Set()) + if (!id) return + try { + const out = await cmd('rdm.duty.get', { projectId, docId: id }) + setDetail(out) + setAuthor(out.assigned && out.roles.author ? [out.roles.author] : []) + setReviewers(out.assigned ? out.roles.reviewers ?? [] : []) + setApprover(out.assigned && out.roles.approver ? [out.roles.approver] : []) + } catch (e) { setErr(String(e)) } + await refreshStatus(id) + } + useEffect(() => { + setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) + refreshCatalog() + api.rdmCommand<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) + .then(out => setMembers(out.members ?? [])).catch(() => setMembers([])) + }, [projectId]) const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) - try { await fn(); setNotice(label); await refreshStatus(docId) } + try { await fn(); setNotice(label); await refreshCatalog(); if (docId) await selectDoc(docId) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } + const dirty = detail != null && ( + (author[0] ?? '') !== (detail.assigned ? detail.roles.author : '') + || JSON.stringify(reviewers) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : []) + || (approver[0] ?? '') !== (detail.assigned ? detail.roles.approver : '')) + + const saveDisabled = busy || !docId || !author[0] || !approver[0] || reviewers.length === 0 + || (detail !== null && detail !== undefined && detail.active != null && !window.confirm( + `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) + + const save = () => run('已保存当前职责', () => + cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + + const batchApply = () => run(`已批量分配 ${batchSel.size} 份文档`, () => + cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({ + docId: d, author: batchAuthor[0], reviewers: batchReviewers, approver: batchApprover[0] })) })) + return (
-
-
- setDocId(e.target.value)} /> - refreshStatus(docId)}>查看整文状态 +

文档清单(职责分配对象为整份文档;搜索/浏览同一目录)

+ + + + {(docs ?? []).map(d => ( + + + + + + + + + ))} + {docs === null && } + {docs !== null && docs.length === 0 && } + +
文档类型 / 阶段当前职责状态
{ const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n) }} /> + · {d.docId}{d.typeKey}{d.stage ? ` / ${d.stage}` : ''}{d.assigned + ? `编 ${d.author} · 审 ${(d.reviewers ?? []).join('、')} · 批 ${d.approver}` : '未分配'}{d.activeRound ? 第 {d.activeRound} 轮在途 : d.assigned ? — : 未分配}{d.sections} 章节
加载中…
暂无文档
+ + {detail === null && docId && 读取职责失败} + {detail && ( +
+

{detail.title} · 当前职责{detail.active ? 在途第 {detail.active.round} 轮(变更将失效需重提交) : null}

+
+
+
+
+ {reviewers.length > 0 && 已选:{reviewers.join('、')}}
+
+
+
+ 保存当前职责 + {detail.active && 保存将使在途第 {detail.active.round} 轮失效(提交人 {detail.active.createdBy})} +
+ {detail.sections.length > 0 && ( +

章节覆盖:{detail.sections.filter(x => x.overridden).length}/{detail.sections.length} 章节单独指派(覆盖文档默认)。

+ )}
-
+ )} - {sub === null && 该文档尚无整文提交记录} {sub && ( -
-

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

-
编制 {sub.duties.author} · 审核 {(sub.duties.reviewers ?? []).join('、')} · 批准 {sub.duties.approver}
+
+

整文审签 · 第 {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 === null && docId &&

该文档尚无整文提交记录(当前职责仍可分配)。

} - {sub === undefined && docId &&

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

} - -

分配文档三权(整文)

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

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

+ {batchSel.size > 0 && ( +
+

批量分配(已选 {batchSel.size} 份;任一校验失败整批拒绝并列原因)

+
+
+
+
+
+
+
+ 批量分配 {batchSel.size} 份文档 + +
+
+ )} {notice &&
{notice}
} {err &&
{err}
} @@ -355,33 +462,102 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) { ) } +// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖; +// 与实际判定同源——UI 不重算,全部读服务端解释) ---- -// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ---- - -interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string } +interface MatrixAction { + action: string; label?: string; scope?: string + allowed: boolean; source: string; reason?: string +} interface MatrixOut { projectId: string; member: string; role: string profRole: { roleId: string; name: string } | null actions: MatrixAction[] } +interface MatrixRowOut { + member: string; role: string + profRole: { roleId: string; name: string } | null + actions: MatrixAction[] +} +interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] } + +function actionTitle(a: MatrixAction) { return a.label || a.action } + +/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */ +function ActionDetailTable({ actions, onOverride, canManage }: { + actions: MatrixAction[] + onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void + canManage?: boolean +}) { + const project = actions.filter(a => a.scope !== 'system') + const system = actions.filter(a => a.scope === 'system') + const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' => + a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default' + return ( + <> + + {canManage ? : null} + + {project.map(a => ( + + + + + + {canManage ? ( + + ) : null} + + ))} + +
动作生效来源原因(拒绝时)项目覆盖
{actionTitle(a)}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'} + +
+ {system.length > 0 && ( + + + + + {system.map(a => ( + + + + + + + ))} + +
动作生效来源原因
{actionTitle(a)}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'}
+
+ )} + + ) +} export function RdmPermMatrixSection({ projectId }: { projectId: string }) { const me = useStore(s => s.me) + const [view, setView] = useState<'member' | 'matrix'>('member') const [member, setMember] = useState('') const [members, setMembers] = useState(null) const [matrix, setMatrix] = useState(null) + const [batch, setBatch] = useState(null) + const [expand, setExpand] = useState('') const [err, setErr] = useState('') + const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) - useEffect(() => { + const loadMembers = () => api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) .then(out => { setMembers(out.members ?? []) - // 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。 if (me?.owner) setMember(prev => prev || me.owner) - }) - .catch(e => setErr(String(e))) - }, [projectId, me?.owner]) + }).catch(e => setErr(String(e))) + useEffect(() => { loadMembers() }, [projectId, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps const query = async (m: string) => { if (!m) return @@ -390,43 +566,107 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) { setMatrix(await api.rdmCommand('rdm.perm.check', permCheckPayload(projectId, m))) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } + const queryBatch = async () => { + setErr(''); setBusy(true); setBatch(null) + try { + setBatch(await api.rdmCommand('rdm.perm.matrix', { projectId })) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps + + /** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */ + const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => { + setErr(''); setNotice(''); setBusy(true) + try { + if (mode === 'default') { + await api.rdmCommand('rdm.perm.unset', { projectId, member: m, action }) + setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`) + } else { + await api.rdmCommand('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' }) + setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`) + } + await query(m) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + const canManage = !!matrix && matrix.role !== undefined return (
- setView(e.target.value as 'member' | 'matrix')}> + + - query(member)}>查看矩阵 + {view === 'member' && ( + <> + + query(member)}>查看 + + )}
-

普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。

+

普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。

- {matrix && ( + + {view === 'member' && matrix && ( <>
- 成员 {matrix.member} · 项目关系 {matrix.role} - {matrix.profRole && <> · 专业角色 {matrix.profRole.name}({matrix.profRole.roleId})} + 成员 {matrix.member} · 项目身份 {matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role} + {matrix.profRole && <> · 专业角色 {matrix.profRole.name}}
- - - - {(matrix.actions ?? []).map(a => ( - - - - - - - ))} - -
动作生效来源原因(拒绝时)
{a.action}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'}
+ override(matrix.member, action, mode)} /> )} - {!matrix && !err && members !== null &&

选择成员后展示 12 项动作的有效权限与第一拒绝原因。

} + {view === 'member' && !matrix && !err && members !== null &&

选择成员后展示各动作的有效权限、来源与第一拒绝原因。

} + + {view === 'matrix' && batch && ( + + + + {(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => ( + + ))} + + + {batch.rows.map(row => ( + + + {row.actions.filter(a => a.scope !== 'system').map(a => ( + + ))} + + ))} + +
成员{actionTitle(a)}
+ + · {row.role === 'owner' ? '负责人' : '成员'} + + {a.allowed ? ✓ : ✗} +
+ )} + {view === 'matrix' && batch && expand && ( +
+

{expand} · 单成员详情与三态覆盖

+ {(() => { + const row = batch.rows.find(r => r.member === expand) + if (!row) return null + return override(expand, action, mode)} /> + })()} +
+ )} + + {notice &&
{notice}
} {err &&
{err}
}
) -} +} \ No newline at end of file