diff --git a/src/api.ts b/src/api.ts index 4412a8b..d8bb8f4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ // API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 import { auth } from './auth' -import { rdmCommandProjectId } from './rdmManagement' +import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, @@ -168,8 +168,11 @@ export const api = { // ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ---- // receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。 - rdmProjectCommand: async (project: Project, id: string, params: Record = {}): Promise => - api.rdmCommand(id, { ...params, projectId: rdmCommandProjectId(project) }), + rdmProjectCommand: async (project: Project, id: string, params: Record = {}): Promise => { + const error = rdmProjectCommandError(project, id) + if (error) throw new Error(error) + return api.rdmCommand(id, { ...params, projectId: rdmCommandProjectId(project) }) + }, rdmCommand: (id: string, params: Record = {}): Promise => auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index 7d5f55b..8420ef6 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -8,8 +8,8 @@ import type { SettingDesc } from '../contracts' import { SettingsShell, Btn, EmptyState } from '@ui' import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal' import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections' -import { rdmCommandProjectId } from '../rdmManagement' -import { projectActionError } from '../projectmeta' +import { rdmProjectReadError } from '../rdmManagement' +import { projectActionError, projectWorkError } from '../projectmeta' export function ProjectSettingsModal() { const projectId = useStore(s => s.ui.projectSettingsId) @@ -46,15 +46,13 @@ export function ProjectSettingsModal() { }, [schema]) if (!projectId) return null - let rdmError = '' - try { - if (!project) throw new Error('项目已不可用,请刷新项目列表或联系项目负责人') - rdmCommandProjectId(project) - } catch (e) { rdmError = String(e).replace(/^Error:\s*/, '') } + const rdmError = rdmProjectReadError(project) + const writeError = projectWorkError(project) const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix' const save = async () => { if (!projectId) return + if (writeError) { store.toast(writeError); return } try { await api.putProjectSettings(projectId, unflatten(draft)) setSaved(true) @@ -90,7 +88,7 @@ export function ProjectSettingsModal() { ) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? ( <>
- 此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。 + {writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
{Object.entries(groups).map(([g, descs]) => (
@@ -106,7 +104,7 @@ export function ProjectSettingsModal() { ))}
))} - 保存项目设置 + 保存项目设置 ) : null} diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index ec3ed0d..8b875a5 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react' import { api } from '../api' import type { Project } from '../contracts' +import { projectWorkError } from '../projectmeta' import { useStore } from '../store' import { Btn, EmptyState, Icon } from '@ui' import { @@ -151,6 +152,7 @@ interface MemberRow { owner: string; role: string; addedBy: string; addedAt: str export function RdmMembersSection({ project }: { project: Project }) { const projectId = project.id + const writeError = projectWorkError(project) const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) const [rows, setRows] = useState(null) const [err, setErr] = useState('') @@ -183,13 +185,13 @@ export function RdmMembersSection({ project }: { project: Project }) { - -

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

+

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

@@ -200,7 +202,7 @@ export function RdmMembersSection({ project }: { project: Project }) { - @@ -230,6 +232,7 @@ interface DocSubmissionView { export function RdmDutiesSection({ project }: { project: Project }) { const projectId = project.id + const writeError = projectWorkError(project) const [docId, setDocId] = useState('') const [sub, setSub] = useState(undefined) const [err, setErr] = useState('') @@ -274,13 +277,13 @@ export function RdmDutiesSection({ project }: { project: Project }) {
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
{(sub.digest ?? '').slice(0, 16)}…
- - -
@@ -302,14 +305,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
-
-

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

+

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

{notice &&
{notice}
} {err &&
{err}
} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 69f1f4e..fd52fac 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -6,15 +6,32 @@ import type { Project } from './contracts' import { projectWorkError } from './projectmeta' export function rdmCommandProjectId(project: Project): string { - const error = projectWorkError(project) - if (error) throw new Error(error) const id = project.settings?.['rdm.projectId'] - if (project.settings?.['rdm.state'] !== 'ready' || typeof id !== 'string' || !id.trim()) { + if (typeof id !== 'string' || !id.trim()) { throw new Error('项目尚未绑定,请联系项目负责人完成项目接入') } return id.trim() } +/** Historical project reads remain available; this does not grant read permission. */ +export function rdmProjectReadError(project: Project | undefined): string | null { + const state = project?.settings?.['rdm.state'] + if (project && (state === 'ready' || state === 'archived' || state === 'closed')) { + try { rdmCommandProjectId(project); return null } + catch (e) { return String(e).replace(/^Error:\s*/, '') } + } + return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入' +} + +const PROJECT_READ_COMMANDS = new Set([ + 'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', + 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get', +]) + +export function rdmProjectCommandError(project: Project, command: string): string | null { + return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project) +} + /** 系统管理「RDM 角色权限」分区消费的命令面。 */ export const RDM_ROLES_COMMANDS = [ 'rdm.role.create', diff --git a/test/project-access.test.ts b/test/project-access.test.ts index 6266b3e..c6e82c2 100644 --- a/test/project-access.test.ts +++ b/test/project-access.test.ts @@ -53,6 +53,30 @@ beforeEach(async () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('project command identity', () => { + it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => { + const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) + const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', + 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get'] + for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' }) + expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ + { path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } }, + ]) + }) + + it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => { + const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) + for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole', + 'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) { + await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow() + } + expect(requests).toEqual([]) + }) + it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => { await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' }) expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
账号项目角色添加人添加时间
{m.role === 'owner' ? OWNER : MEMBER} {m.addedBy || '—'} {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}