1016 lines
41 KiB
TypeScript
1016 lines
41 KiB
TypeScript
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>
|
||
);
|
||
};
|