From 443a09d5b469fef1e13baef2a1f52db1c4b4fee7 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Thu, 10 Sep 2026 23:56:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20Phase=206=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B9=E7=9B=AE=E5=A4=9A=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=99=A8=E3=80=81=E5=89=8D=E7=BD=AE=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=A8=AA=E5=B9=85=E4=B8=8E=E6=96=87=E6=A1=A3=E8=A1=80?= =?UTF-8?q?=E7=BC=98=E5=A4=B1=E6=95=88=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.tsx | 67 +++++++- web/src/api/projectWorkflow.test.ts | 50 ++++++ web/src/api/projectWorkflow.ts | 151 ++++++++++++++++++ web/src/components/layout/AppShell.tsx | 19 ++- web/src/components/layout/Topbar.tsx | 20 +++ .../workflow/ProjectWorkflowSwitcher.tsx | 148 +++++++++++++++++ .../workflow/StaleDocumentNotice.tsx | 63 ++++++++ .../workflow/WorkflowPrerequisiteBanner.tsx | 63 ++++++++ web/src/types/projectWorkflow.ts | 79 +++++++++ 9 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 web/src/api/projectWorkflow.test.ts create mode 100644 web/src/api/projectWorkflow.ts create mode 100644 web/src/components/workflow/ProjectWorkflowSwitcher.tsx create mode 100644 web/src/components/workflow/StaleDocumentNotice.tsx create mode 100644 web/src/components/workflow/WorkflowPrerequisiteBanner.tsx create mode 100644 web/src/types/projectWorkflow.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 2e58de57..291ec886 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,10 @@ import { unshareFreeformDoc, deleteFreeformDoc, } from './api/freeformDoc'; +import { fetchProjectLineage } from './api/projectWorkflow'; +import { DocumentLineage } from './types/projectWorkflow'; +import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner'; +import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { deriveReplyTitle } from './features/replyExport/title'; import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard'; import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download'; @@ -468,11 +472,12 @@ export default function App() { }, [deliverables]); const [activeCategory, setActiveCategory] = useState('workflow'); + const [activeDocId, setActiveDocId] = useState(() => { const activeSess = sessions.find((s) => s.sessionId === activeSessionId); if (activeSess?.projectId === 5201) return 'doc-neuropulse-feasibility_reg'; if (activeSess?.projectId === 3) return 'doc-cusa-feasibility-reg'; - return 'doc-001'; + return deliverables[0]?.docId || 'doc-001'; }); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); @@ -533,6 +538,56 @@ export default function App() { return activeSess ? activeSess.projectId : 1; }, [sessions, activeSessionId]); + // 项目多工作流激活状态与文档血缘 (支持前端切换与状态联动) + const [projectWorkflowsState, setProjectWorkflowsState] = useState< + Record + >({}); + const [projectLineages, setProjectLineages] = useState([]); + + const currentActiveSession = + sessions.find((s) => s.sessionId === activeSessionId) || sessions[0]; + const currentWfState = projectWorkflowsState[currentProjectId] || { + activeKey: 'main', + version: 1, + creatorId: currentActiveSession?.userId || currentUser.id || 2, + }; + + useEffect(() => { + if (currentProjectId) { + fetchProjectLineage(currentProjectId) + .then((lins) => setProjectLineages(lins)) + .catch(() => {}); + } + }, [currentProjectId, currentWfState.activeKey]); + + const hasApprovedSystemReqs = deliverables.some( + (d) => + d.projectId === currentProjectId && + (d.taskKey === 'system_reqs' || d.docId.includes('system_reqs')) && + d.lifecycleState === 'approved' + ); + + const workflowBanner = ( + <> + { + setProjectWorkflowsState((prev) => ({ + ...prev, + [currentProjectId]: { ...currentWfState, activeKey: 'product_manager' }, + })); + }} + /> + { + fetchProjectLineage(currentProjectId).then(setProjectLineages).catch(() => {}); + }} + /> + + ); + // 严格按 projectId 隔离当前项目的交付物 const currentProjectDeliverables = useMemo(() => { return deliverables.filter((doc) => doc.projectId === currentProjectId); @@ -2161,6 +2216,16 @@ export default function App() { onOpenRightsModal={() => setRightsModalOpen(true)} onOpenUserSettings={() => setUserSettingsOpen(true)} onOpenAdminSettings={() => setAdminSettingsOpen(true)} + activeWorkflowKey={currentWfState.activeKey} + workflowVersion={currentWfState.version} + isCreator={currentWfState.creatorId === currentUser.id} + onWorkflowChanged={(newKey, newVer) => { + setProjectWorkflowsState((prev) => ({ + ...prev, + [currentProjectId]: { ...currentWfState, activeKey: newKey, version: newVer }, + })); + }} + workflowBanner={workflowBanner} chatContent={
{/* P1, P2, P3: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */} diff --git a/web/src/api/projectWorkflow.test.ts b/web/src/api/projectWorkflow.test.ts new file mode 100644 index 00000000..4e7a7f19 --- /dev/null +++ b/web/src/api/projectWorkflow.test.ts @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + Project, + ProjectWorkflow, + WorkflowDefinition, + DocumentLineage, +} from '../types/projectWorkflow'; + +test('Project workflow types validation', () => { + const p: Project = { + id: 1001, + code: 'electro-cutter-01', + name: '智能高频电刀', + status: 'active', + creator_user_id: 2, + active_workflow_key: 'main', + workflow_selection_version: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + assert.equal(p.id, 1001); + assert.equal(p.creator_user_id, 2); + assert.equal(p.active_workflow_key, 'main'); + + const pw: ProjectWorkflow = { + id: 1, + project_id: 1001, + workflow_key: 'product_manager', + is_active: true, + display_order: 2, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + assert.equal(pw.workflow_key, 'product_manager'); + + const lineage: DocumentLineage = { + id: 1, + project_id: 1001, + downstream_doc_id: 'doc_proj1001_test_manager', + downstream_version: 1, + upstream_doc_id: 'doc_proj1001_system_reqs', + upstream_version: 1, + upstream_sha256: 'sha_upstream', + is_stale: false, + created_at: new Date().toISOString(), + }; + assert.equal(lineage.is_stale, false); + assert.equal(lineage.upstream_doc_id, 'doc_proj1001_system_reqs'); +}); diff --git a/web/src/api/projectWorkflow.ts b/web/src/api/projectWorkflow.ts new file mode 100644 index 00000000..794679cd --- /dev/null +++ b/web/src/api/projectWorkflow.ts @@ -0,0 +1,151 @@ +import { + Project, + ProjectWorkflow, + WorkflowDefinition, + DocumentLineage, + CreateProjectInput, + ActivateWorkflowInput, + ActivateWorkflowResult, +} from '../types/projectWorkflow'; + +const API_BASE = ''; + +function getAuthHeaders(): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (typeof window !== 'undefined' && window.localStorage) { + try { + const raw = window.localStorage.getItem('smdf_auth_session'); + if (raw) { + const session = JSON.parse(raw); + if (session.token) { + headers['Authorization'] = `Bearer ${session.token}`; + } + if (session.user?.id) { + headers['X-User-ID'] = String(session.user.id); + } + } + } catch { + // ignore + } + } + return headers; +} + +export async function fetchProjects(): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects`, { + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`获取项目列表失败: ${res.status}`); + } + return res.json(); +} + +export async function createProject(data: CreateProjectInput): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `创建项目失败: ${res.status}`); + } + return res.json(); +} + +export async function getProject(id: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${id}`, { + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`获取项目详情失败: ${res.status}`); + } + return res.json(); +} + +export async function fetchProjectWorkflows(projectId: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows`, { + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`获取项目工作流清单失败: ${res.status}`); + } + return res.json(); +} + +export async function activateWorkflow( + projectId: number, + input: ActivateWorkflowInput +): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/active-workflow`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text(); + let msg = text; + try { + const parsed = JSON.parse(text); + if (parsed.message) msg = parsed.message; + } catch { + // ignore + } + throw new Error(msg || `切换激活工作流失败: ${res.status}`); + } + return res.json(); +} + +export async function fetchWorkflowCatalog(): Promise { + const res = await fetch(`${API_BASE}/api/v1/workflows`, { + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`获取工作流规范目录失败: ${res.status}`); + } + return res.json(); +} + +export async function fetchProjectLineage( + projectId: number, + docId?: string +): Promise { + const url = docId + ? `${API_BASE}/api/v1/projects/${projectId}/lineage?doc_id=${encodeURIComponent(docId)}` + : `${API_BASE}/api/v1/projects/${projectId}/lineage`; + const res = await fetch(url, { + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`获取文档血缘失败: ${res.status}`); + } + return res.json(); +} + +export async function runProjectWorkflow( + projectId: number, + workflowKey: string, + payload: Record +): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows/${workflowKey}/run`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text(); + let msg = text; + try { + const parsed = JSON.parse(text); + if (parsed.message) msg = parsed.message; + else if (parsed.error_reason) msg = parsed.error_reason; + } catch { + // ignore + } + throw new Error(msg || `启动工作流运行失败: ${res.status}`); + } + return res.json(); +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 9800865c..eb3d6f92 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -22,6 +22,11 @@ interface AppShellProps { onOpenAdminSettings?: () => void; chatContent: React.ReactNode; docContent: React.ReactNode; + activeWorkflowKey?: string; + workflowVersion?: number; + isCreator?: boolean; + onWorkflowChanged?: (newKey: string, newVersion: number) => void; + workflowBanner?: React.ReactNode; } export const AppShell: React.FC = ({ @@ -39,6 +44,11 @@ export const AppShell: React.FC = ({ onOpenAdminSettings, chatContent, docContent, + activeWorkflowKey, + workflowVersion, + isCreator, + onWorkflowChanged, + workflowBanner, }) => { const [theme, setTheme] = useState<'dark' | 'light'>('dark'); const [leftWidth, setLeftWidth] = useState(280); @@ -97,6 +107,10 @@ export const AppShell: React.FC = ({ onToggleLeft={() => setLeftCollapsed(!leftCollapsed)} rightCollapsed={rightCollapsed} onToggleRight={() => setRightCollapsed(!rightCollapsed)} + activeWorkflowKey={activeWorkflowKey} + workflowVersion={workflowVersion} + isCreator={isCreator} + onWorkflowChanged={onWorkflowChanged} />
= ({ )} {/* 中间栏 */} - {chatContent} + + {workflowBanner} + {chatContent} + {!rightCollapsed && ( void; rightCollapsed: boolean; onToggleRight: () => void; + activeWorkflowKey?: string; + workflowVersion?: number; + isCreator?: boolean; + onWorkflowChanged?: (newKey: string, newVersion: number) => void; } export const Topbar: React.FC = ({ @@ -20,6 +25,10 @@ export const Topbar: React.FC = ({ onToggleLeft, rightCollapsed, onToggleRight, + activeWorkflowKey = 'main', + workflowVersion = 1, + isCreator = true, + onWorkflowChanged, }) => { return (
= ({
+
+ + {/* 项目工作流切换器 */} + + {/* 项目助手提示 */}
void; +} + +const WORKFLOW_OPTIONS = [ + { key: 'main', label: '主协同流程', icon: '⚡' }, + { key: 'product_manager', label: '智能产品经理', icon: '📋' }, + { key: 'test_manager', label: '智能测试经理', icon: '🧪' }, +]; + +export const ProjectWorkflowSwitcher: React.FC = ({ + projectId, + activeWorkflowKey, + workflowVersion, + isCreator, + onWorkflowChanged, +}) => { + const [switching, setSwitching] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + const handleSelect = async (targetKey: string) => { + if (targetKey === activeWorkflowKey) return; + if (!isCreator) { + setErrorMsg('权限不足:仅项目创建人可切换激活工作流'); + setTimeout(() => setErrorMsg(null), 3000); + return; + } + + setSwitching(true); + setErrorMsg(null); + try { + const res = await activateWorkflow(projectId, { + workflow_key: targetKey, + expected_version: workflowVersion, + reason: '前端控制台用户切换工作流', + }); + if (onWorkflowChanged) { + onWorkflowChanged(res.active_workflow_key, res.version); + } + } catch (err: any) { + setErrorMsg(err.message || '切换工作流失败'); + setTimeout(() => setErrorMsg(null), 4000); + } finally { + setSwitching(false); + } + }; + + return ( +
+
+ + + 工作流: + + + {WORKFLOW_OPTIONS.map((wf) => { + const isActive = wf.key === activeWorkflowKey; + return ( + + ); + })} +
+ + {errorMsg && ( +
+ + {errorMsg} +
+ )} +
+ ); +}; diff --git a/web/src/components/workflow/StaleDocumentNotice.tsx b/web/src/components/workflow/StaleDocumentNotice.tsx new file mode 100644 index 00000000..ebde03e1 --- /dev/null +++ b/web/src/components/workflow/StaleDocumentNotice.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { BellRing, RefreshCw } from 'lucide-react'; +import { DocumentLineage } from '../../types/projectWorkflow'; + +interface StaleDocumentNoticeProps { + lineages: DocumentLineage[]; + onTriggerRecheck?: () => void; +} + +export const StaleDocumentNotice: React.FC = ({ + lineages, + onTriggerRecheck, +}) => { + const staleItem = lineages.find((l) => l.is_stale); + if (!staleItem) return null; + + return ( +
+
+ + + 上游输入已变更(血缘失效): + {staleItem.stale_reason || '上游系统需求规格书已重新生成或更新,当前验证方案可能存在覆盖缺口。'} + +
+ {onTriggerRecheck && ( + + )} +
+ ); +}; diff --git a/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx b/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx new file mode 100644 index 00000000..ffe842ef --- /dev/null +++ b/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { AlertTriangle, ArrowRight } from 'lucide-react'; + +interface WorkflowPrerequisiteBannerProps { + workflowKey: string; + hasApprovedSystemReqs: boolean; + onSwitchToProductManager: () => void; +} + +export const WorkflowPrerequisiteBanner: React.FC = ({ + workflowKey, + hasApprovedSystemReqs, + onSwitchToProductManager, +}) => { + if (workflowKey !== 'test_manager' || hasApprovedSystemReqs) { + return null; + } + + return ( +
+
+ + + 前置依赖未就绪: + 智能测试经理工作流强依赖同项目已批准的《系统需求规格书 (RD-SR)》。请先由产品经理完成编制并经三级审批批准。 + +
+ +
+ ); +}; diff --git a/web/src/types/projectWorkflow.ts b/web/src/types/projectWorkflow.ts new file mode 100644 index 00000000..d9279659 --- /dev/null +++ b/web/src/types/projectWorkflow.ts @@ -0,0 +1,79 @@ +/** + * 项目多工作流与文档血缘类型定义 + */ + +export interface Project { + id: number; + code: string; + name: string; + status: string; + customer_name?: string; + device_type?: string; + intended_use?: string; + target_market?: string; + creator_user_id: number; + active_workflow_key: string; + workflow_selection_version: number; + created_at: string; + updated_at: string; +} + +export interface ProjectWorkflow { + id: number; + project_id: number; + workflow_key: string; + is_active: boolean; + display_order: number; + config?: Record; + created_at: string; + updated_at: string; +} + +export interface WorkflowDefinition { + key: string; + name: string; + description: string; + category: string; + enabled: boolean; + task_key: string; + prerequisites: string[]; + outputs: string[]; +} + +export interface DocumentLineage { + id: number; + project_id: number; + downstream_doc_id: string; + downstream_version: number; + upstream_doc_id: string; + upstream_version: number; + upstream_sha256: string; + is_stale: boolean; + stale_reason?: string; + stale_detected_at?: string; + created_at: string; +} + +export interface CreateProjectInput { + name: string; + code?: string; + customer_name?: string; + device_type?: string; + intended_use?: string; + target_market?: string; + initial_workflow_key?: string; +} + +export interface ActivateWorkflowInput { + workflow_key: string; + expected_version: number; + reason?: string; +} + +export interface ActivateWorkflowResult { + project_id: number; + active_workflow_key: string; + version: number; + changed_at: string; + changed_by: number; +}