diff --git a/web/src/api/projectSettingsError.test.ts b/web/src/api/projectSettingsError.test.ts new file mode 100644 index 00000000..0a5351fe --- /dev/null +++ b/web/src/api/projectSettingsError.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ProjectSettingsApiError, + parseProjectSettingsErrorBody, + projectSettingsErrorFromResponse, + recoverFromProjectSettingsError, + retainDraftOnFrozenConflict, +} from './projectSettingsError'; + +describe('projectSettingsError', () => { + it('keeps status, code, message and issues on PLAN_FROZEN', () => { + const err = projectSettingsErrorFromResponse( + 409, + JSON.stringify({ error: 'PLAN_FROZEN', message: '工作流已激活,规划已冻结' }), + '保存工作流规划失败: 409', + ); + assert.equal(err instanceof ProjectSettingsApiError, true); + assert.equal(err.status, 409); + assert.equal(err.code, 'PLAN_FROZEN'); + assert.equal(err.message, '工作流已激活,规划已冻结'); + assert.deepEqual(err.issues, []); + assert.equal(recoverFromProjectSettingsError(err), 'refresh_and_freeze'); + }); + + it('maps revision and concurrent conflicts to distinct recovery', () => { + assert.equal( + recoverFromProjectSettingsError({ code: 'PLAN_REVISION_CONFLICT', status: 409 }), + 'reload_revision', + ); + assert.equal( + recoverFromProjectSettingsError({ code: 'ERR_CONCURRENT_SWITCH', status: 409 }), + 'retry_after_switch', + ); + }); + + it('joins validation issues but still keeps typed codes', () => { + const body = parseProjectSettingsErrorBody( + JSON.stringify({ + error: 'VALIDATION_FAILED', + message: '工作流配置校验未通过', + issues: [{ code: 'DUTY_PARTIAL_ASSIGNMENT', field: 'intake.duties', message: '不能只指派部分职责' }], + }), + ); + assert.equal(body.code, 'VALIDATION_FAILED'); + assert.equal(body.message, '不能只指派部分职责'); + assert.equal(body.issues[0]?.code, 'DUTY_PARTIAL_ASSIGNMENT'); + }); + + it('retains the local draft when a late tab hits PLAN_FROZEN', () => { + const draft = { workflow_definition_key: 'main', workflow_version: 1, stages: [] }; + assert.equal(retainDraftOnFrozenConflict(draft), draft); + }); +}); diff --git a/web/src/api/projectSettingsError.ts b/web/src/api/projectSettingsError.ts new file mode 100644 index 00000000..4a3c151c --- /dev/null +++ b/web/src/api/projectSettingsError.ts @@ -0,0 +1,83 @@ +export type ProjectSettingsIssue = { + code?: string; + field?: string; + message?: string; +}; + +export type ProjectSettingsErrorRecovery = + | 'refresh_and_freeze' + | 'reload_revision' + | 'retry_after_switch' + | 'show_message'; + +export class ProjectSettingsApiError extends Error { + readonly status: number; + readonly code: string; + readonly issues: ProjectSettingsIssue[]; + + constructor(input: { + status: number; + code: string; + message: string; + issues?: ProjectSettingsIssue[]; + }) { + super(input.message); + this.name = 'ProjectSettingsApiError'; + this.status = input.status; + this.code = input.code; + this.issues = input.issues ?? []; + } +} + +export function parseProjectSettingsErrorBody(text: string): { + message: string; + code: string; + issues: ProjectSettingsIssue[]; +} { + try { + const parsed = JSON.parse(text) as { + error?: string; + message?: string; + issues?: ProjectSettingsIssue[]; + }; + const issues = Array.isArray(parsed.issues) ? parsed.issues : []; + const issueText = issues + .map((issue) => issue.message) + .filter((msg): msg is string => Boolean(msg)) + .join(';'); + return { + code: String(parsed.error || ''), + message: issueText || parsed.message || parsed.error || text || '请求失败', + issues, + }; + } catch { + return { code: '', message: text || '请求失败', issues: [] }; + } +} + +export function projectSettingsErrorFromResponse( + status: number, + text: string, + fallback: string, +): ProjectSettingsApiError { + const parsed = parseProjectSettingsErrorBody(text); + return new ProjectSettingsApiError({ + status, + code: parsed.code, + message: parsed.message || fallback, + issues: parsed.issues, + }); +} + +export function recoverFromProjectSettingsError( + err: Pick, +): ProjectSettingsErrorRecovery { + if (err.code === 'PLAN_FROZEN') return 'refresh_and_freeze'; + if (err.code === 'PLAN_REVISION_CONFLICT') return 'reload_revision'; + if (err.code === 'ERR_CONCURRENT_SWITCH') return 'retry_after_switch'; + return 'show_message'; +} + +export function retainDraftOnFrozenConflict(draft: T): T { + return draft; +} diff --git a/web/src/lib/projectSettingsMode.test.ts b/web/src/lib/projectSettingsMode.test.ts new file mode 100644 index 00000000..64a551ad --- /dev/null +++ b/web/src/lib/projectSettingsMode.test.ts @@ -0,0 +1,106 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + afterFrozenConflictReload, + frozenDutySnapshot, + projectSettingsChrome, + resolveProjectSettingsMode, + shouldShowPlanEditor, + shouldShowSavePreview, +} from './projectSettingsMode'; +import type { WorkflowPlan } from '../types/projectSettings'; + +describe('projectSettingsMode', () => { + it('enters frozen when can_edit_plan is false or the project is active', () => { + assert.equal( + resolveProjectSettingsMode({ + settings: { can_edit_plan: false, lifecycle_status: 'planning' }, + }), + 'frozen', + ); + assert.equal( + resolveProjectSettingsMode({ + settings: { can_edit_plan: true, lifecycle_status: 'active' }, + }), + 'frozen', + ); + assert.equal( + resolveProjectSettingsMode({ + settings: { + can_edit_plan: true, + lifecycle_status: 'planning', + live_instance: { id: 89, status: 'active', config_revision: 1 }, + }, + }), + 'frozen', + ); + assert.equal(shouldShowPlanEditor('frozen'), false); + assert.equal(shouldShowSavePreview('frozen'), false); + const chrome = projectSettingsChrome('frozen'); + assert.equal(chrome.showWorkflowSelector, false); + assert.equal(chrome.showDutySelects, false); + assert.equal(chrome.showSavePreview, false); + assert.equal(chrome.showFrozenSnapshot, true); + }); + + it('stays planning only for an editable draft', () => { + assert.equal( + resolveProjectSettingsMode({ + settings: { can_edit_plan: true, lifecycle_status: 'planning' }, + }), + 'planning', + ); + assert.equal(shouldShowPlanEditor('planning'), true); + assert.equal(shouldShowSavePreview('planning'), true); + }); + + it('lists enabled duties for the frozen snapshot and skips disabled tasks', () => { + const plan: WorkflowPlan = { + workflow_definition_key: 'main', + workflow_version: 2, + stages: [ + { + stage_key: 'V0.1_triage', + enabled: true, + doc_tasks: [ + { + task_key: 'intake', + task_title: '用户初始需求收集表', + enabled: true, + duties: { author_user_id: 9, reviewer_user_id: 2, approver_user_id: 3 }, + }, + { + task_key: 'research', + enabled: false, + duties: { author_user_id: 9 }, + }, + ], + }, + ], + }; + assert.deepEqual(frozenDutySnapshot(plan), [ + { + taskKey: 'intake', + title: '用户初始需求收集表', + author: 9, + reviewer: 2, + approver: 3, + }, + ]); + }); + + it('keeps the local draft after PLAN_FROZEN refresh and switches to frozen', () => { + const draft: WorkflowPlan = { + workflow_definition_key: 'main', + workflow_version: 1, + stages: [], + }; + const recovered = afterFrozenConflictReload( + { can_edit_plan: false, lifecycle_status: 'active' }, + draft, + ); + assert.equal(recovered.plan, draft); + assert.equal(recovered.mode, 'frozen'); + assert.equal(projectSettingsChrome(recovered.mode).showSavePreview, false); + }); +}); diff --git a/web/src/lib/projectSettingsMode.ts b/web/src/lib/projectSettingsMode.ts new file mode 100644 index 00000000..924a866d --- /dev/null +++ b/web/src/lib/projectSettingsMode.ts @@ -0,0 +1,75 @@ +import type { ProjectSettings, WorkflowPlan } from '../types/projectSettings'; + +export type ProjectSettingsMode = 'loading' | 'planning' | 'frozen' | 'error'; + +export function resolveProjectSettingsMode(input: { + loading?: boolean; + settings?: Pick | null; + loadError?: boolean; +}): ProjectSettingsMode { + if (input.loadError && !input.settings) return 'error'; + if (input.loading && !input.settings) return 'loading'; + const settings = input.settings; + if (!settings) return 'loading'; + if (!settings.can_edit_plan) return 'frozen'; + if (settings.lifecycle_status === 'active') return 'frozen'; + const live = settings.live_instance; + if (live && live.status !== 'completed' && live.status !== 'terminated') return 'frozen'; + return 'planning'; +} + +export function shouldShowPlanEditor(mode: ProjectSettingsMode): boolean { + return mode === 'planning'; +} + +export function shouldShowSavePreview(mode: ProjectSettingsMode): boolean { + return mode === 'planning'; +} + +export function projectSettingsChrome(mode: ProjectSettingsMode): { + showWorkflowSelector: boolean; + showDutySelects: boolean; + showSavePreview: boolean; + showStepPills: boolean; + showFrozenSnapshot: boolean; +} { + const planning = mode === 'planning'; + return { + showWorkflowSelector: planning, + showDutySelects: planning, + showSavePreview: planning, + showStepPills: planning, + showFrozenSnapshot: mode === 'frozen', + }; +} + +export function afterFrozenConflictReload( + settings: Pick, + draftPlan: WorkflowPlan, +): { plan: WorkflowPlan; mode: ProjectSettingsMode } { + return { + plan: draftPlan, + mode: resolveProjectSettingsMode({ settings }), + }; +} + +export function frozenDutySnapshot(plan?: WorkflowPlan | null): Array<{ + taskKey: string; + title: string; + author?: number; + reviewer?: number; + approver?: number; +}> { + if (!plan) return []; + return plan.stages.flatMap((stage) => + stage.doc_tasks + .filter((task) => task.enabled) + .map((task) => ({ + taskKey: task.task_key, + title: task.task_title || task.task_key, + author: task.duties.author_user_id, + reviewer: task.duties.reviewer_user_id, + approver: task.duties.approver_user_id, + })), + ); +}