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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 09:48:27 +00:00
co-authored by Cursor
parent 1497473186
commit 8e4beff5f8
6 changed files with 168 additions and 12 deletions
@@ -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();
});
+32
View File
@@ -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<ProjectSettings> {
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<ActivationPreview> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, {
method: 'POST',
@@ -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) => ({
@@ -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<ProjectSettingsModalProps> = ({
projectId,
projectName,
members,
canAssignLead = false,
onClose,
onActivated,
}) => {
@@ -171,6 +174,44 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
});
};
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<ProjectSettingsModalProps> = ({
fontSize: '13px',
}}
>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>当前工作流已冻结,只读查看</div>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>当前工作流已激活</div>
<div>工作流:{settings.active_workflow_key || plan?.workflow_definition_key || '—'}</div>
<div>版本:v{plan?.workflow_version ?? settings.workflow_selection_version}</div>
<div>当前阶段:{settings.active_stage_key || '—'}</div>
<div style={{ marginTop: '8px', color: 'var(--text-muted)', fontSize: '12px' }}>
已激活项目不能改规划或职责。如需换工作流,请走下方受控终止,而不是在本页直接编辑。
规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
</div>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '8px' }}>冻结职责快照</div>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '8px' }}>
{chrome.showDutyReassign && canAssignLead ? '文档职责(可改派)' : '冻结职责快照'}
</div>
{snapshot.length === 0 && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>当前没有启用的文档任务。</div>
)}
{snapshot.map((item) => (
<div key={item.taskKey} style={{ fontSize: '12px', marginBottom: '6px' }}>
<strong>{item.title}</strong>
<div>编制 {memberName(item.author)} · 审核 {memberName(item.reviewer)} · 批准 {memberName(item.approver)}</div>
</div>
))}
{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 (
<div
key={item.taskKey}
data-testid={`duty-reassign-${item.taskKey}`}
style={{
fontSize: '12px',
marginBottom: '10px',
padding: '8px',
border: '1px solid var(--border)',
borderRadius: '6px',
}}
>
<strong>{item.title}</strong>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px', marginTop: '6px' }}>
{(['author', 'reviewer', 'approver'] as const).map((role) => (
<select
key={role}
value={dt.duties[`${role}_user_id` as keyof typeof dt.duties] || ''}
onChange={(e) =>
updateDocTask(si, ti, {
duties: {
...dt.duties,
[`${role}_user_id`]: Number(e.target.value) || undefined,
},
})
}
style={{ fontSize: '11px', padding: '4px' }}
>
<option value="">{role === 'author' ? '作者' : role === 'reviewer' ? '审核' : '批准'}</option>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{m.name}
</option>
))}
</select>
))}
</div>
<button
type="button"
onClick={() => handleReassignDuty(item.taskKey)}
disabled={loading}
style={{
marginTop: '8px',
padding: '4px 10px',
fontSize: '11px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--brand-50, #eff6ff)',
cursor: 'pointer',
}}
>
保存改派
</button>
</div>
);
}
return (
<div key={item.taskKey} style={{ fontSize: '12px', marginBottom: '6px' }}>
<strong>{item.title}</strong>
<div>
编制 {memberName(item.author)} · 审核 {memberName(item.reviewer)} · 批准{' '}
{memberName(item.approver)}
</div>
</div>
);
})}
</div>
</div>
)}
+1
View File
@@ -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', () => {
+5 -1
View File
@@ -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,
};
}