diff --git a/web/src/App.tsx b/web/src/App.tsx index b99082d3..41a54356 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(null); const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false); const [projectMembersError, setProjectMembersError] = useState(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 + Record >({}); const [projectLineages, setProjectLineages] = useState([]); 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} /> + + ({ + 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', + }, + })); + }} + /> ); } diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts new file mode 100644 index 00000000..2df4b01a --- /dev/null +++ b/web/src/api/projectSettings.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index b8f0a062..601d7164 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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 = ({ 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 = ({ onToggleRight={() => setRightCollapsed(!rightCollapsed)} activeWorkflowKey={activeWorkflowKey} workflowVersion={workflowVersion} + lifecycleStatus={lifecycleStatus} isCreator={isCreator} + governanceEnabled={governanceEnabled} onWorkflowChanged={onWorkflowChanged} + onOpenProjectSettings={onOpenProjectSettings} />
void; activeWorkflowKey?: string; workflowVersion?: number; + lifecycleStatus?: string; isCreator?: boolean; + governanceEnabled?: boolean; onWorkflowChanged?: (newKey: string, newVersion: number) => void; + onOpenProjectSettings?: () => void; } export const Topbar: React.FC = ({ @@ -27,8 +30,11 @@ export const Topbar: React.FC = ({ onToggleRight, activeWorkflowKey = 'main', workflowVersion = 1, + lifecycleStatus = 'active', isCreator = true, + governanceEnabled = false, onWorkflowChanged, + onOpenProjectSettings, }) => { return (
= ({ projectId={currentSession.projectId} activeWorkflowKey={activeWorkflowKey} workflowVersion={workflowVersion} + lifecycleStatus={lifecycleStatus} isCreator={isCreator} + governanceEnabled={governanceEnabled} onWorkflowChanged={onWorkflowChanged} + onOpenSettings={onOpenProjectSettings} /> {/* 项目助手提示 */} diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx new file mode 100644 index 00000000..8f345161 --- /dev/null +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -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 = ({ + open, + projectId, + projectName, + members, + onClose, + onActivated, +}) => { + const [step, setStep] = useState('select'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [settings, setSettings] = useState(null); + const [plan, setPlan] = useState(null); + const [preview, setPreview] = useState(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) => { + 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 ( +
+
e.stopPropagation()} + > +
+
+
项目工作流配置
+
{projectName}
+
+ +
+ +
+ {(['select', 'configure', 'confirm'] as Step[]).map((s, i) => ( + + {i + 1}. {s === 'select' ? '选择工作流' : s === 'configure' ? '配置职责' : '确认激活'} + + ))} +
+ +
+ {loading && !settings && ( +
+ 加载中... +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {step === 'select' && settings && ( +
+

+ 选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。 +

+ {(settings.published_workflows || []).map((wf) => ( + + ))} + {(!settings.published_workflows || settings.published_workflows.length === 0) && ( +
+ 暂无已发布工作流,请先在系统设置中发布工作流定义。 +
+ )} +
+ )} + + {step === 'configure' && plan && ( +
+

+ 为启用的文档任务指派作者、审核人、批准人(三权分离)。 +

+ {plan.stages.length === 0 && ( +
+ 请先保存一次规划以从工作流定义加载阶段结构,或返回上一步重新选择。 +
+ )} + {plan.stages.map((st, si) => ( +
+
{st.stage_key}
+ {st.doc_tasks.map((dt, ti) => ( +
+ + {dt.enabled && ( +
+ {(['author', 'reviewer', 'approver'] as const).map((role) => ( + + ))} +
+ )} +
+ ))} +
+ ))} +
+ )} + + {step === 'confirm' && preview && ( +
+
+
+ + 激活预览 +
+
工作流:{preview.summary.workflow_name} v{preview.summary.workflow_version}
+
启用阶段:{preview.summary.enabled_stages} · 文档任务:{preview.summary.enabled_doc_tasks}
+
职责指派:{preview.summary.assigned_duties} 项
+ {preview.summary.first_stage_name && ( +
首阶段:{preview.summary.first_stage_name}
+ )} +
+

+ 确认后将锁定工作流配置,运行期间不可直接修改开关与职责。令牌有效期至{' '} + {new Date(preview.expires_at).toLocaleTimeString()}。 +

+
+ )} +
+ +
+ + + {step === 'select' && ( + + )} + + {step === 'configure' && ( + + )} + + {step === 'confirm' && ( + + )} +
+
+
+ ); +}; diff --git a/web/src/components/workflow/ProjectWorkflowSwitcher.tsx b/web/src/components/workflow/ProjectWorkflowSwitcher.tsx index a0bf0105..e0a8417f 100644 --- a/web/src/components/workflow/ProjectWorkflowSwitcher.tsx +++ b/web/src/components/workflow/ProjectWorkflowSwitcher.tsx @@ -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 = ({ projectId, activeWorkflowKey, workflowVersion, + lifecycleStatus = 'active', isCreator, + governanceEnabled = false, onWorkflowChanged, + onOpenSettings, }) => { const [switching, setSwitching] = useState(false); const [errorMsg, setErrorMsg] = useState(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 = ( 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 = ( 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 ( +
+
+ + {isPlanning ? ( + 规划态 · 未激活工作流 + ) : ( + + {workflowLabel(activeWorkflowKey)} + + + )} + {isPlanning && isCreator && onOpenSettings && ( + + )} + {!isPlanning && onOpenSettings && ( + + )} + {!isCreator && isPlanning && } +
+ {errorMsg && ( +
+ + {errorMsg} +
+ )} +
+ ); + } + return (
= ( return (