feat(workflow): Phase 6 前端项目多工作流切换器、前置依赖横幅与文档血缘失效提醒

This commit is contained in:
WeiCHEN
2026-09-10 23:56:35 +08:00
parent e9238c0ab1
commit 443a09d5b4
9 changed files with 658 additions and 2 deletions
+66 -1
View File
@@ -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<DeliverableCategory>('workflow');
const [activeDocId, setActiveDocId] = useState<string>(() => {
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<number, { activeKey: string; version: number; creatorId: number }>
>({});
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
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 = (
<>
<WorkflowPrerequisiteBanner
workflowKey={currentWfState.activeKey}
hasApprovedSystemReqs={hasApprovedSystemReqs}
onSwitchToProductManager={() => {
setProjectWorkflowsState((prev) => ({
...prev,
[currentProjectId]: { ...currentWfState, activeKey: 'product_manager' },
}));
}}
/>
<StaleDocumentNotice
lineages={projectLineages}
onTriggerRecheck={() => {
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={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* P1, P2, P3: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */}
+50
View File
@@ -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');
});
+151
View File
@@ -0,0 +1,151 @@
import {
Project,
ProjectWorkflow,
WorkflowDefinition,
DocumentLineage,
CreateProjectInput,
ActivateWorkflowInput,
ActivateWorkflowResult,
} from '../types/projectWorkflow';
const API_BASE = '';
function getAuthHeaders(): HeadersInit {
const headers: Record<string, string> = {
'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<Project[]> {
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<Project> {
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<Project> {
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<ProjectWorkflow[]> {
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<ActivateWorkflowResult> {
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<WorkflowDefinition[]> {
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<DocumentLineage[]> {
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<string, any>
): Promise<any> {
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();
}
+18 -1
View File
@@ -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<AppShellProps> = ({
@@ -39,6 +44,11 @@ export const AppShell: React.FC<AppShellProps> = ({
onOpenAdminSettings,
chatContent,
docContent,
activeWorkflowKey,
workflowVersion,
isCreator,
onWorkflowChanged,
workflowBanner,
}) => {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [leftWidth, setLeftWidth] = useState<number>(280);
@@ -97,6 +107,10 @@ export const AppShell: React.FC<AppShellProps> = ({
onToggleLeft={() => setLeftCollapsed(!leftCollapsed)}
rightCollapsed={rightCollapsed}
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
activeWorkflowKey={activeWorkflowKey}
workflowVersion={workflowVersion}
isCreator={isCreator}
onWorkflowChanged={onWorkflowChanged}
/>
<div
@@ -138,7 +152,10 @@ export const AppShell: React.FC<AppShellProps> = ({
)}
{/* 中间栏 */}
<ChatColumn currentSession={currentSession}>{chatContent}</ChatColumn>
<ChatColumn currentSession={currentSession}>
{workflowBanner}
{chatContent}
</ChatColumn>
{!rightCollapsed && (
<ResizeHandle
+20
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Sun, Moon, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Sparkles } from 'lucide-react';
import { ProjectSession } from '../../types/workbench';
import { ProjectWorkflowSwitcher } from '../workflow/ProjectWorkflowSwitcher';
interface TopbarProps {
currentSession: ProjectSession;
@@ -10,6 +11,10 @@ interface TopbarProps {
onToggleLeft: () => void;
rightCollapsed: boolean;
onToggleRight: () => void;
activeWorkflowKey?: string;
workflowVersion?: number;
isCreator?: boolean;
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
}
export const Topbar: React.FC<TopbarProps> = ({
@@ -20,6 +25,10 @@ export const Topbar: React.FC<TopbarProps> = ({
onToggleLeft,
rightCollapsed,
onToggleRight,
activeWorkflowKey = 'main',
workflowVersion = 1,
isCreator = true,
onWorkflowChanged,
}) => {
return (
<header
@@ -107,6 +116,17 @@ export const Topbar: React.FC<TopbarProps> = ({
</span>
</div>
<div style={{ width: '1px', height: '20px', background: 'var(--border)', margin: '0 4px' }} />
{/* 项目工作流切换器 */}
<ProjectWorkflowSwitcher
projectId={currentSession.projectId}
activeWorkflowKey={activeWorkflowKey}
workflowVersion={workflowVersion}
isCreator={isCreator}
onWorkflowChanged={onWorkflowChanged}
/>
{/* 项目助手提示 */}
<div
style={{
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { Workflow, Lock, CheckCircle2, AlertCircle } from 'lucide-react';
import { activateWorkflow } from '../../api/projectWorkflow';
interface ProjectWorkflowSwitcherProps {
projectId: number;
activeWorkflowKey: string;
workflowVersion: number;
isCreator: boolean;
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
}
const WORKFLOW_OPTIONS = [
{ key: 'main', label: '主协同流程', icon: '⚡' },
{ key: 'product_manager', label: '智能产品经理', icon: '📋' },
{ key: 'test_manager', label: '智能测试经理', icon: '🧪' },
];
export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = ({
projectId,
activeWorkflowKey,
workflowVersion,
isCreator,
onWorkflowChanged,
}) => {
const [switching, setSwitching] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px',
background: 'var(--bg-subtle, #f1f5f9)',
borderRadius: 'var(--r-md, 8px)',
border: '1px solid var(--border, #e2e8f0)',
}}
>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-muted, #64748b)',
padding: '0 6px',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
>
<Workflow size={13} />
工作流:
</span>
{WORKFLOW_OPTIONS.map((wf) => {
const isActive = wf.key === activeWorkflowKey;
return (
<button
key={wf.key}
onClick={() => handleSelect(wf.key)}
disabled={switching || (!isCreator && !isActive)}
title={
!isCreator
? `仅创建人可切换工作流 (当前激活: ${wf.label})`
: `点击切换至 ${wf.label}`
}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '11px',
fontWeight: isActive ? 600 : 500,
borderRadius: '6px',
border: 'none',
cursor: switching || (!isCreator && !isActive) ? 'not-allowed' : 'pointer',
background: isActive ? 'var(--bg-card, #ffffff)' : 'transparent',
color: isActive ? 'var(--brand-600, #4f46e5)' : 'var(--text-muted, #64748b)',
boxShadow: isActive ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
opacity: !isCreator && !isActive ? 0.6 : 1,
transition: 'all 0.15s ease',
}}
>
<span>{wf.icon}</span>
<span>{wf.label}</span>
{isActive && <CheckCircle2 size={12} style={{ color: 'var(--brand-600, #4f46e5)' }} />}
{!isCreator && !isActive && <Lock size={10} style={{ opacity: 0.5 }} />}
</button>
);
})}
</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',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
whiteSpace: 'nowrap',
}}
>
<AlertCircle size={12} />
{errorMsg}
</div>
)}
</div>
);
};
@@ -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<StaleDocumentNoticeProps> = ({
lineages,
onTriggerRecheck,
}) => {
const staleItem = lineages.find((l) => l.is_stale);
if (!staleItem) return null;
return (
<div
style={{
margin: '8px 16px',
padding: '8px 12px',
borderRadius: '8px',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#b91c1c' }}>
<BellRing size={16} style={{ color: '#ef4444', flexShrink: 0 }} />
<span>
<strong>上游输入已变更(血缘失效):</strong>
{staleItem.stale_reason || '上游系统需求规格书已重新生成或更新,当前验证方案可能存在覆盖缺口。'}
</span>
</div>
{onTriggerRecheck && (
<button
onClick={onTriggerRecheck}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 10px',
borderRadius: '6px',
background: '#dc2626',
color: '#ffffff',
border: 'none',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
<RefreshCw size={12} />
<span>重新执行验证方案评审</span>
</button>
)}
</div>
);
};
@@ -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<WorkflowPrerequisiteBannerProps> = ({
workflowKey,
hasApprovedSystemReqs,
onSwitchToProductManager,
}) => {
if (workflowKey !== 'test_manager' || hasApprovedSystemReqs) {
return null;
}
return (
<div
style={{
margin: '8px 16px',
padding: '8px 12px',
borderRadius: '8px',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#b45309' }}>
<AlertTriangle size={16} style={{ color: '#d97706', flexShrink: 0 }} />
<span>
<strong>前置依赖未就绪:</strong>
智能测试经理工作流强依赖同项目已批准的《系统需求规格书 (RD-SR)》。请先由产品经理完成编制并经三级审批批准。
</span>
</div>
<button
onClick={onSwitchToProductManager}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 10px',
borderRadius: '6px',
background: '#d97706',
color: '#ffffff',
border: 'none',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
<span>前往产品经理编制需求</span>
<ArrowRight size={12} />
</button>
</div>
);
};
+79
View File
@@ -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<string, any>;
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;
}