feat(web): resolve client duty_policy for author_only plans

Align TypeScript types and projectSettingsDuties with backend
ResolveDocTaskDuty so author_only no longer fails as partial.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 13:15:05 +00:00
co-authored by Cursor
parent 2c012ee59f
commit be128602e4
2 changed files with 122 additions and 14 deletions
+102 -13
View File
@@ -12,17 +12,62 @@ export function classifyClientDuty(duty?: DocDuty): ClientDutyMode {
return 'partial';
}
/** 对齐 Go ResolveDocTaskDuty:author_only 仅要求编制人,有效三职派生为 A/A/A。 */
export function resolveClientDocTaskDuty(task: DocTaskPlan): {
mode: ClientDutyMode;
effective: DocDuty;
isAuthorOnly: boolean;
} {
if (!task.enabled) {
return { mode: 'none', effective: {}, isAuthorOnly: false };
}
const policy = (task.duty_policy || '').trim();
if (policy === 'author_only') {
const author = task.duties?.author_user_id ?? 0;
if (author <= 0) {
return { mode: 'partial', effective: {}, isAuthorOnly: true };
}
return {
mode: 'full',
effective: {
author_user_id: author,
reviewer_user_id: author,
approver_user_id: author,
},
isAuthorOnly: true,
};
}
if (policy && policy !== 'separated') {
return { mode: 'partial', effective: {}, isAuthorOnly: false };
}
const mode = classifyClientDuty(task.duties);
return { mode, effective: { ...(task.duties || {}) }, isAuthorOnly: false };
}
export function canToggleHumanApproval(task: Pick<DocTaskPlan, 'approval_configurable'>, planning: boolean): boolean {
return planning && Boolean(task.approval_configurable);
}
export function toggleHumanApproval(task: DocTaskPlan, managed: boolean): DocTaskPlan {
if (!managed) {
return { ...task, duties: {} };
return { ...task, duty_policy: '', duties: {} };
}
return { ...task };
}
/** 切换兼任模式:开启时只保留编制人并清空审核/批准;关闭时清除 duty_policy。 */
export function toggleAuthorOnly(task: DocTaskPlan, enabled: boolean): DocTaskPlan {
if (!enabled) {
return { ...task, duty_policy: '' };
}
const author = task.duties?.author_user_id;
return {
...task,
duty_policy: 'author_only',
duties: author ? { author_user_id: author } : {},
};
}
export function clientDutyIssues(
plan: WorkflowPlan,
members: Array<{ userId: number }>,
@@ -35,9 +80,50 @@ export function clientDutyIssues(
.flatMap((stage) => stage.doc_tasks.filter((task) => task.enabled));
for (const task of enabled) {
const mode = classifyClientDuty(task.duties);
const field = `${task.task_key}.duties`;
const label = task.task_title || task.task_key;
const policy = (task.duty_policy || '').trim();
if (policy && policy !== 'separated' && policy !== 'author_only') {
issues.push({
code: 'UNKNOWN_DUTY_POLICY',
field: `${task.task_key}.duty_policy`,
message: `文档「${label}」的职责模式无效`,
});
continue;
}
if (policy === 'author_only') {
const author = task.duties?.author_user_id ?? 0;
if (author <= 0) {
issues.push({
code: 'AUTHOR_ONLY_AUTHOR_REQUIRED',
field,
message: `文档「${label}」兼任模式必须指定编制人`,
});
continue;
}
const reviewer = task.duties?.reviewer_user_id ?? 0;
const approver = task.duties?.approver_user_id ?? 0;
if ((reviewer > 0 && reviewer !== author) || (approver > 0 && approver !== author)) {
issues.push({
code: 'AUTHOR_ONLY_DUTY_CONFLICT',
field,
message: `文档「${label}」兼任模式不能指定与编制人不同的审核人或批准人`,
});
continue;
}
if (!memberIDs.has(author)) {
issues.push({
code: 'DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER',
field: `${task.task_key}.author`,
message: `文档「${label}」的负责人不是当前成员`,
});
}
continue;
}
const { mode, effective } = resolveClientDocTaskDuty(task);
if (mode === 'partial') {
issues.push({
code: 'DUTY_PARTIAL_ASSIGNMENT',
@@ -55,7 +141,7 @@ export function clientDutyIssues(
continue;
}
if (mode === 'full') {
const ids = [task.duties.author_user_id, task.duties.reviewer_user_id, task.duties.approver_user_id];
const ids = [effective.author_user_id, effective.reviewer_user_id, effective.approver_user_id];
if (new Set(ids).size !== ids.length) {
issues.push({
code: 'SEPARATION_OF_DUTY_VIOLATION',
@@ -64,9 +150,9 @@ export function clientDutyIssues(
});
}
for (const [role, id] of [
['author', task.duties.author_user_id],
['reviewer', task.duties.reviewer_user_id],
['approver', task.duties.approver_user_id],
['author', effective.author_user_id],
['reviewer', effective.reviewer_user_id],
['approver', effective.approver_user_id],
] as const) {
if (id && !memberIDs.has(id)) {
issues.push({
@@ -82,13 +168,16 @@ export function clientDutyIssues(
}
export function needsThreeMembersForDuties(plan: WorkflowPlan, requireApprovalKeys: string[] = []): boolean {
return plan.stages.some((stage) =>
stage.enabled &&
stage.doc_tasks.some((task) => {
if (!task.enabled) return false;
if (!task.approval_configurable || requireApprovalKeys.includes(task.task_key)) return true;
return classifyClientDuty(task.duties) === 'full';
}),
return plan.stages.some(
(stage) =>
stage.enabled &&
stage.doc_tasks.some((task) => {
if (!task.enabled) return false;
const { mode, isAuthorOnly } = resolveClientDocTaskDuty(task);
if (isAuthorOnly) return false;
if (!task.approval_configurable || requireApprovalKeys.includes(task.task_key)) return true;
return mode === 'full';
}),
);
}
+20 -1
View File
@@ -4,11 +4,15 @@ export interface DocDuty {
approver_user_id?: number;
}
export type DutyPolicy = 'separated' | 'author_only';
export interface DocTaskPlan {
task_key: string;
task_title?: string;
enabled: boolean;
approval_configurable?: boolean;
/** 逐文档职责模式;缺省/空值按 separated 解读。 */
duty_policy?: DutyPolicy | '';
duties: DocDuty;
}
@@ -48,6 +52,12 @@ export interface WorkflowEventSummary {
created_at: string;
}
export interface GovernancePolicyCapability {
allowed: boolean;
revision: number;
blocked_reason?: string;
}
export interface ProjectSettings {
project_id: number;
lifecycle_status: string;
@@ -62,6 +72,7 @@ export interface ProjectSettings {
live_instance?: { id: number; status: string; config_revision: number };
published_workflows?: PublishedOption[];
recent_workflow_events?: WorkflowEventSummary[];
author_only_policy?: GovernancePolicyCapability;
}
export interface TerminationPreview {
@@ -72,6 +83,12 @@ export interface TerminationPreview {
impact?: string;
}
export interface PreviewDocRef {
task_key: string;
title: string;
author_user_id?: number;
}
export interface ActivationPreview {
confirmation_token: string;
plan_hash: string;
@@ -85,7 +102,9 @@ export interface ActivationPreview {
governed_doc_tasks?: number;
unmanaged_doc_tasks?: number;
assigned_duties: number;
unmanaged_documents?: Array<{ task_key: string; title: string }>;
author_only_doc_tasks?: number;
unmanaged_documents?: PreviewDocRef[];
author_only_documents?: PreviewDocRef[];
first_stage_key?: string;
first_stage_name?: string;
};