Preserve project history reads after archive or closure
Why: Project readiness is a write gate. Applying it to member, permission, and document status queries hides history that the backend intentionally keeps readable after archival or closure. Evidence: SELF archived and closed read regressions failed before the fix. Full npm test, npm run build, and git diff --check passed with the amended code. Receipts: /tmp/project-member-frontend-archived-read-red-20261013.json and /tmp/project-member-frontend-archived-read-green-20261013.json. Impact: Resolve canonical project IDs separately from command readiness. Preserve known read commands and existing settings tabs for historical projects; keep mutations and new work blocked with existing disabled controls. Server authorization remains authoritative. Refs: Dev project-member-access Task 4; independent Frontend review P2
This commit is contained in:
+6
-3
@@ -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 <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) }),
|
||||
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
|
||||
const error = rdmProjectCommandError(project, id)
|
||||
if (error) throw new Error(error)
|
||||
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||||
},
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -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' ? (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
|
||||
</div>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
@@ -106,7 +104,7 @@ export function ProjectSettingsModal() {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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 = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
@@ -183,13 +185,13 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
<option value="member">member(成员)</option>
|
||||
<option value="owner">owner(负责人)</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !member.trim()}
|
||||
onClick={() => run(`已添加 ${member.trim()}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
|
||||
setMember('')
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
<p className="ad-hint">{writeError ?? '加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。'}</p>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
@@ -200,7 +202,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
<td><button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
|
||||
</tr>
|
||||
@@ -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<DocSubmissionView | null | undefined>(undefined)
|
||||
const [err, setErr] = useState('')
|
||||
@@ -274,13 +277,13 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || sub.state !== 'submitted'}
|
||||
onClick={() => run('整文终批完成(各章节已基线)',
|
||||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="ad-mini" disabled={busy || !!writeError || sub.state !== 'submitted'}
|
||||
onClick={() => run('已通过整文审核',
|
||||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="ad-mini" disabled={busy || !!writeError || sub.state !== 'submitted'}
|
||||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||||
</div>
|
||||
@@ -302,14 +305,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<button className="lg-submit ad-create-btn"
|
||||
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
disabled={busy || !!writeError || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
|
||||
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
|
||||
<button className="ad-mini" disabled={busy || !docId}
|
||||
<button className="ad-mini" disabled={busy || !!writeError || !docId}
|
||||
onClick={() => run('整文已提交审核(全文锁定)',
|
||||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||||
</div>
|
||||
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
<p className="ad-hint">{writeError ?? '三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。'}</p>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
|
||||
+20
-3
@@ -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',
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user