feat(web): add project workflow settings wizard and replace free switcher.

Topbar shows configure/view workflow actions under governance mode with a multi-step ProjectSettingsModal for plan, duty assignment, and confirmed activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-12 03:03:34 +00:00
co-authored by Cursor
parent 8ef408ba58
commit 3fca0f1aca
8 changed files with 808 additions and 11 deletions
+42 -4
View File
@@ -52,6 +52,8 @@ import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
import { CreateProjectModal } from './components/project/CreateProjectModal';
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
import { fetchProjectSettings } from './api/projectSettings';
import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
import { ShieldAlert } from 'lucide-react';
import { LoginView } from './components/auth/LoginView';
@@ -590,6 +592,8 @@ export default function App() {
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
const [projectMembersError, setProjectMembersError] = useState<string | null>(null);
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
const [workflowGovernanceEnabled, setWorkflowGovernanceEnabled] = useState(false);
const isAdmin = currentUser.roleId === 'role-admin';
@@ -809,16 +813,17 @@ export default function App() {
// 项目多工作流激活状态与文档血缘 (支持前端切换与状态联动)
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
Record<number, { activeKey: string; version: number; creatorId: number }>
Record<number, { activeKey: string; version: number; creatorId: number; lifecycleStatus: string }>
>({});
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
const currentActiveSession =
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0];
const currentWfState = projectWorkflowsState[currentProjectId] || {
activeKey: 'main',
activeKey: '',
version: 1,
creatorId: currentActiveSession?.userId || currentUser.id || 2,
lifecycleStatus: 'planning',
};
useEffect(() => {
@@ -829,14 +834,18 @@ export default function App() {
setProjectWorkflowsState((prev) => ({
...prev,
[currentProjectId]: {
activeKey: p.active_workflow_key || 'main',
activeKey: p.active_workflow_key || '',
version: p.workflow_selection_version || 1,
creatorId: p.creator_user_id || 1,
lifecycleStatus: p.lifecycle_status || (p.active_workflow_key ? 'active' : 'planning'),
},
}));
}
})
.catch(() => {});
fetchProjectSettings(currentProjectId)
.then(() => setWorkflowGovernanceEnabled(true))
.catch(() => setWorkflowGovernanceEnabled(false));
}
}, [currentProjectId]);
@@ -3343,11 +3352,19 @@ export default function App() {
onOpenProjectMembers={() => setProjectMembersModalOpen(true)}
activeWorkflowKey={currentWfState.activeKey}
workflowVersion={currentWfState.version}
lifecycleStatus={currentWfState.lifecycleStatus}
isCreator={currentWfState.creatorId === currentUser.id}
governanceEnabled={workflowGovernanceEnabled}
onOpenProjectSettings={() => setProjectSettingsOpen(true)}
onWorkflowChanged={(newKey, newVer) => {
setProjectWorkflowsState((prev) => ({
...prev,
[currentProjectId]: { ...currentWfState, activeKey: newKey, version: newVer },
[currentProjectId]: {
...currentWfState,
activeKey: newKey,
version: newVer,
lifecycleStatus: 'active',
},
}));
}}
workflowBanner={workflowBanner}
@@ -3810,6 +3827,27 @@ export default function App() {
onRemoveMember={handleRemoveProjectMember}
onRestoreMember={handleRestoreProjectMember}
/>
<ProjectSettingsModal
open={projectSettingsOpen}
projectId={currentProjectId}
projectName={currentActiveSession?.title || '当前项目'}
members={(currentProject?.members || []).map((m) => ({
userId: m.userId,
name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`,
}))}
onClose={() => setProjectSettingsOpen(false)}
onActivated={(activeKey) => {
setProjectWorkflowsState((prev) => ({
...prev,
[currentProjectId]: {
...currentWfState,
activeKey,
lifecycleStatus: 'active',
},
}));
}}
/>
</>
);
}
+102
View File
@@ -0,0 +1,102 @@
import {
ProjectSettings,
WorkflowPlan,
ActivationPreview,
ActivateResult,
} from '../types/projectSettings';
import { getBearerAuthHeaders } from './authHeaders';
const API_BASE = '';
export async function fetchDefaultWorkflowPlan(
projectId: number,
workflowKey: string,
version?: number
): Promise<WorkflowPlan> {
const params = new URLSearchParams({ workflow_key: workflowKey });
if (version) params.set('version', String(version));
const res = await fetch(
`${API_BASE}/api/v1/projects/${projectId}/workflow-plan/default?${params}`,
{ headers: getBearerAuthHeaders() }
);
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `加载默认规划失败: ${res.status}`);
}
return res.json();
}
export async function fetchProjectSettings(projectId: number): Promise<ProjectSettings> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/settings`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `获取项目设置失败: ${res.status}`);
}
return res.json();
}
export async function saveWorkflowPlan(
projectId: number,
plan: WorkflowPlan,
expectedPlanRevision: number,
expectedSelectionVersion: number
): Promise<ProjectSettings> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, {
method: 'PUT',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({
plan,
expected_plan_revision: expectedPlanRevision,
expected_selection_version: expectedSelectionVersion,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(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',
headers: getBearerAuthHeaders('application/json'),
body: '{}',
});
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `激活预览失败: ${res.status}`);
}
return res.json();
}
export async function activateWorkflowInstance(
projectId: number,
input: {
confirmation_token: string;
expected_project_version: number;
expected_plan_revision: number;
}
): Promise<ActivateResult> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-instances/activate`, {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `激活工作流失败: ${res.status}`);
}
return res.json();
}
function parseError(text: string): string | null {
try {
const parsed = JSON.parse(text);
return parsed.message || parsed.error || null;
} catch {
return text || null;
}
}
+9
View File
@@ -25,8 +25,11 @@ interface AppShellProps {
docContent: React.ReactNode;
activeWorkflowKey?: string;
workflowVersion?: number;
lifecycleStatus?: string;
isCreator?: boolean;
governanceEnabled?: boolean;
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
onOpenProjectSettings?: () => void;
workflowBanner?: React.ReactNode;
}
@@ -48,8 +51,11 @@ export const AppShell: React.FC<AppShellProps> = ({
docContent,
activeWorkflowKey,
workflowVersion,
lifecycleStatus,
isCreator,
governanceEnabled,
onWorkflowChanged,
onOpenProjectSettings,
workflowBanner,
}) => {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
@@ -123,8 +129,11 @@ export const AppShell: React.FC<AppShellProps> = ({
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
activeWorkflowKey={activeWorkflowKey}
workflowVersion={workflowVersion}
lifecycleStatus={lifecycleStatus}
isCreator={isCreator}
governanceEnabled={governanceEnabled}
onWorkflowChanged={onWorkflowChanged}
onOpenProjectSettings={onOpenProjectSettings}
/>
<div
+9
View File
@@ -13,8 +13,11 @@ interface TopbarProps {
onToggleRight: () => void;
activeWorkflowKey?: string;
workflowVersion?: number;
lifecycleStatus?: string;
isCreator?: boolean;
governanceEnabled?: boolean;
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
onOpenProjectSettings?: () => void;
}
export const Topbar: React.FC<TopbarProps> = ({
@@ -27,8 +30,11 @@ export const Topbar: React.FC<TopbarProps> = ({
onToggleRight,
activeWorkflowKey = 'main',
workflowVersion = 1,
lifecycleStatus = 'active',
isCreator = true,
governanceEnabled = false,
onWorkflowChanged,
onOpenProjectSettings,
}) => {
return (
<header
@@ -123,8 +129,11 @@ export const Topbar: React.FC<TopbarProps> = ({
projectId={currentSession.projectId}
activeWorkflowKey={activeWorkflowKey}
workflowVersion={workflowVersion}
lifecycleStatus={lifecycleStatus}
isCreator={isCreator}
governanceEnabled={governanceEnabled}
onWorkflowChanged={onWorkflowChanged}
onOpenSettings={onOpenProjectSettings}
/>
{/* 项目助手提示 */}
@@ -0,0 +1,463 @@
import React, { useCallback, useEffect, useState } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import {
fetchProjectSettings,
fetchDefaultWorkflowPlan,
saveWorkflowPlan,
fetchActivationPreview,
activateWorkflowInstance,
} from '../../api/projectSettings';
import {
ProjectSettings,
WorkflowPlan,
ActivationPreview,
DocTaskPlan,
} from '../../types/projectSettings';
interface ProjectMemberOption {
userId: number;
name: string;
}
interface ProjectSettingsModalProps {
open: boolean;
projectId: number;
projectName: string;
members: ProjectMemberOption[];
onClose: () => void;
onActivated?: (activeWorkflowKey: string) => void;
}
type Step = 'select' | 'configure' | 'confirm';
export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
open,
projectId,
projectName,
members,
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 [preview, setPreview] = useState<ActivationPreview | null>(null);
const [selectedKey, setSelectedKey] = useState('');
const loadSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const s = await fetchProjectSettings(projectId);
setSettings(s);
if (s.plan) {
setPlan(s.plan);
setSelectedKey(s.plan.workflow_definition_key);
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) 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 handleSavePlan = async () => {
if (!plan || !settings) return;
setLoading(true);
setError(null);
try {
const updated = await saveWorkflowPlan(
projectId,
plan,
settings.workflow_plan_revision,
settings.workflow_selection_version
);
setSettings(updated);
if (updated.plan) setPlan(updated.plan);
const pv = await fetchActivationPreview(projectId);
setPreview(pv);
setStep('confirm');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '保存规划失败');
} finally {
setLoading(false);
}
};
const handleActivate = async () => {
if (!preview || !settings) 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) {
setError(err instanceof Error ? err.message : '激活失败');
} finally {
setLoading(false);
}
};
if (!open) return null;
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>
<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>
)}
{error && (
<div
style={{
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',
marginBottom: '12px',
}}
>
<AlertCircle size={16} />
{error}
</div>
)}
{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>
)}
</div>
)}
{step === 'configure' && plan && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
为启用的文档任务指派作者、审核人、批准人(三权分离)。
</p>
{plan.stages.length === 0 && (
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
请先保存一次规划以从工作流定义加载阶段结构,或返回上一步重新选择。
</div>
)}
{plan.stages.map((st, si) => (
<div key={st.stage_key} style={{ border: '1px solid var(--border)', borderRadius: '8px', padding: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>{st.stage_key}</div>
{st.doc_tasks.map((dt, ti) => (
<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_key}
</label>
{dt.enabled && (
<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>
)}
</div>
))}
</div>
))}
</div>
)}
{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.assigned_duties} 项</div>
{preview.summary.first_stage_name && (
<div>首阶段:{preview.summary.first_stage_name}</div>
)}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: 0 }}>
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。令牌有效期至{' '}
{new Date(preview.expires_at).toLocaleTimeString()}。
</p>
</div>
)}
</div>
<div
style={{
padding: '14px 20px',
borderTop: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
}}
>
<button
onClick={() => {
if (step === 'configure') setStep('select');
else if (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} />
{step === 'select' ? '取消' : '上一步'}
</button>
{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>
)}
{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}
校验并预览 <ChevronRight size={14} />
</button>
)}
{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>
);
};
@@ -1,13 +1,16 @@
import React, { useState } from 'react';
import { Workflow, Lock, CheckCircle2, AlertCircle } from 'lucide-react';
import { Workflow, Lock, CheckCircle2, AlertCircle, Settings2, Eye } from 'lucide-react';
import { activateWorkflow, getProject } from '../../api/projectWorkflow';
interface ProjectWorkflowSwitcherProps {
projectId: number;
activeWorkflowKey: string;
workflowVersion: number;
lifecycleStatus?: string;
isCreator: boolean;
governanceEnabled?: boolean;
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
onOpenSettings?: () => void;
}
const WORKFLOW_OPTIONS = [
@@ -16,17 +19,26 @@ const WORKFLOW_OPTIONS = [
{ key: 'test_manager', label: '智能测试经理', icon: '🧪' },
];
const workflowLabel = (key: string) =>
WORKFLOW_OPTIONS.find((w) => w.key === key)?.label || key;
export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = ({
projectId,
activeWorkflowKey,
workflowVersion,
lifecycleStatus = 'active',
isCreator,
governanceEnabled = false,
onWorkflowChanged,
onOpenSettings,
}) => {
const [switching, setSwitching] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const handleSelect = async (targetKey: string) => {
const isPlanning = lifecycleStatus === 'planning' || !activeWorkflowKey;
const showGovernanceUI = governanceEnabled || isPlanning;
const handleLegacySelect = async (targetKey: string) => {
if (targetKey === activeWorkflowKey) return;
if (!isCreator) {
setErrorMsg('权限不足:仅项目创建人可切换激活工作流');
@@ -44,8 +56,7 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
expected_version: workflowVersion,
reason: '前端控制台用户切换工作流',
});
} catch (firstErr: any) {
// 若遇到并发版本落后或冲突,自动拉取数据库最新项目版本重试一次
} catch {
const fresh = await getProject(projectId);
res = await activateWorkflow(projectId, {
workflow_key: targetKey,
@@ -56,14 +67,109 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
if (onWorkflowChanged && res) {
onWorkflowChanged(res.active_workflow_key, res.version);
}
} catch (err: any) {
setErrorMsg(err.message || '切换工作流失败');
} catch (err: unknown) {
setErrorMsg(err instanceof Error ? err.message : '切换工作流失败');
setTimeout(() => setErrorMsg(null), 4000);
} finally {
setSwitching(false);
}
};
if (showGovernanceUI) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
background: 'var(--bg-subtle, #f1f5f9)',
borderRadius: 'var(--r-md, 8px)',
border: '1px solid var(--border, #e2e8f0)',
fontSize: '11px',
}}
>
<Workflow size={13} />
{isPlanning ? (
<span style={{ color: 'var(--amber-600, #d97706)', fontWeight: 600 }}>规划态 · 未激活工作流</span>
) : (
<span style={{ fontWeight: 600, color: 'var(--brand-600)' }}>
{workflowLabel(activeWorkflowKey)}
<CheckCircle2 size={12} style={{ marginLeft: '4px', display: 'inline' }} />
</span>
)}
{isPlanning && isCreator && onOpenSettings && (
<button
onClick={onOpenSettings}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
marginLeft: '4px',
fontSize: '11px',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
background: 'var(--brand-600, #4f46e5)',
color: '#fff',
}}
>
<Settings2 size={12} />
配置工作流
</button>
)}
{!isPlanning && onOpenSettings && (
<button
onClick={onOpenSettings}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
marginLeft: '4px',
fontSize: '11px',
borderRadius: '6px',
border: '1px solid var(--border)',
cursor: 'pointer',
background: 'var(--bg-card, #fff)',
color: 'var(--text-muted)',
}}
>
<Eye size={12} />
查看流程
</button>
)}
{!isCreator && isPlanning && <Lock size={12} style={{ opacity: 0.5 }} />}
</div>
{errorMsg && (
<div
style={{
position: 'absolute',
top: '36px',
left: '0',
zIndex: 100,
background: 'var(--danger-50, #fef2f2)',
border: '1px solid var(--danger-200, #fecaca)',
color: 'var(--danger-700, #b91c1c)',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
}}
>
<AlertCircle size={12} />
{errorMsg}
</div>
)}
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
<div
@@ -97,7 +203,7 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
return (
<button
key={wf.key}
onClick={() => handleSelect(wf.key)}
onClick={() => handleLegacySelect(wf.key)}
disabled={switching || (!isCreator && !isActive)}
title={
!isCreator
+68
View File
@@ -0,0 +1,68 @@
export interface DocDuty {
author_user_id?: number;
reviewer_user_id?: number;
approver_user_id?: number;
}
export interface DocTaskPlan {
task_key: string;
enabled: boolean;
duties: DocDuty;
}
export interface StagePlan {
stage_key: string;
enabled: boolean;
doc_tasks: DocTaskPlan[];
}
export interface WorkflowPlan {
workflow_definition_key: string;
workflow_version: number;
workflow_version_id?: number;
stages: StagePlan[];
}
export interface PublishedOption {
definition_key: string;
definition_name: string;
latest_version: number;
workflow_version_id: number;
product_domain?: string;
}
export interface ProjectSettings {
project_id: number;
lifecycle_status: string;
active_workflow_key: string;
workflow_selection_version: number;
workflow_plan_revision: number;
plan?: WorkflowPlan;
can_edit_plan: boolean;
can_activate: boolean;
published_workflows?: PublishedOption[];
}
export interface ActivationPreview {
confirmation_token: string;
plan_hash: string;
expected_project_version: number;
expected_plan_revision: number;
summary: {
workflow_name: string;
workflow_version: number;
enabled_stages: number;
enabled_doc_tasks: number;
assigned_duties: number;
first_stage_key?: string;
first_stage_name?: string;
};
validation: { valid: boolean; issues: Array<{ code: string; message: string }> };
expires_at: string;
}
export interface ActivateResult {
project_id: number;
lifecycle_status: string;
active_workflow_key: string;
}
+2
View File
@@ -12,8 +12,10 @@ export interface Project {
intended_use?: string;
target_market?: string;
creator_user_id: number;
lifecycle_status?: string;
active_workflow_key: string;
workflow_selection_version: number;
workflow_plan_revision?: number;
created_at: string;
updated_at: string;
}