From 8e4beff5f89677c3d9901bf438c624670c54eea0 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:48:27 +0000 Subject: [PATCH] feat(web): allow duty reassignment in frozen project settings Show editable duty selects after activation when the user has project:assign_lead, calling the new PATCH API instead of workflow-plan PUT. Co-authored-by: Cursor --- .../workflow-plan-409-frozen-duties.spec.ts | 8 +- web/src/api/projectSettings.ts | 32 +++++ web/src/components/layout/WorkbenchView.tsx | 1 + .../project/ProjectSettingsModal.tsx | 132 ++++++++++++++++-- web/src/lib/projectSettingsMode.test.ts | 1 + web/src/lib/projectSettingsMode.ts | 6 +- 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/web/e2e/workflow-plan-409-frozen-duties.spec.ts b/web/e2e/workflow-plan-409-frozen-duties.spec.ts index 7942a7dd..86743826 100644 --- a/web/e2e/workflow-plan-409-frozen-duties.spec.ts +++ b/web/e2e/workflow-plan-409-frozen-duties.spec.ts @@ -241,7 +241,8 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => { await page.getByRole('button', { name: /校验并预览/ }).click(); await expect(page.getByRole('alert')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('当前工作流已激活')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/规划(工作流选择与整表配置)已冻结/)).toBeVisible(); await shot(page, '06-dual-tab-frozen.png'); await page2.close(); @@ -267,7 +268,10 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => { await page.reload(); await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click(); await page.getByRole('button', { name: '查看工作流设置' }).click(); - await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('当前工作流已激活')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('duty-reassign-intake').or(page.getByText('文档职责(可改派)'))).toBeVisible({ + timeout: 10_000, + }); await shot(page, '08-reload-frozen.png'); await page.close(); }); diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index 03f978ed..75c598d1 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -65,6 +65,38 @@ export async function saveWorkflowPlan( return res.json(); } +export async function reassignDocTaskDuties( + projectId: number, + taskKey: string, + input: { + author_user_id?: number; + reviewer_user_id?: number; + approver_user_id?: number; + duty_policy?: string; + } +): Promise { + const res = await fetch( + `${API_BASE}/api/v1/projects/${projectId}/doc-tasks/${encodeURIComponent(taskKey)}/duties`, + { + method: 'PATCH', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ + duties: { + author_user_id: input.author_user_id || 0, + reviewer_user_id: input.reviewer_user_id || 0, + approver_user_id: input.approver_user_id || 0, + }, + duty_policy: input.duty_policy || undefined, + }), + } + ); + if (!res.ok) { + const text = await res.text(); + throw projectSettingsErrorFromResponse(res.status, text, `改派文档职责失败: ${res.status}`); + } + return res.json(); +} + export async function fetchActivationPreview(projectId: number): Promise { const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, { method: 'POST', diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index 3c67f185..b8d5c9b2 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -275,6 +275,7 @@ export function WorkbenchView({ open={projectSettingsOpen} projectId={currentProjectId} projectName={currentActiveSession?.title || '当前项目'} + canAssignLead={canAssignLead} members={(currentProject?.members || []) .filter((m) => m.status === 'active') .map((m) => ({ diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index bdcdc98e..6cd29c77 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -8,6 +8,7 @@ import { activateWorkflowInstance, fetchTerminationPreview, terminateWorkflowInstance, + reassignDocTaskDuties, } from '../../api/projectSettings'; import { ProjectSettingsApiError, @@ -54,6 +55,7 @@ interface ProjectSettingsModalProps { projectId: number; projectName: string; members: ProjectMemberOption[]; + canAssignLead?: boolean; onClose: () => void; onActivated?: (activeWorkflowKey: string) => void; } @@ -65,6 +67,7 @@ export const ProjectSettingsModal: React.FC = ({ projectId, projectName, members, + canAssignLead = false, onClose, onActivated, }) => { @@ -171,6 +174,44 @@ export const ProjectSettingsModal: React.FC = ({ }); }; + const handleReassignDuty = async (taskKey: string) => { + if (!plan || !canAssignLead) return; + let duties: DocTaskPlan['duties'] | null = null; + let dutyPolicy: string | undefined; + for (const st of plan.stages) { + for (const dt of st.doc_tasks) { + if (dt.task_key === taskKey) { + duties = dt.duties; + dutyPolicy = dt.duty_policy; + break; + } + } + if (duties) break; + } + if (!duties) return; + setLoading(true); + setError(null); + try { + const updated = await reassignDocTaskDuties(projectId, taskKey, { + author_user_id: duties.author_user_id, + reviewer_user_id: duties.reviewer_user_id, + approver_user_id: duties.approver_user_id, + duty_policy: dutyPolicy, + }); + setSettings(updated); + if (updated.plan) setPlan(updated.plan); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '改派文档职责失败'; + if (err instanceof ProjectSettingsApiError && err.code === 'DUTY_REASSIGN_BUSY') { + setError(msg || '该文档小队任务进行中,完成后可更换负责人'); + } else { + setError(msg); + } + } finally { + setLoading(false); + } + }; + const handleSavePlan = async () => { if (!plan || !settings || !settings.can_edit_plan) return; @@ -379,25 +420,98 @@ export const ProjectSettingsModal: React.FC = ({ fontSize: '13px', }} > -
当前工作流已冻结,只读查看
+
当前工作流已激活
工作流:{settings.active_workflow_key || plan?.workflow_definition_key || '—'}
版本:v{plan?.workflow_version ?? settings.workflow_selection_version}
当前阶段:{settings.active_stage_key || '—'}
- 已激活项目不能改规划或职责。如需换工作流,请走下方受控终止,而不是在本页直接编辑。 + 规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
-
冻结职责快照
+
+ {chrome.showDutyReassign && canAssignLead ? '文档职责(可改派)' : '冻结职责快照'} +
{snapshot.length === 0 && (
当前没有启用的文档任务。
)} - {snapshot.map((item) => ( -
- {item.title} -
编制 {memberName(item.author)} · 审核 {memberName(item.reviewer)} · 批准 {memberName(item.approver)}
-
- ))} + {snapshot.map((item) => { + const loc = plan + ? plan.stages + .map((st, si) => { + const ti = st.doc_tasks.findIndex((dt) => dt.task_key === item.taskKey); + return ti >= 0 ? { si, ti, dt: st.doc_tasks[ti] } : null; + }) + .find(Boolean) + : null; + if (chrome.showDutyReassign && canAssignLead && loc) { + const { si, ti, dt } = loc; + return ( +
+ {item.title} +
+ {(['author', 'reviewer', 'approver'] as const).map((role) => ( + + ))} +
+ +
+ ); + } + return ( +
+ {item.title} +
+ 编制 {memberName(item.author)} · 审核 {memberName(item.reviewer)} · 批准{' '} + {memberName(item.approver)} +
+
+ ); + })}
)} diff --git a/web/src/lib/projectSettingsMode.test.ts b/web/src/lib/projectSettingsMode.test.ts index 64a551ad..d88e27a5 100644 --- a/web/src/lib/projectSettingsMode.test.ts +++ b/web/src/lib/projectSettingsMode.test.ts @@ -41,6 +41,7 @@ describe('projectSettingsMode', () => { assert.equal(chrome.showDutySelects, false); assert.equal(chrome.showSavePreview, false); assert.equal(chrome.showFrozenSnapshot, true); + assert.equal(chrome.showDutyReassign, true); }); it('stays planning only for an editable draft', () => { diff --git a/web/src/lib/projectSettingsMode.ts b/web/src/lib/projectSettingsMode.ts index 924a866d..30f2d38c 100644 --- a/web/src/lib/projectSettingsMode.ts +++ b/web/src/lib/projectSettingsMode.ts @@ -32,14 +32,18 @@ export function projectSettingsChrome(mode: ProjectSettingsMode): { showSavePreview: boolean; showStepPills: boolean; showFrozenSnapshot: boolean; + showDutyReassign: boolean; } { const planning = mode === 'planning'; + const frozen = mode === 'frozen'; return { showWorkflowSelector: planning, showDutySelects: planning, showSavePreview: planning, showStepPills: planning, - showFrozenSnapshot: mode === 'frozen', + showFrozenSnapshot: frozen, + // 激活后可受控改派(需前端再与 canAssignLead 求交) + showDutyReassign: frozen, }; }