diff --git a/e2e/rdm-duties.playwright.ts b/e2e/rdm-duties.playwright.ts new file mode 100644 index 0000000..388c882 --- /dev/null +++ b/e2e/rdm-duties.playwright.ts @@ -0,0 +1,160 @@ +// Real React/Picker interaction with deterministic HTTP responses; no live account or database. +import { test, expect, type Page } from '@playwright/test' + +test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server') + +function deferred() { + let resolve!: () => void + const promise = new Promise(ok => { resolve = ok }) + return { promise, resolve } +} + +async function dutiesPage(page: Page) { + const writes: { command: string; params: Record }[] = [] + const calls: { command: string; params: Record }[] = [] + const holds = new Map; release: ReturnType }>() + const active = new Set() + const submissions: Record = { 'doc-A': null, 'doc-B': null } + const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', + 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', + 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status'] + const docs = [ + { docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' }, + { docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' }, + ] + await page.route('**/api/**', async route => { + const request = route.request() + const path = new URL(request.url()).pathname + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true } + else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '', + settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }] + else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} } + else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true })) + else if (path.startsWith('/api/commands/')) { + const command = decodeURIComponent(path.substring('/api/commands/'.length)) + const params = request.postDataJSON().params as Record + expect(params.projectId).toBe('domain-7') + calls.push({ command, params }) + const hold = holds.get(`${command}:${params.docId ?? ''}`) + if (hold) { hold.started.resolve(); await hold.release.promise } + let payload: unknown = {} + if (command === 'rdm.duty.catalog') payload = { docs } + else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) } + else if (command === 'rdm.duty.get') { + const doc = docs.find(d => d.docId === params.docId)! + payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned, + roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], + active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null } + } else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] } + else { writes.push({ command, params }) } + body = { payload } + } else if (path.endsWith('/cards')) body = { cards: [] } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) + }) + await page.goto('/') + await page.getByRole('button', { name: '项目设置', exact: true }).click() + await page.getByRole('tab', { name: /文档职责/ }).click() + await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible() + return { writes, calls, active, submissions, + hold(command: string, docId: string) { + const pending = { started: deferred(), release: deferred() } + holds.set(`${command}:${docId}`, pending) + return pending + } } +} + +async function chooseMember(page: Page, label: string, owner: string) { + await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner) + await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click() +} + +async function settleResponse(page: Page, command: string, release: () => void) { + const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`)) + release() + await response + await page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done())))) +} + +test('a newly assigned document can submit its first whole-document review', async ({ page }) => { + const fixture = await dutiesPage(page) + await page.getByRole('button', { name: '文档A', exact: true }).click() + await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible() + const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true }) + await expect(submit).toBeEnabled() + await submit.click() + await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible() + expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }]) +}) + +test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => { + const fixture = await dutiesPage(page) + fixture.active.add('doc-A') + const dialogs: string[] = [] + let accept = false + page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() }) + await page.getByRole('button', { name: '文档A', exact: true }).click() + await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible() + const save = page.getByRole('button', { name: '保存当前职责', exact: true }) + await expect(save).toBeDisabled() + expect(dialogs).toEqual([]) + await chooseMember(page, '编制人', 'bob') + await expect(save).toBeEnabled() + expect(dialogs).toEqual([]) + await save.click() + expect(dialogs).toHaveLength(1) + expect(fixture.writes).toEqual([]) + accept = true + await save.click() + await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() + expect(dialogs).toHaveLength(2) + expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { + projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', + } }]) +}) + +test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => { + const fixture = await dutiesPage(page) + const old = fixture.hold('rdm.duty.get', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('button', { name: '文档B', exact: true }).click() + await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() + await settleResponse(page, 'rdm.duty.get', old.release.resolve) + await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() + expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) + await chooseMember(page, '批准人', 'alice') + await page.getByRole('button', { name: '保存当前职责', exact: true }).click() + await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() + expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { + projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', + } }]) +}) + +test('a late review status cannot replace the selected document frozen review', async ({ page }) => { + const fixture = await dutiesPage(page) + const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author, + createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} }) + fixture.submissions['doc-A'] = submission(1, 'alice') + fixture.submissions['doc-B'] = submission(2, 'bob') + const old = fixture.hold('rdm.review.doc.status', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('button', { name: '文档B', exact: true }).click() + await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() + await settleResponse(page, 'rdm.review.doc.status', old.release.resolve) + await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0) +}) + +test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => { + const fixture = await dutiesPage(page) + const old = fixture.hold('rdm.duty.get', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click() + await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0) + await settleResponse(page, 'rdm.duty.get', old.release.resolve) + expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) +}) diff --git a/src/App.tsx b/src/App.tsx index 94a8180..147723f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,7 +77,7 @@ export function App() { const h = (e: KeyboardEvent) => { if (authState === 'ok' && (e.metaKey || e.ctrlKey)) { const key = e.key.toLowerCase() - if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return } + if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return } if (key === 'b') { e.preventDefault(); openContext(); return } } workspaceShortcuts(e) @@ -114,10 +114,14 @@ export function App() { // me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有 // 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单 // 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。 - store.refreshMe() - store.refreshProjects() - store.refreshProfiles() - store.refreshPlugins() + let active = true + store.refreshMe().then(() => { + if (!active) return + void store.refreshProjects() + void store.refreshProfiles() + void store.refreshPlugins() + }) + return () => { active = false } }, [authState]) const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null) diff --git a/src/api.ts b/src/api.ts index 6f24c64..d8bb8f4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ // API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 import { auth } from './auth' +import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, @@ -14,11 +15,16 @@ const BASE = '' * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } -async function json(res: Response): Promise { +async function checked(res: Response): Promise { if (!res.ok) { const text = await res.text() throw new Error(`${res.status}: ${text}`) } + return res +} + +async function json(res: Response): Promise { + await checked(res) return res.json() as Promise } @@ -26,7 +32,7 @@ export const api = { listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), - deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }), + deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked), listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 @@ -46,15 +52,23 @@ export const api = { // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }> + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + auth.beginSession() + return me }), // 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定; // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }> + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + auth.beginSession() + return me }), - logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }), + logout: () => { + const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }) + auth.clear() + return request + }, changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }> @@ -125,7 +139,7 @@ export const api = { getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r => 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) }), + putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked), // P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。 getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r => json<{ schema: SettingDesc[]; defaults: Record; system: Record }>(r)), @@ -154,6 +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 => { + 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/auth.ts b/src/auth.ts index 993728e..0ccf069 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -17,11 +17,22 @@ function pickStorage(): KV { } const storage = pickStorage() +const identityListeners = new Set<() => void>() +function identityChanged() { identityListeners.forEach(listener => listener()) } export const auth = { apiKey(): string { return storage.getItem(KEY) ?? '' }, - setKey(key: string) { storage.setItem(KEY, key.trim()) }, - clear() { storage.removeItem(KEY) }, + setKey(key: string) { + const next = key.trim() + if (next === this.apiKey()) return + storage.setItem(KEY, next) + identityChanged() + }, + clear() { storage.removeItem(KEY); identityChanged() }, + onIdentityChange(listener: () => void) { + identityListeners.add(listener) + return () => { identityListeners.delete(listener) } + }, // 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端 // 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。 beginSession() { this.clear() }, @@ -33,7 +44,7 @@ export const auth = { headers.set('Authorization', `Bearer ${key}`) return fetch(input, { ...init, headers }) }, - // 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。 + // 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。 logout() { this.clear() try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ } diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 6c51c64..a867858 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -2,7 +2,6 @@ // LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key); // ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。 import { useState } from 'react' -import { auth } from '../auth' import { api } from '../api' import { Icon, BrandMark } from '@ui' @@ -43,8 +42,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on setErr(''); setBusy(true) try { const me = await api.login(owner.trim(), password) - // 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。 - auth.beginSession() // 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明; // 受限会话先进设密门,完成前不放行。 if (me.sessionScope === 'set_password_only' || me.mustChangePassword) { @@ -62,7 +59,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on try { // 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。 const me = await api.redeemLoginKey(key.trim()) - auth.beginSession() if (me.sessionScope === 'set_password_only' || me.mustChangePassword) { onMustChange(me.owner) return diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index eae4004..8420ef6 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -8,6 +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 { rdmProjectReadError } from '../rdmManagement' +import { projectActionError, projectWorkError } from '../projectmeta' export function ProjectSettingsModal() { const projectId = useStore(s => s.ui.projectSettingsId) @@ -16,21 +18,26 @@ export function ProjectSettingsModal() { const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable) const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable) const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable) + const owner = useStore(s => s.me?.owner) const [tab, setTab] = useState('project') const [schema, setSchema] = useState([]) const [draft, setDraft] = useState>({}) const [loaded, setLoaded] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState('') useEffect(() => { if (!projectId) return - setLoaded(false) + let active = true + setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([]) api.getSettings(projectId).then(s => { + if (!active) return setSchema(s.schema) setDraft(flatten(s.project)) setLoaded(true) - }).catch(() => undefined) - }, [projectId]) + }).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') }) + return () => { active = false } + }, [projectId, owner]) const groups = useMemo(() => { const g: Record = {} @@ -39,15 +46,19 @@ export function ProjectSettingsModal() { }, [schema]) if (!projectId) return null + 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) setTimeout(() => setSaved(false), 1500) } catch (e) { - store.toast(`项目设置保存失败:${e}`) + store.toast(`项目设置保存失败:${projectActionError(e)}`) } } @@ -64,18 +75,20 @@ export function ProjectSettingsModal() { ...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []), ...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []), ]} - footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} + footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} >
- {tab === 'members' && } - {tab === 'duties' && } - {tab === 'matrix' && } + {rdmTab && rdmError ? {rdmError} : project && <> + {tab === 'members' && } + {tab === 'duties' && } + {tab === 'matrix' && } + } {tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? ( - 加载中… + {error || '加载中…'} ) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? ( <>
- 此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。 + {writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
{Object.entries(groups).map(([g, descs]) => (
@@ -91,7 +104,7 @@ export function ProjectSettingsModal() { ))}
))} - 保存项目设置 + 保存项目设置 ) : null}
diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index e08fadf..65d8d13 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -7,7 +7,7 @@ import { api } from '../api' import { Icon } from '@ui' import type { Conversation } from '../contracts' import { relativeTime, filterTasks } from '../tasklist' -import { projectStage } from '../projectmeta' +import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta' import { buildWorkspaceEntries } from '../workspaces' /** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */ @@ -68,8 +68,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, const [newProjectName, setNewProjectName] = useState('') const createConversation = async (projectId?: string) => { - await store.createTask(projectId) - onConversationOpen?.() + try { + await store.createTask(projectId) + onConversationOpen?.() + } catch { /* store owns the actionable failure toast */ } } const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable }) @@ -77,7 +79,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, const pinned = visible.filter(t => t.pinned) const rest = visible.filter(t => !t.pinned) const currentProject = projects.find(p => p.id === currentProjectId) - const stage = projectStage(currentProject?.settings) + const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings) + const workError = currentProject ? projectWorkError(currentProject) : null const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null @@ -162,8 +165,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, onChange={e => setNewProjectName(e.target.value)} onKeyDown={async e => { if (e.key === 'Enter' && newProjectName.trim()) { - await store.createProject(newProjectName.trim()) - setCreatingProject(false); setNewProjectName('') + try { + await store.createProject(newProjectName.trim()) + setCreatingProject(false); setNewProjectName('') + } catch { /* keep the input available for a corrected name */ } } if (e.key === 'Escape') setCreatingProject(false) }} @@ -179,7 +184,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, )} {rest.length === 0 && pinned.length === 0 && ( -
从「新建对话」开始,描述你想完成的工作。
+
{workError ?? '从「新建对话」开始,描述你想完成的工作。'}
)} {rest.map(t => taskRow(t))} @@ -224,7 +229,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, setMenu(null)}> {projects.map(p => { const count = tasks.filter(t => t.projectId === p.id && !t.archived).length - const pstage = projectStage(p.settings) + const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings) return ( { switchProject(p.id); setMenu(null) }}> {p.name} diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 0a0934f..b8abad1 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -5,6 +5,8 @@ // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 import { useEffect, useState, useRef, useCallback } from 'react' import { api } from '../api' +import type { Project } from '../contracts' +import { projectWorkError } from '../projectmeta' import { useStore } from '../store' import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui' import { @@ -151,7 +153,10 @@ interface MemberRow { profRole?: { roleId: string; name: string } | null } -export function RdmMembersSection({ projectId }: { projectId: string }) { +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('') const [notice, setNotice] = useState('') @@ -166,7 +171,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { * 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 }>( + const out = await cmd<{ 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 => ({ @@ -180,15 +185,15 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q)) } return { items, nextCursor: out.next || undefined } - }, [projectId]) + }, [project]) const refresh = async () => { try { - const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) + const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) setRows(out.members ?? []) } catch (e) { setErr(String(e)) } } - useEffect(() => { setPicked([]); refresh() }, [projectId]) + useEffect(() => { setPicked([]); refresh() }, [project]) const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) @@ -200,6 +205,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { return (
+ {writeError &&

{writeError}

}
项目成员 -
@@ -232,15 +238,15 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { {m.addedBy || '—'} {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'} - {' '} - + () => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除 ))} @@ -296,7 +302,9 @@ function useMemberPickerPage(members: { owner: string; role: string }[] | null) }, [members]) } -export function RdmDutiesSection({ projectId }: { projectId: string }) { +export function RdmDutiesSection({ project }: { project: Project }) { + const projectId = project.id + const writeError = projectWorkError(project) const [docs, setDocs] = useState(null) const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null) const [docId, setDocId] = useState('') @@ -313,46 +321,95 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) { const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) - const cmd = (id: string, params: Record) => api.rdmCommand(id, params) + const cmd = (id: string, params: Record) => api.rdmProjectCommand(project, id, params) const memberPage = useMemberPickerPage(members) + const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 }) + requests.current.project = project + const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch + const currentDocument = (epoch: number, selection: number, id: string) => + currentProject(epoch) && requests.current.selection === selection && requests.current.selected === id - const refreshCatalog = async () => { + const refreshCatalog = async (epoch = requests.current.epoch) => { + const request = ++requests.current.catalog try { const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId }) + if (!currentProject(epoch) || request !== requests.current.catalog) return setDocs(out.docs ?? []) - } catch (e) { setErr(String(e)); setDocs([]) } + } catch (e) { + if (!currentProject(epoch) || request !== requests.current.catalog) return + setErr(String(e)); setDocs([]) + } } - const refreshStatus = async (id: string) => { - if (!id) { setSub(undefined); return } + const refreshStatus = async (id: string, epoch: number, selection: number) => { + if (!id || !currentDocument(epoch, selection, id)) return + const request = ++requests.current.status try { const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id)) + if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return setSub(out.submission) - } catch (e) { setSub(undefined) } + } catch (e) { + if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return + setSub(undefined); setErr(`整文状态读取失败:${e}`) + } } /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ - const selectDoc = async (id: string) => { - setErr(''); setNotice(''); setDocId(id); setDetail(undefined); setBatchSel(new Set()) + const selectDoc = async (id: string, keepNotice = false) => { + const epoch = requests.current.epoch + const selection = ++requests.current.selection + requests.current.selected = id + requests.current.status++ + setErr(''); if (!keepNotice) setNotice('') + setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) + setAuthor([]); setReviewers([]); setApprover([]) if (!id) return try { const out = await cmd('rdm.duty.get', { projectId, docId: id }) + if (!currentDocument(epoch, selection, id)) return + if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档') 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) + } catch (e) { + if (!currentDocument(epoch, selection, id)) return + setDetail(null); setErr(String(e)) + } + await refreshStatus(id, epoch, selection) } useEffect(() => { + const epoch = ++requests.current.epoch + requests.current.selected = ''; requests.current.selection++; requests.current.status++ + requests.current.action++ 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]) + setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([]) + setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) + setErr(''); setNotice(''); setBusy(false) + void refreshCatalog(epoch) + cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) + .then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) }) + .catch(() => { if (currentProject(epoch)) setMembers([]) }) + return () => { + requests.current.epoch++; requests.current.selection++; requests.current.status++ + requests.current.catalog++; requests.current.action++; requests.current.selected = '' + } + }, [project]) const run = async (label: string, fn: () => Promise) => { + const epoch = requests.current.epoch + const selection = requests.current.selection + const selected = requests.current.selected + const action = ++requests.current.action + const currentAction = () => currentProject(epoch) && action === requests.current.action setErr(''); setNotice(''); setBusy(true) - try { await fn(); setNotice(label); await refreshCatalog(); if (docId) await selectDoc(docId) } - catch (e) { setErr(String(e)) } finally { setBusy(false) } + try { + await fn() + if (!currentAction() || !currentDocument(epoch, selection, selected)) return + setNotice(label) + await refreshCatalog(epoch) + if (currentAction() && currentDocument(epoch, selection, selected) && selected) await selectDoc(selected, true) + } catch (e) { + if (currentAction() && currentDocument(epoch, selection, selected)) setErr(String(e)) + } finally { if (currentAction()) setBusy(false) } } const dirty = detail != null && ( @@ -360,12 +417,15 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) { || 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 saveDisabled = busy || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0 - const save = () => run('已保存当前职责', () => - cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + const save = () => { + if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return + if (detail.active && !window.confirm( + `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return + return 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 => ({ @@ -416,22 +476,26 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
)} + {detail && docId && ( +
+ +
+ )} {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(' ') || '—'}
- - - -
@@ -449,7 +513,7 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
- 批量分配 {batchSel.size} 份文档
@@ -539,7 +603,10 @@ function ActionDetailTable({ actions, onOverride, canManage }: { ) } -export function RdmPermMatrixSection({ projectId }: { projectId: string }) { +export function RdmPermMatrixSection({ project }: { project: Project }) { + const projectId = project.id + const writeError = projectWorkError(project) + const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) const me = useStore(s => s.me) const [view, setView] = useState<'member' | 'matrix'>('member') const [member, setMember] = useState('') @@ -552,47 +619,49 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) { const [busy, setBusy] = useState(false) const loadMembers = () => - api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) + cmd<{ 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))) - useEffect(() => { loadMembers() }, [projectId, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps const query = async (m: string) => { if (!m) return setErr(''); setBusy(true); setMatrix(null) try { - setMatrix(await api.rdmCommand('rdm.perm.check', permCheckPayload(projectId, m))) + setMatrix(await cmd('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 })) + setBatch(await cmd('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') => { + if (writeError) { setErr(writeError); return } setErr(''); setNotice(''); setBusy(true) try { if (mode === 'default') { - await api.rdmCommand('rdm.perm.unset', { projectId, member: m, action }) + await cmd('rdm.perm.unset', { projectId, member: m, action }) setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`) } else { - await api.rdmCommand('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' }) + await cmd('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 + const canManage = !writeError && !!matrix && matrix.role !== undefined return (
+ {writeError &&

{writeError}

}