feat(workflow): Phase 6 前端项目多工作流切换器、前置依赖横幅与文档血缘失效提醒
This commit is contained in:
+66
-1
@@ -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: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user