Files
Agent-System/web/src/components/project/ProjectSettingsModal.tsx
T
weichen 7f9d01a3b6 fix(web): 激活前预检人工审批并消除密钥框密码警告
未纳入人工审批的计划会被服务端拒绝为 400。配置页在请求前拦截并提示;管理台密钥输入放入表单,避免浏览器把密码框当成未托管字段。
2026-09-28 01:35:46 +00:00

1016 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useState } from 'react';
import { X, ChevronRight, ChevronDown, ChevronLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import {
fetchProjectSettings,
fetchDefaultWorkflowPlan,
saveWorkflowPlan,
fetchActivationPreview,
activateWorkflowInstance,
fetchTerminationPreview,
terminateWorkflowInstance,
reassignDocTaskDuties,
} from '../../api/projectSettings';
import {
ProjectSettingsApiError,
recoverFromProjectSettingsError,
retainDraftOnFrozenConflict,
} from '../../api/projectSettingsError';
import {
ProjectSettings,
WorkflowPlan,
ActivationPreview,
DocTaskPlan,
} from '../../types/projectSettings';
import {
frozenDutySnapshot,
projectSettingsChrome,
resolveProjectSettingsMode,
shouldShowPlanEditor,
shouldShowSavePreview,
} from '../../lib/projectSettingsMode';
import {
canToggleHumanApproval,
clientDutyIssues,
formatDutyIssues,
locateDutyIssue,
needsGovernedDocument,
needsThreeMembersForDuties,
NO_GOVERNED_DOCUMENT_MESSAGE,
resolveClientDocTaskDuty,
toggleAuthorOnly,
toggleHumanApproval,
stageStartsCollapsed,
applyStageHumanApproval,
stageApprovalKeys,
} from '../../lib/projectSettingsDuties';
import { ACTIVATION_UNMANAGED_FREEZE_HINT } from '../../lib/blockedGovernanceCopy';
import { WorkflowPrerequisiteBanner } from '../workflow/WorkflowPrerequisiteBanner';
import type { ProjectSettingsIssue } from '../../api/projectSettingsError';
interface ProjectMemberOption {
userId: number;
name: string;
}
interface ProjectSettingsModalProps {
open: boolean;
projectId: number;
projectName: string;
members: ProjectMemberOption[];
canAssignLead?: boolean;
onClose: () => void;
onActivated?: (activeWorkflowKey: string) => void;
}
type Step = 'select' | 'configure' | 'confirm';
export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
open,
projectId,
projectName,
members,
canAssignLead = false,
onClose,
onActivated,
}) => {
const [step, setStep] = useState<Step>('select');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settings, setSettings] = useState<ProjectSettings | null>(null);
const [plan, setPlan] = useState<WorkflowPlan | null>(null);
const [openStages, setOpenStages] = useState<Record<string, boolean>>({});
const [preview, setPreview] = useState<ActivationPreview | null>(null);
const [selectedKey, setSelectedKey] = useState('');
const [terminateReason, setTerminateReason] = useState('');
const [terminatePreview, setTerminatePreview] = useState<{ impact?: string } | null>(null);
const [dutyIssues, setDutyIssues] = useState<ProjectSettingsIssue[]>([]);
const [requireApprovalKeys, setRequireApprovalKeys] = useState<string[]>([]);
const loadSettings = useCallback(async () => {
setLoading(true);
setError(null);
setDutyIssues([]);
setRequireApprovalKeys([]);
try {
const s = await fetchProjectSettings(projectId);
setSettings(s);
if (s.live_instance && s.live_instance.status !== 'completed' && s.live_instance.status !== 'terminated') {
try {
const preview = await fetchTerminationPreview(s.live_instance.id);
setTerminatePreview(preview);
} catch {
setTerminatePreview(null);
}
} else {
setTerminatePreview(null);
}
if (s.plan) {
setPlan(s.plan);
setSelectedKey(s.plan.workflow_definition_key);
const nextMode = resolveProjectSettingsMode({ settings: s });
if (nextMode !== 'frozen') {
setStep('configure');
}
} else if (s.published_workflows?.length) {
setSelectedKey(s.published_workflows[0].definition_key);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载项目设置失败');
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (open) {
setStep('select');
setPreview(null);
loadSettings();
}
}, [open, loadSettings]);
const handleSelectWorkflow = async () => {
if (!settings || !selectedKey || !settings.can_edit_plan) return;
const wf = settings.published_workflows?.find((w) => w.definition_key === selectedKey);
if (!wf) return;
setLoading(true);
setError(null);
try {
const defaultPlan = await fetchDefaultWorkflowPlan(projectId, wf.definition_key, wf.latest_version);
setPlan(defaultPlan);
setStep('configure');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载默认规划失败');
} finally {
setLoading(false);
}
};
const updateDocTask = (stageIdx: number, taskIdx: number, patch: Partial<DocTaskPlan>) => {
if (!plan) return;
const stages = plan.stages.map((st, si) => {
if (si !== stageIdx) return st;
return {
...st,
doc_tasks: st.doc_tasks.map((dt, ti) => (ti === taskIdx ? { ...dt, ...patch } : dt)),
};
});
setPlan({ ...plan, stages });
};
const applyStageApproval = (stageIdx: number, managed: boolean) => {
if (!plan) return;
const stage = plan.stages[stageIdx];
if (!stage) return;
const keys = stageApprovalKeys(stage.doc_tasks, showEditor);
const stages = plan.stages.map((item, index) =>
index === stageIdx
? { ...item, doc_tasks: applyStageHumanApproval(item.doc_tasks, managed, showEditor) }
: item,
);
setPlan({ ...plan, stages });
setRequireApprovalKeys((current) => {
if (managed) return Array.from(new Set([...current, ...keys]));
const drop = new Set(keys);
return current.filter((key) => !drop.has(key));
});
};
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 () => {
const liveEdit = resolveProjectSettingsMode({ settings }) === 'live_edit';
if (!plan || !settings || (!settings.can_edit_plan && !liveEdit)) return;
const issues = clientDutyIssues(plan, members, requireApprovalKeys);
if (issues.length > 0) {
setDutyIssues(issues);
setError(formatDutyIssues(issues));
return;
}
if (needsThreeMembersForDuties(plan, requireApprovalKeys) && members.length < 3) {
setError(`当前项目只有 ${members.length} 名有效成员。纳入人工审批的文档需要分别指定编制、审核、批准负责人。`);
return;
}
if (!liveEdit && !needsGovernedDocument(plan)) {
setError(NO_GOVERNED_DOCUMENT_MESSAGE);
return;
}
setLoading(true);
setError(null);
setDutyIssues([]);
try {
const updated = await saveWorkflowPlan(
projectId,
plan,
settings.workflow_plan_revision,
settings.workflow_selection_version,
liveEdit
);
setSettings(updated);
if (updated.plan) setPlan(updated.plan);
if (liveEdit) return;
const pv = await fetchActivationPreview(projectId);
setPreview(pv);
setStep('confirm');
} catch (err: unknown) {
if (err instanceof ProjectSettingsApiError) {
const recovery = recoverFromProjectSettingsError(err);
if (recovery === 'refresh_and_freeze') {
setPlan(retainDraftOnFrozenConflict(plan));
try {
const latest = await fetchProjectSettings(projectId);
setSettings(latest);
} catch {
/* keep draft; still surface the frozen conflict */
}
setError(err.message);
return;
}
if (recovery === 'retry_after_switch' || recovery === 'reload_revision') {
try {
const latest = await fetchProjectSettings(projectId);
setSettings(latest);
if (latest.plan) setPlan(latest.plan);
} catch {
/* still surface conflict; user can reopen settings */
}
setError(err.message);
return;
}
if (err.issues.length > 0) {
setDutyIssues(err.issues);
setError(err.message);
return;
}
}
setError(err instanceof Error ? err.message : '保存规划失败');
} finally {
setLoading(false);
}
};
const handleTerminate = async () => {
if (!settings?.live_instance || !terminateReason.trim()) return;
setLoading(true);
setError(null);
try {
await terminateWorkflowInstance(settings.live_instance.id, {
reason: terminateReason.trim(),
confirm: true,
expected_config_revision: settings.live_instance.config_revision,
});
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '终止工作流失败');
} finally {
setLoading(false);
}
};
const handleActivate = async () => {
if (!preview || !settings || !settings.can_edit_plan) return;
setLoading(true);
setError(null);
try {
const result = await activateWorkflowInstance(projectId, {
confirmation_token: preview.confirmation_token,
expected_project_version: preview.expected_project_version,
expected_plan_revision: preview.expected_plan_revision,
});
onActivated?.(result.active_workflow_key);
onClose();
} catch (err: unknown) {
if (err instanceof ProjectSettingsApiError && recoverFromProjectSettingsError(err) === 'refresh_and_freeze') {
try {
const latest = await fetchProjectSettings(projectId);
setSettings(latest);
} catch {
/* still surface the frozen conflict */
}
setError(err.message);
return;
}
setError(err instanceof Error ? err.message : '激活失败');
} finally {
setLoading(false);
}
};
if (!open) return null;
const mode = resolveProjectSettingsMode({
loading: loading && !settings,
settings,
loadError: Boolean(error && !settings),
});
const liveEdit = mode === 'live_edit';
const showEditor = shouldShowPlanEditor(mode);
const showSave = shouldShowSavePreview(mode);
const chrome = projectSettingsChrome(mode);
const memberName = (id?: number) => members.find((m) => m.userId === id)?.name || (id ? `#${id}` : '未指派');
const snapshot = frozenDutySnapshot(plan);
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(15, 23, 42, 0.45)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
width: 'min(720px, 94vw)',
maxHeight: '88vh',
background: 'var(--bg-card, #fff)',
borderRadius: '12px',
border: '1px solid var(--border, #e2e8f0)',
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<div style={{ fontSize: '16px', fontWeight: 700 }}>项目工作流配置</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{projectName}</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer' }}>
<X size={18} />
</button>
</div>
{chrome.showStepPills && (
<div style={{ padding: '12px 20px', display: 'flex', gap: '8px', fontSize: '12px' }}>
{(['select', 'configure', 'confirm'] as Step[]).map((s, i) => (
<span
key={s}
style={{
padding: '4px 10px',
borderRadius: '999px',
background: step === s ? 'var(--brand-50)' : 'var(--bg-subtle)',
color: step === s ? 'var(--brand-600)' : 'var(--text-muted)',
fontWeight: step === s ? 600 : 400,
}}
>
{i + 1}. {s === 'select' ? '选择工作流' : s === 'configure' ? '配置职责' : '确认激活'}
</span>
))}
</div>
)}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{loading && !settings && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-muted)' }}>
<Loader2 size={16} className="spin" /> 加载中...
</div>
)}
{chrome.showFrozenSnapshot && settings && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', marginBottom: '16px' }}>
<div
style={{
padding: '12px',
border: '1px solid var(--border)',
borderRadius: '8px',
background: 'var(--bg-subtle)',
fontSize: '13px',
}}
>
<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' }}>
{chrome.showDutyReassign && canAssignLead ? '文档职责(可改派)' : '冻结职责快照'}
</div>
{snapshot.length === 0 && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>当前没有启用的文档任务。</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>
)}
{settings?.live_instance && settings.live_instance.status !== 'completed' && settings.live_instance.status !== 'terminated' && (
<div
style={{
marginBottom: '16px',
padding: '12px',
border: '1px solid var(--danger-200, #fecaca)',
borderRadius: '8px',
background: 'var(--danger-50, #fef2f2)',
}}
>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '8px' }}>受控终止(高风险,与规划编辑无关)</div>
{terminatePreview?.impact ? (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '8px' }}>{terminatePreview.impact}</div>
) : (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '8px' }}>
终止会取消运行中任务并关闭当前实例,之后才能另配工作流。本操作不会保存上方规划草稿。
</div>
)}
<textarea
value={terminateReason}
onChange={(e) => setTerminateReason(e.target.value)}
placeholder="请填写终止原因(必填)"
rows={2}
style={{ width: '100%', fontSize: '12px', padding: '8px', borderRadius: '6px', border: '1px solid var(--border)' }}
/>
<button
onClick={handleTerminate}
disabled={loading || !terminateReason.trim()}
style={{
marginTop: '8px',
padding: '6px 12px',
borderRadius: '6px',
border: 'none',
background: 'var(--danger-600, #dc2626)',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
}}
>
确认终止当前实例
</button>
</div>
)}
{settings?.recent_workflow_events && settings.recent_workflow_events.length > 0 && (
<div style={{ marginBottom: '16px', fontSize: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '6px' }}>最近工作流事件</div>
<ul style={{ margin: 0, paddingLeft: '18px', color: 'var(--text-muted)' }}>
{settings.recent_workflow_events.slice(0, 5).map((ev) => (
<li key={ev.id}>{ev.title || ev.type}</li>
))}
</ul>
</div>
)}
{liveEdit && settings && (
<div
style={{
padding: '12px',
border: '1px solid var(--border)',
borderRadius: '8px',
background: 'var(--bg-subtle)',
fontSize: '13px',
marginBottom: '16px',
}}
>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>当前工作流已激活</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
运行中仍可调整职责、审批与文档启用,但不能切换工作流。
</div>
</div>
)}
{showEditor && chrome.showWorkflowSelector && step === 'select' && settings && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
选择要激活的已发布工作流版本。激活后不可切换工作流;职责、审批与文档启用在运行中仍可调整。
</p>
{(settings.published_workflows || []).map((wf) => (
<label
key={wf.definition_key}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '12px',
border: `1px solid ${selectedKey === wf.definition_key ? 'var(--brand-400)' : 'var(--border)'}`,
borderRadius: '8px',
cursor: 'pointer',
background: selectedKey === wf.definition_key ? 'var(--brand-50)' : 'transparent',
}}
>
<input
type="radio"
checked={selectedKey === wf.definition_key}
onChange={() => setSelectedKey(wf.definition_key)}
/>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{wf.definition_name}</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
{wf.definition_key} · v{wf.latest_version}
{wf.product_domain ? ` · ${wf.product_domain}` : ''}
</div>
</div>
</label>
))}
{(!settings.published_workflows || settings.published_workflows.length === 0) && (
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
暂无已发布工作流,请先在系统设置中发布工作流定义。
</div>
)}
<WorkflowPrerequisiteBanner workflowKey={selectedKey} />
</div>
)}
{showEditor && chrome.showDutySelects && step === 'configure' && plan && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<WorkflowPrerequisiteBanner workflowKey={plan.workflow_definition_key} />
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
允许裁剪的文档可选择是否纳入人工审批。关闭后仍会生成并进入版本账本,只是不走三权。
</p>
{!liveEdit && !needsGovernedDocument(plan) && (
<div
style={{
padding: '10px 12px',
background: 'var(--warning-50, #fffbeb)',
border: '1px solid var(--warning-200, #fde68a)',
borderRadius: '8px',
color: 'var(--warning-800, #92400e)',
fontSize: '12px',
}}
>
{NO_GOVERNED_DOCUMENT_MESSAGE}。请勾选「纳入人工审批」并指定负责人;成员不足三人时可用「由编制人兼任审核、批准」。
</div>
)}
{needsThreeMembersForDuties(plan, requireApprovalKeys) && members.length < 3 && (
<div
style={{
padding: '10px 12px',
background: 'var(--warning-50, #fffbeb)',
border: '1px solid var(--warning-200, #fde68a)',
borderRadius: '8px',
color: 'var(--warning-800, #92400e)',
fontSize: '12px',
}}
>
当前只有 {members.length} 名有效项目成员,纳入人工审批的文档无法满足三权分离。
</div>
)}
{plan.stages.length === 0 && (
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
请先保存一次规划以从工作流定义加载阶段结构,或返回上一步重新选择。
</div>
)}
{plan.stages.map((st, si) => {
const expanded = openStages[st.stage_key] ?? !stageStartsCollapsed(st.doc_tasks.length, si);
return (
<div key={st.stage_key} style={{ border: '1px solid var(--border)', borderRadius: '8px', padding: '12px' }}>
<button
type="button"
onClick={() => setOpenStages((prev) => ({ ...prev, [st.stage_key]: !expanded }))}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
fontWeight: 600,
marginBottom: expanded ? '8px' : 0,
textAlign: 'left',
}}
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{st.stage_name || st.stage_key}
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>· {st.doc_tasks.length} 项</span>
</button>
{expanded && showEditor && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px' }}>
<button type="button" onClick={() => applyStageApproval(si, true)} style={{ fontSize: '11px', cursor: 'pointer' }}>
本阶段全部纳入人工审批
</button>
<button type="button" onClick={() => applyStageApproval(si, false)} style={{ fontSize: '11px', cursor: 'pointer' }}>
本阶段全部取消人工审批
</button>
</div>
)}
{expanded && st.doc_tasks.map((dt, ti) => {
const resolved = resolveClientDocTaskDuty(dt);
const managed =
resolved.mode !== 'none' ||
dt.duty_policy === 'author_only' ||
requireApprovalKeys.includes(dt.task_key);
const authorOnly = dt.duty_policy === 'author_only';
const authorOnlyAllowed = Boolean(settings?.author_only_policy?.allowed);
const inline = locateDutyIssue(dutyIssues, dt.task_key);
return (
<div key={dt.task_key} style={{ marginBottom: '10px', fontSize: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
<input
type="checkbox"
checked={dt.enabled}
onChange={(e) => updateDocTask(si, ti, { enabled: e.target.checked })}
/>
{dt.task_title || dt.task_key}
</label>
{dt.enabled && canToggleHumanApproval(dt, showEditor) && (
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
<input
type="checkbox"
checked={managed}
onChange={(e) => {
const next = e.target.checked;
setRequireApprovalKeys((keys) =>
next ? Array.from(new Set([...keys, dt.task_key])) : keys.filter((key) => key !== dt.task_key),
);
updateDocTask(si, ti, toggleHumanApproval(dt, next));
}}
/>
纳入人工审批
</label>
)}
{dt.enabled && managed && (
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
marginBottom: '6px',
opacity: authorOnlyAllowed || authorOnly ? 1 : 0.7,
}}
title={
authorOnlyAllowed
? undefined
: settings?.author_only_policy?.blocked_reason ||
'系统管理员尚未开启「允许编制人兼任审核、批准」'
}
>
<input
type="checkbox"
checked={authorOnly}
disabled={!authorOnlyAllowed && !authorOnly}
onChange={(e) => updateDocTask(si, ti, toggleAuthorOnly(dt, e.target.checked))}
/>
由编制人兼任审核、批准
{!authorOnlyAllowed && (
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>
(总开关未开启)
</span>
)}
</label>
)}
{dt.enabled && (managed || !dt.approval_configurable) && (
authorOnly ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '6px' }}>
<select
value={dt.duties.author_user_id || ''}
onChange={(e) =>
updateDocTask(si, ti, {
duties: { author_user_id: Number(e.target.value) || undefined },
duty_policy: 'author_only',
})
}
style={{ fontSize: '11px', padding: '4px' }}
>
<option value="">编制人(兼任审核与批准)</option>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{m.name}
</option>
))}
</select>
{dt.duties.author_user_id ? (
<div style={{ color: 'var(--text-muted)', fontSize: '11px' }}>
审核、批准:同编制人(自动承担)
</div>
) : null}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '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>
)
)}
{inline?.message && (
<div style={{ color: 'var(--danger-700)', marginTop: '4px' }}>{inline.message}</div>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
{showEditor && step === 'confirm' && preview && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div
style={{
padding: '14px',
background: 'var(--bg-subtle)',
borderRadius: '8px',
fontSize: '13px',
}}
>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>
<CheckCircle2 size={14} style={{ display: 'inline', marginRight: '6px' }} />
激活预览
</div>
<div>工作流:{preview.summary.workflow_name} v{preview.summary.workflow_version}</div>
<div>启用阶段:{preview.summary.enabled_stages} · 文档任务:{preview.summary.enabled_doc_tasks}</div>
<div>
纳入人工审批:{preview.summary.governed_doc_tasks ?? preview.summary.enabled_doc_tasks} · 不纳入:{preview.summary.unmanaged_doc_tasks ?? 0}
</div>
<div>职责指派:{preview.summary.assigned_duties} 项</div>
{(preview.summary.author_only_doc_tasks ?? 0) > 0 && (
<div>
兼任审批:{preview.summary.author_only_doc_tasks} 份
{(preview.summary.author_only_documents || []).length > 0
? `(${(preview.summary.author_only_documents || [])
.map((doc) => doc.title || doc.task_key)
.join('、')})`
: ''}
</div>
)}
{(preview.summary.unmanaged_documents || []).length > 0 && (
<div>
例外文档:{(preview.summary.unmanaged_documents || []).map((doc) => doc.title || doc.task_key).join('、')}
</div>
)}
{preview.summary.first_stage_name && (
<div>首阶段:{preview.summary.first_stage_name}</div>
)}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: 0 }}>
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。{ACTIVATION_UNMANAGED_FREEZE_HINT}{' '}
令牌有效期至 {new Date(preview.expires_at).toLocaleTimeString()}。
</p>
</div>
)}
</div>
{error && (
<div
role="alert"
aria-live="polite"
style={{
margin: '0 20px 0',
padding: '10px 12px',
background: 'var(--danger-50, #fef2f2)',
border: '1px solid var(--danger-200)',
borderRadius: '8px',
color: 'var(--danger-700)',
fontSize: '13px',
display: 'flex',
gap: '8px',
}}
>
<AlertCircle size={16} />
{error}
</div>
)}
<div
style={{
padding: '14px 20px',
borderTop: error ? 'none' : '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
}}
>
<button
onClick={() => {
if (showEditor && !liveEdit && step === 'configure') setStep('select');
else if (showEditor && step === 'confirm') setStep('configure');
else onClose();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '8px 14px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
cursor: 'pointer',
fontSize: '13px',
}}
>
<ChevronLeft size={14} />
{!showEditor || step === 'select' || liveEdit ? '关闭' : '上一步'}
</button>
{showSave && chrome.showWorkflowSelector && step === 'select' && (
<button
onClick={handleSelectWorkflow}
disabled={!selectedKey || loading}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
background: 'var(--brand-600, #4f46e5)',
color: '#fff',
cursor: 'pointer',
fontSize: '13px',
opacity: !selectedKey ? 0.5 : 1,
}}
>
下一步 <ChevronRight size={14} />
</button>
)}
{showSave && step === 'configure' && (
<button
onClick={handleSavePlan}
disabled={loading || !plan}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
background: 'var(--brand-600, #4f46e5)',
color: '#fff',
cursor: 'pointer',
fontSize: '13px',
}}
>
{loading ? <Loader2 size={14} /> : null}
{liveEdit ? '保存配置' : '校验并预览'} {!liveEdit && <ChevronRight size={14} />}
</button>
)}
{showSave && step === 'confirm' && (
<button
onClick={handleActivate}
disabled={loading}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
background: 'var(--success, #16a34a)',
color: '#fff',
cursor: 'pointer',
fontSize: '13px',
}}
>
{loading ? <Loader2 size={14} /> : <CheckCircle2 size={14} />}
确认激活
</button>
)}
</div>
</div>
</div>
);
};