init the project
This commit is contained in:
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://localhost:8765/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+2371
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "zeekerwatchman-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"react-dropzone": "^14.2.0",
|
||||
"zustand": "^5.0.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-virtuoso": "^4.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
// ChatPanel — 右侧 LLM 对话窗口 + 增量编辑确认
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useAppStore, type ChatMessage } from '@/lib/store';
|
||||
|
||||
interface Action {
|
||||
action: string;
|
||||
rule_id?: string;
|
||||
rule?: any;
|
||||
changes?: any;
|
||||
ir_id?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
function rulesToSummary(rules: { rule_id: string; description: string; priority: string }[]): string {
|
||||
if (!rules || rules.length === 0) return '';
|
||||
const lines = ['| rule_id | priority | description |', '|---------|----------|-------------|'];
|
||||
for (const r of rules.slice(0, 30)) {
|
||||
lines.push(`| ${r.rule_id} | ${r.priority} | ${r.description.slice(0, 60)} |`);
|
||||
}
|
||||
return `### 当前 IR 规则 (${rules.length} 条)\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
export default function ChatPanel() {
|
||||
const router = useRouter();
|
||||
const store = useAppStore();
|
||||
const messages = store.chatMessages;
|
||||
const setMessages = store.setChatMessages;
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoApply, setAutoApply] = useState(true);
|
||||
const [confirmAction, setConfirmAction] = useState<{ msgIdx: number; actIdx: number; action: Action } | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, confirmAction]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
const history = messages.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
try {
|
||||
// Build context: tell the LLM exactly what's on the left side
|
||||
const rulesSummary = rulesToSummary(store.irRules);
|
||||
const tcCount = store.testcases?.length || 0;
|
||||
const tcSummary = tcCount > 0
|
||||
? store.testcases.slice(0, 5).map((c: any) =>
|
||||
`[${c.priority}] ${c.case_title?.slice(0, 60)}`).join('\n')
|
||||
: '';
|
||||
|
||||
const res = await fetch('/api/chat/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
context: {
|
||||
session_id: store.sessionId,
|
||||
current_page: store.currentPage,
|
||||
has_prd: !!store.prdText,
|
||||
has_ir: store.irRules.length > 0,
|
||||
has_cases: tcCount > 0,
|
||||
prd_id: store.prdId, ir_id: store.irId, tc_set_id: store.tcSetId,
|
||||
prd_text: store.prdText,
|
||||
ir_rules_summary: rulesSummary,
|
||||
ir_content_snippet: store.irContent.slice(0, 800),
|
||||
testcase_count: tcCount,
|
||||
testcase_summary: tcSummary,
|
||||
// Existing TC IDs (for delete/modify reference)
|
||||
existing_tc_ids: store.testcases?.map((c: any) => c.id) || [],
|
||||
// Currently selected item
|
||||
selected_case: store.selectedCase,
|
||||
selected_rule: store.selectedRule,
|
||||
},
|
||||
history,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Chat error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const reply = data.reply || '';
|
||||
const actions: Action[] = data.actions || [];
|
||||
|
||||
setMessages([...messages, { role: 'user', content: text }, {
|
||||
role: 'assistant', content: reply, actions,
|
||||
confirmed: autoApply || actions.length === 0,
|
||||
}]);
|
||||
|
||||
// Auto-apply actions immediately when toggle is on
|
||||
if (autoApply && actions.length > 0) {
|
||||
applyActions(actions);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessages([...messages, { role: 'user', content: text }, { role: 'assistant', content: `错误: ${err.message}` }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [input, loading, messages, store.irRules, store.prdText, store.irContent, store.testcases, store.currentPage]);
|
||||
|
||||
const applyActions = useCallback((acts: Action[]) => {
|
||||
const batch: { action: string; data: any }[] = [];
|
||||
for (const act of acts) {
|
||||
switch (act.action) {
|
||||
case 'navigate':
|
||||
if (act.page === 'ir-confirm') router.push(`/ir-confirm?prdId=${store.prdId}`);
|
||||
else if (act.page === 'cases') router.push(`/cases?irId=${store.irId}`);
|
||||
else if (act.page === 'home') router.push('/');
|
||||
break;
|
||||
case 'update_ir':
|
||||
store.setIr(store.irId, act.content || '');
|
||||
store.triggerIrRefresh();
|
||||
break;
|
||||
case 'add_rule':
|
||||
case 'delete_rule':
|
||||
case 'modify_rule':
|
||||
case 'add_case':
|
||||
case 'delete_case':
|
||||
case 'modify_case':
|
||||
batch.push({ action: act.action, data: act });
|
||||
break;
|
||||
case 'generate_cases':
|
||||
router.push(`/cases?irId=${act.ir_id || store.irId}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) {
|
||||
store.setPendingEdits(batch);
|
||||
}
|
||||
}, [store, router]);
|
||||
|
||||
const handleConfirmAction = (msgIdx: number, actIdx: number) => {
|
||||
const msg = messages[msgIdx];
|
||||
if (!msg?.actions) return;
|
||||
const act = msg.actions[actIdx];
|
||||
setConfirmAction({ msgIdx, actIdx, action: act });
|
||||
};
|
||||
|
||||
const handleApplyConfirmed = () => {
|
||||
if (!confirmAction) return;
|
||||
applyActions([confirmAction.action]);
|
||||
// Mark this action as confirmed
|
||||
const updated = [...messages];
|
||||
const msg = { ...updated[confirmAction.msgIdx] };
|
||||
msg.confirmed = true;
|
||||
if (msg.actions) {
|
||||
msg.actions = [...msg.actions];
|
||||
msg.actions[confirmAction.actIdx] = { ...msg.actions[confirmAction.actIdx], _applied: true };
|
||||
}
|
||||
updated[confirmAction.msgIdx] = msg;
|
||||
setMessages(updated);
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const handleRejectAction = () => {
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-100 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">AI 助手</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => { store.newSession(); }} title="新建会话,清除记忆"
|
||||
className="text-[10px] text-gray-400 hover:text-red-500">+New</button>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title={autoApply ? '直接执行,不确认' : '执行前需确认'}>
|
||||
<span className="text-[10px] text-gray-400">{autoApply ? '自动' : '确认'}</span>
|
||||
<button
|
||||
onClick={() => setAutoApply(!autoApply)}
|
||||
className={`relative h-4 w-8 rounded-full transition-colors ${autoApply ? 'bg-blue-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-transform ${autoApply ? 'left-4' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-400">可以帮你分析 PRD、修改 IR、生成用例</p>
|
||||
{store.irRules.length > 0 && (
|
||||
<p className="mt-1 text-[10px] text-blue-500">当前 IR: {store.irRules.length} 条规则</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-gray-400">
|
||||
<p>试试这些:</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{[
|
||||
'帮我分析当前 PRD 的功能点',
|
||||
'检查 IR 是否有遗漏',
|
||||
'为 P0 功能增加一条异常用例',
|
||||
'把 FEAT-SYS-FG-01 的优先级改成 P0',
|
||||
].map((hint) => (
|
||||
<button key={hint} onClick={() => setInput(hint)}
|
||||
className="block w-full rounded border border-gray-200 px-2.5 py-1.5 text-left text-xs text-gray-500 hover:bg-gray-50">
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === 'user' ? 'bg-blue-600 text-white' :
|
||||
msg.role === 'system' ? 'bg-yellow-50 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'}`}>
|
||||
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
|
||||
{msg.actions && msg.actions.length > 0 && (
|
||||
<div className="mt-2 border-t border-gray-300/50 pt-2 space-y-1">
|
||||
{msg.actions.map((act, j) => (
|
||||
<div key={j}>
|
||||
{act._applied || autoApply ? (
|
||||
<span className="text-xs text-green-600">✓ 已执行: {act.action}</span>
|
||||
) : confirmAction?.msgIdx === i && confirmAction?.actIdx === j ? (
|
||||
<div className="rounded bg-white border border-gray-200 p-2 text-xs">
|
||||
<p className="font-medium text-gray-700 mb-1">确认执行 {act.action}?</p>
|
||||
{act.rule && <p className="text-gray-500 mb-1">添加: {act.rule.description?.slice(0, 60)}</p>}
|
||||
{act.rule_id && <p className="text-gray-500 mb-1">目标: {act.rule_id}</p>}
|
||||
{act.changes && <p className="text-gray-500 mb-1">修改: {JSON.stringify(act.changes).slice(0, 80)}</p>}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button onClick={handleApplyConfirmed}
|
||||
className="rounded bg-green-600 px-3 py-1 text-white hover:bg-green-700">应用</button>
|
||||
<button onClick={handleRejectAction}
|
||||
className="rounded bg-gray-200 px-3 py-1 text-gray-700 hover:bg-gray-300">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => handleConfirmAction(i, j)}
|
||||
className="inline-block rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200">
|
||||
⚡ {act.action}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg bg-gray-100 px-4 py-2 text-sm text-gray-400">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0ms' }} />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '150ms' }} />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-100 p-3 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<textarea value={input} onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown} placeholder="输入消息..." rows={2}
|
||||
className="flex-1 resize-none rounded border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
||||
disabled={loading} />
|
||||
<button onClick={sendMessage} disabled={loading || !input.trim()}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 self-end">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 用例导出面板:格式切换 + 导出下载
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ExportFormat } from '@/lib/types';
|
||||
import { exportTestCases } from '@/lib/api';
|
||||
|
||||
interface ExportPanelProps {
|
||||
tcSetId: string;
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS: { key: ExportFormat; label: string; description: string }[] = [
|
||||
{ key: 'yaml', label: 'YAML', description: '自动化测试可解析的结构化用例' },
|
||||
{ key: 'csv', label: 'CSV 表格', description: '适合 Excel 查看与评审归档' },
|
||||
{ key: 'xmind', label: 'XMind', description: '思维导图,便于评审展示' },
|
||||
];
|
||||
|
||||
export default function ExportPanel({ tcSetId }: ExportPanelProps) {
|
||||
const [activeFormat, setActiveFormat] = useState<ExportFormat>('yaml');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportTestCases(tcSetId, activeFormat);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `testcases.${activeFormat === 'csv' ? 'csv' : activeFormat === 'yaml' ? 'yaml' : 'xmind'}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">导出测试用例</h3>
|
||||
|
||||
{/* 格式切换标签 */}
|
||||
<div className="mb-6 flex gap-2">
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.key}
|
||||
onClick={() => setActiveFormat(fmt.key)}
|
||||
className={`rounded-lg border px-4 py-3 text-sm transition
|
||||
${activeFormat === fmt.key
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="font-medium">{fmt.label}</div>
|
||||
<div className="mt-0.5 text-xs text-gray-400">{fmt.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-2 rounded bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{exporting ? '导出中...' : `下载 ${activeFormat.toUpperCase()}`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// IR 工作台:双栏布局 - 编辑器 + 思维导图 + 审计报告
|
||||
// 点击思维导图节点 → 定位编辑器对应行
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type Monaco from 'monaco-editor';
|
||||
import MindMap from './MindMap';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { updateIR } from '@/lib/api';
|
||||
|
||||
interface IrWorkspaceProps {
|
||||
irContent: string;
|
||||
auditReport?: string;
|
||||
onSave: (content: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function IrWorkspace({ irContent, auditReport, onSave, loading }: IrWorkspaceProps) {
|
||||
const [content, setContent] = useState(irContent);
|
||||
const [auditExpanded, setAuditExpanded] = useState(false);
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const monacoRef = useRef<typeof Monaco | null>(null);
|
||||
const decorationsRef = useRef<string[]>([]);
|
||||
const pendingEdits = useAppStore((s) => s.pendingEdits);
|
||||
const setPendingEdits = useAppStore((s) => s.setPendingEdits);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((ed, monaco) => {
|
||||
editorRef.current = ed;
|
||||
monacoRef.current = monaco;
|
||||
}, []);
|
||||
|
||||
// Auto-save IR to backend + update store irRules when content changes
|
||||
const irId = useAppStore((s) => s.irId);
|
||||
const setIr = useAppStore((s) => s.setIr);
|
||||
useEffect(() => {
|
||||
if (!irId || content === irContent) return;
|
||||
const timer = setTimeout(() => {
|
||||
updateIR(irId, content).catch(() => {});
|
||||
// Re-extract rules metadata for chat context
|
||||
try {
|
||||
const ir = JSON.parse(content);
|
||||
const rules = ir.rules || [];
|
||||
const rulesMeta = rules.map((r: any) => ({
|
||||
rule_id: r.rule_id || '',
|
||||
description: r.description || '',
|
||||
priority: r.priority || 'P2',
|
||||
}));
|
||||
setIr(irId, content, rulesMeta);
|
||||
} catch {}
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [content, irId]);
|
||||
|
||||
// Listen for pending edits from ChatPanel (batch)
|
||||
useEffect(() => {
|
||||
if (!pendingEdits || pendingEdits.length === 0) return;
|
||||
|
||||
try {
|
||||
const ir = JSON.parse(content);
|
||||
const rules: any[] = ir.rules || [];
|
||||
let lastTargetLine = -1;
|
||||
|
||||
for (const { action, data } of pendingEdits) {
|
||||
if (action === 'add_rule' && data.rule) {
|
||||
rules.push(data.rule);
|
||||
lastTargetLine = content.split('\n').length;
|
||||
} else if (action === 'delete_rule' && data.rule_id) {
|
||||
const idx = rules.findIndex((r: any) => r.rule_id === data.rule_id);
|
||||
if (idx >= 0) {
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(data.rule_id)) { lastTargetLine = i + 1; break; }
|
||||
}
|
||||
rules.splice(idx, 1);
|
||||
}
|
||||
} else if (action === 'modify_rule' && data.rule_id && data.changes) {
|
||||
const rule = rules.find((r: any) => r.rule_id === data.rule_id);
|
||||
if (rule) {
|
||||
Object.assign(rule, data.changes);
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(data.rule_id)) { lastTargetLine = i + 1; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newContent = JSON.stringify(ir, null, 2);
|
||||
setContent(newContent);
|
||||
setPendingEdits([]);
|
||||
|
||||
if (lastTargetLine > 0) {
|
||||
setTimeout(() => {
|
||||
const ed = editorRef.current;
|
||||
const mc = monacoRef.current;
|
||||
if (ed && mc) {
|
||||
ed.revealLineInCenter(lastTargetLine);
|
||||
if (decorationsRef.current.length > 0) ed.deltaDecorations(decorationsRef.current, []);
|
||||
decorationsRef.current = ed.deltaDecorations([], [{
|
||||
range: new mc.Range(lastTargetLine, 1, lastTargetLine, 1),
|
||||
options: { isWholeLine: true, className: 'mindmap-highlight-line', glyphMarginClassName: 'mindmap-highlight-glyph' },
|
||||
}]);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
} catch {
|
||||
setPendingEdits([]);
|
||||
}
|
||||
}, [pendingEdits]);
|
||||
|
||||
const handleNodeClick = useCallback((label: string) => {
|
||||
const ed = editorRef.current;
|
||||
if (!ed || !label) return;
|
||||
|
||||
const model = ed.getModel();
|
||||
if (!model) return;
|
||||
|
||||
// Search for the clicked label in the editor content
|
||||
const text = model.getValue();
|
||||
const lines = text.split('\n');
|
||||
const keyword = label.slice(0, 20); // first 20 chars as search key
|
||||
|
||||
let targetLine = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(keyword)) {
|
||||
targetLine = i + 1; // Monaco lines are 1-based
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetLine === -1) return;
|
||||
|
||||
// Clear old highlights
|
||||
if (decorationsRef.current.length > 0) {
|
||||
ed.deltaDecorations(decorationsRef.current, []);
|
||||
}
|
||||
|
||||
// Highlight and scroll to the line
|
||||
const mc = monacoRef.current;
|
||||
if (!mc) return;
|
||||
|
||||
const newDecorations = ed.deltaDecorations([], [
|
||||
{
|
||||
range: new mc.Range(targetLine, 1, targetLine, 1),
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className: 'mindmap-highlight-line',
|
||||
glyphMarginClassName: 'mindmap-highlight-glyph',
|
||||
},
|
||||
},
|
||||
]);
|
||||
decorationsRef.current = newDecorations;
|
||||
|
||||
ed.revealLineInCenter(targetLine);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-12rem)] flex-col gap-4">
|
||||
{/* 路径导航 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>项目</span>
|
||||
<span>/</span>
|
||||
<span className="font-medium text-gray-900">PRD</span>
|
||||
<span>/</span>
|
||||
<span className="font-medium text-blue-600">IR (当前)</span>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSave(content)}
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
保存并生成用例
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">点击思维导图节点可定位</span>
|
||||
</div>
|
||||
|
||||
{/* 双栏内容 */}
|
||||
<div className="flex flex-1 gap-4 min-h-0">
|
||||
{/* 左侧:编辑器 */}
|
||||
<div className="w-1/2 rounded-lg border border-gray-200 bg-white flex flex-col">
|
||||
<div className="border-b border-gray-100 px-4 py-2 text-xs font-medium text-gray-500 shrink-0">
|
||||
IR 编辑器
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
value={content}
|
||||
onChange={(value) => setContent(value || '')}
|
||||
onMount={handleEditorMount}
|
||||
theme="light"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
glyphMargin: true,
|
||||
}}
|
||||
loading={<div className="flex h-full items-center justify-center text-sm text-gray-400">加载编辑器中...</div>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:思维导图预览 */}
|
||||
<div className="w-1/2 rounded-lg border border-gray-200 bg-white flex flex-col">
|
||||
<div className="border-b border-gray-100 px-4 py-2 text-xs font-medium text-gray-500 shrink-0">
|
||||
思维导图预览 — 点击节点定位
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto p-2">
|
||||
<MindMap irContent={content} onNodeClick={handleNodeClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部抽屉:审计报告 */}
|
||||
{auditReport && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<button
|
||||
onClick={() => setAuditExpanded(!auditExpanded)}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700">IR 审计报告</span>
|
||||
<span className="text-xs text-blue-600">{auditExpanded ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
{auditExpanded && (
|
||||
<div className="border-t border-gray-100 px-4 py-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono">{auditReport}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlight style */}
|
||||
<style jsx global>{`
|
||||
.mindmap-highlight-line {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.mindmap-highlight-glyph {
|
||||
background: #3b82f6;
|
||||
width: 3px !important;
|
||||
margin-left: 3px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 全局布局 — 左侧主内容 + 右侧 AI 对话窗口
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import ChatPanel from './ChatPanel';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const router = useRouter();
|
||||
const setCurrentPage = useAppStore((s) => s.setCurrentPage);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(router.pathname.replace('/', '') || 'home');
|
||||
}, [router.pathname, setCurrentPage]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-50">
|
||||
{/* Left: Main Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="shrink-0 border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-gray-900">ZeekerWatchman</h1>
|
||||
<nav className="flex gap-4 text-sm text-gray-600">
|
||||
<a href="/" className="hover:text-gray-900">主页</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Right: Chat Panel */}
|
||||
<div className="w-[380px] shrink-0">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// 思维导图组件:解析 IR (YAML 或 JSON),渲染为可视化树形图
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface TreeNode {
|
||||
label: string;
|
||||
children: TreeNode[];
|
||||
priority?: string;
|
||||
subtreeHeight?: number;
|
||||
_lines?: string[];
|
||||
}
|
||||
|
||||
interface MindMapProps {
|
||||
irContent: string;
|
||||
onNodeClick?: (label: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IR Parsers
|
||||
// ============================================================================
|
||||
|
||||
function parseIr(irContent: string): TreeNode | null {
|
||||
if (!irContent || !irContent.trim()) return null;
|
||||
|
||||
const trimmed = irContent.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
return parseJsonIr(JSON.parse(trimmed));
|
||||
} catch { /* fall through to YAML */ }
|
||||
}
|
||||
return parseYamlIr(trimmed);
|
||||
}
|
||||
|
||||
function parseJsonIr(data: any): TreeNode | null {
|
||||
// Root label priority: feature_name > meta.prd_title > feature > 'IR'
|
||||
const rootLabel =
|
||||
data.feature_name ||
|
||||
data.meta?.prd_title ||
|
||||
data.feature ||
|
||||
'IR';
|
||||
|
||||
const rules: any[] = data.rules || [];
|
||||
const children: TreeNode[] = [];
|
||||
|
||||
// Group rules by section number (from rule_id)
|
||||
const groups: Map<string, any[]> = new Map();
|
||||
for (const rule of rules) {
|
||||
const cat = extractSection(rule);
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat)!.push(rule);
|
||||
}
|
||||
|
||||
for (const [category, groupRules] of groups) {
|
||||
const ruleNodes: TreeNode[] = [];
|
||||
for (const rule of groupRules.slice(0, 10)) {
|
||||
const desc = rule.description || rule.rule_id || '';
|
||||
const detailChildren: TreeNode[] = [];
|
||||
|
||||
// Show key trigger conditions
|
||||
const conditions = rule.trigger?.conditions || [];
|
||||
for (const cond of conditions.slice(0, 2)) {
|
||||
const unit = cond.unit || '';
|
||||
const val = cond.value !== undefined ? cond.value : '';
|
||||
const op = cond.operator || '';
|
||||
detailChildren.push({
|
||||
label: `${cond.signal || ''} ${op} ${val}${unit}`,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Show first action
|
||||
const actions = rule.actions || [];
|
||||
for (const act of actions.slice(0, 1)) {
|
||||
const icon = act.type === 'user_interaction' ? '💬' : '⚙';
|
||||
detailChildren.push({
|
||||
label: `${icon} ${act.description || ''}`,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
ruleNodes.push({
|
||||
label: truncate(desc, 30),
|
||||
priority: rule.priority,
|
||||
children: detailChildren,
|
||||
});
|
||||
}
|
||||
|
||||
children.push({
|
||||
label: truncate(category, 22),
|
||||
priority: groupRules[0]?.priority,
|
||||
children: ruleNodes,
|
||||
});
|
||||
}
|
||||
|
||||
return { label: rootLabel, children };
|
||||
}
|
||||
|
||||
/** Extract section number from rule_id for grouping by PRD chapter */
|
||||
function extractSection(rule: any): string {
|
||||
// IR-ZEEKER-4.2.1-001 → "4.2.1"
|
||||
const m = rule.rule_id?.match(/IR-\w+-([\d.]+)-\d+$/);
|
||||
if (m) return `§ ${m[1]}`;
|
||||
// Fallback: first word of description
|
||||
const desc = rule.description || '';
|
||||
return desc.slice(0, 12) + (desc.length > 12 ? '…' : '');
|
||||
}
|
||||
|
||||
function parseYamlIr(yaml: string): TreeNode | null {
|
||||
const lines = yaml.split('\n');
|
||||
let rootLabel = 'IR';
|
||||
const modules: Map<string, { name: string; features: Map<string, { name: string; priority: string; details: string[] }> }> = new Map();
|
||||
let currentModule = '';
|
||||
let currentFeature = '';
|
||||
let currentPriority = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// prd_title in meta section
|
||||
if (trimmed.startsWith('prd_title:')) {
|
||||
rootLabel = trimmed.split(':').slice(1).join(':').trim().replace(/['"]/g, '') || rootLabel;
|
||||
}
|
||||
// Also check for title variations
|
||||
if (trimmed.startsWith('title:') && !trimmed.startsWith('prd_title:')) {
|
||||
const maybeTitle = trimmed.split(':').slice(1).join(':').trim().replace(/['"]/g, '');
|
||||
if (maybeTitle) rootLabel = maybeTitle;
|
||||
}
|
||||
|
||||
// Module detection
|
||||
if (trimmed.startsWith('- module:') || trimmed.startsWith('module:')) {
|
||||
currentModule = trimmed.split(':').slice(1).join(':').trim().replace(/['"]/g, '');
|
||||
currentFeature = '';
|
||||
if (!modules.has(currentModule)) {
|
||||
modules.set(currentModule, { name: currentModule, features: new Map() });
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('feature_name:')) {
|
||||
currentFeature = trimmed.split(':').slice(1).join(':').trim().replace(/['"]/g, '');
|
||||
if (currentModule && !modules.has(currentModule)) {
|
||||
modules.set(currentModule, { name: currentModule, features: new Map() });
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('priority:')) {
|
||||
currentPriority = trimmed.split(':').slice(1).join(':').trim().replace(/['"]/g, '');
|
||||
if (currentModule && currentFeature) {
|
||||
const mod = modules.get(currentModule);
|
||||
if (mod && !mod.features.has(currentFeature)) {
|
||||
mod.features.set(currentFeature, { name: currentFeature, priority: currentPriority, details: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFeature && currentModule && trimmed.startsWith('- ') && !trimmed.startsWith('- module')) {
|
||||
const mod = modules.get(currentModule);
|
||||
if (mod) {
|
||||
const feat = mod.features.get(currentFeature);
|
||||
if (feat) {
|
||||
const detail = trimmed.replace(/^- /, '');
|
||||
if (detail.length < 80) feat.details.push(detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const children: TreeNode[] = [];
|
||||
modules.forEach((mod) => {
|
||||
const featureNodes: TreeNode[] = [];
|
||||
mod.features.forEach((feat) => {
|
||||
featureNodes.push({
|
||||
label: truncate(feat.name, 22),
|
||||
priority: feat.priority,
|
||||
children: feat.details.slice(0, 2).map((d) => ({ label: truncate(d, 25), children: [] })),
|
||||
});
|
||||
});
|
||||
children.push({ label: truncate(mod.name, 18), children: featureNodes });
|
||||
});
|
||||
|
||||
return { label: rootLabel, children };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Layout constants & helpers
|
||||
// ============================================================================
|
||||
|
||||
const LINE_HEIGHT = 14;
|
||||
const NODE_PAD_Y = 10;
|
||||
const H_GAP = 55;
|
||||
const V_GAP = 6;
|
||||
const ROOT_W = 260;
|
||||
const MAX_NODE_W = 200;
|
||||
const MIN_NODE_W = 100;
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (!s) return '';
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||
}
|
||||
|
||||
function priorityColor(p: string | undefined): string {
|
||||
if (!p) return '#9ca3af';
|
||||
if (p.startsWith('P0')) return '#ef4444';
|
||||
if (p.startsWith('P1')) return '#f59e0b';
|
||||
return '#3b82f6';
|
||||
}
|
||||
|
||||
function estimateNodeWidth(label: string, level: number): number {
|
||||
if (level === 0) return ROOT_W;
|
||||
const cjk = (label.match(/[一-鿿 -〿]/g) || []).length;
|
||||
const ascii = label.length - cjk;
|
||||
const w = cjk * 13 + ascii * 7 + 28;
|
||||
return Math.max(MIN_NODE_W, Math.min(w, MAX_NODE_W));
|
||||
}
|
||||
|
||||
/** Split a label into wrapped lines that fit within nodeWidth pixels. */
|
||||
function wrapLabel(label: string, nodeWidth: number, fontSize: number): string[] {
|
||||
if (!label) return [''];
|
||||
// Approximate chars per line: CJK ~fontSize px, ASCII ~fontSize*0.55 px
|
||||
const cjkWidth = fontSize;
|
||||
const asciiWidth = fontSize * 0.55;
|
||||
const availW = nodeWidth - 20; // 10px padding each side
|
||||
|
||||
const lines: string[] = [];
|
||||
let current = '';
|
||||
let currentW = 0;
|
||||
|
||||
for (const ch of label) {
|
||||
const chW = /[一-鿿 -〿]/.test(ch) ? cjkWidth : asciiWidth;
|
||||
if (currentW + chW > availW && current.length > 0) {
|
||||
lines.push(current);
|
||||
current = ch;
|
||||
currentW = chW;
|
||||
} else {
|
||||
current += ch;
|
||||
currentW += chW;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
|
||||
// Max 3 lines, truncate last with …
|
||||
if (lines.length > 3) {
|
||||
lines.splice(3);
|
||||
if (lines[2].length > 3) {
|
||||
lines[2] = lines[2].slice(0, -3) + '…';
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function nodeHeight(lines: number): number {
|
||||
return lines * LINE_HEIGHT + NODE_PAD_Y * 2;
|
||||
}
|
||||
|
||||
function calcSubtreeHeight(node: TreeNode): number {
|
||||
const lines = node._lines?.length || 1;
|
||||
const myH = nodeHeight(lines);
|
||||
if (node.children.length === 0) {
|
||||
node.subtreeHeight = myH;
|
||||
return myH;
|
||||
}
|
||||
let total = 0;
|
||||
for (const child of node.children) {
|
||||
total += calcSubtreeHeight(child);
|
||||
}
|
||||
total += (node.children.length - 1) * V_GAP;
|
||||
node.subtreeHeight = Math.max(total, myH);
|
||||
return node.subtreeHeight;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TreeNodeView — recursive SVG rendering with proper subtree-based layout
|
||||
// ============================================================================
|
||||
|
||||
function TreeNodeView({ node, x, y, level, onHover, onNodeClick }: {
|
||||
node: TreeNode; x: number; y: number; level: number;
|
||||
onHover: (label: string | null) => void;
|
||||
onNodeClick?: (label: string) => void;
|
||||
}) {
|
||||
const fontSize = level === 0 ? 13 : 11;
|
||||
const nodeW = estimateNodeWidth(node.label, level);
|
||||
const lines = wrapLabel(node.label, nodeW, fontSize);
|
||||
node._lines = lines;
|
||||
const nh = nodeHeight(lines.length);
|
||||
const children = node.children;
|
||||
|
||||
// Position children centered vertically within this node's span
|
||||
let childCursorY = y - (node.subtreeHeight || nh) / 2;
|
||||
if (children.length > 0) {
|
||||
childCursorY = y - ((children.reduce((s, c) => s + (c.subtreeHeight || nh), 0) + (children.length - 1) * V_GAP) / 2);
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Node background */}
|
||||
<rect
|
||||
x={x} y={y - nh / 2} width={nodeW} height={nh} rx={6}
|
||||
fill={level === 0 ? '#1e40af' : level === 1 ? '#f0f9ff' : '#ffffff'}
|
||||
stroke={level === 0 ? '#1e3a8a' : priorityColor(node.priority)}
|
||||
strokeWidth={level === 0 ? 2 : 1.2}
|
||||
onMouseEnter={() => onHover(node.label)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => onNodeClick?.(node.label)}
|
||||
style={{ cursor: 'pointer', transition: 'fill 0.15s' }}
|
||||
/>
|
||||
{/* Priority badge */}
|
||||
{node.priority && level > 0 && (
|
||||
<>
|
||||
<rect x={x + nodeW - 26} y={y - nh / 2 + 2} width={22} height={13} rx={3} fill={priorityColor(node.priority)} />
|
||||
<text x={x + nodeW - 15} y={y - nh / 2 + 11} textAnchor="middle" fill="white" fontSize={8} fontWeight="bold">{node.priority}</text>
|
||||
</>
|
||||
)}
|
||||
{/* Multi-line label */}
|
||||
<text
|
||||
x={x + 10}
|
||||
y={y - (lines.length - 1) * LINE_HEIGHT / 2 + 4}
|
||||
fill={level === 0 ? 'white' : '#1f2937'}
|
||||
fontSize={fontSize}
|
||||
fontWeight={level <= 1 ? 600 : 400}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<tspan key={i} x={x + 10} dy={i === 0 ? 0 : LINE_HEIGHT}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
|
||||
{/* Children */}
|
||||
{children.map((child, i) => {
|
||||
const childH = child.subtreeHeight || nodeHeight(1);
|
||||
const childCenterY = childCursorY + childH / 2;
|
||||
const childX = x + nodeW + H_GAP;
|
||||
|
||||
childCursorY += childH + (i < children.length - 1 ? V_GAP : 0);
|
||||
|
||||
return (
|
||||
<g key={`${child.label}-${i}`}>
|
||||
<path
|
||||
d={`M ${x + nodeW} ${y} C ${x + nodeW + H_GAP * 0.4} ${y}, ${childX - H_GAP * 0.4} ${childCenterY}, ${childX} ${childCenterY}`}
|
||||
fill="none" stroke="#d1d5db" strokeWidth={1.2}
|
||||
/>
|
||||
<TreeNodeView node={child} x={childX} y={childCenterY} level={level + 1} onHover={onHover} onNodeClick={onNodeClick} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main component
|
||||
// ============================================================================
|
||||
|
||||
export default function MindMap({ irContent, onNodeClick }: MindMapProps) {
|
||||
const tree = useMemo(() => parseIr(irContent), [irContent]);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
if (!tree || tree.children.length === 0) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-gray-400">暂无可解析的 IR 结构</div>;
|
||||
}
|
||||
|
||||
// Calculate layout dimensions
|
||||
calcSubtreeHeight(tree);
|
||||
const totalH = (tree.subtreeHeight || 200) + 60;
|
||||
const totalW = 300 + tree.children.length * 280;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<svg width={totalW} height={totalH} className="min-w-full">
|
||||
<TreeNodeView node={tree} x={20} y={totalH / 2} level={0} onHover={setHovered} onNodeClick={onNodeClick} />
|
||||
</svg>
|
||||
{hovered && (
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 rounded bg-gray-800 px-3 py-1.5 text-xs text-white shadow max-w-sm truncate">
|
||||
{hovered}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// 水管式流水线进度条 — A→B→C 三阶段带流动动画
|
||||
|
||||
'use client';
|
||||
|
||||
interface PipelineEvent {
|
||||
stage: number;
|
||||
status: 'running' | 'done' | 'error';
|
||||
message: string;
|
||||
elapsed?: number;
|
||||
stage_total: number;
|
||||
detail?: string;
|
||||
completed?: number;
|
||||
total?: number;
|
||||
rules_so_far?: number;
|
||||
total_rules?: number;
|
||||
estimated_total?: number;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: PipelineEvent[];
|
||||
error: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const STAGES = [
|
||||
{ id: 1, label: '语义索引', icon: '🔍' },
|
||||
{ id: 2, label: 'IR 提取', icon: '📝' },
|
||||
{ id: 3, label: '合并审计', icon: '📊' },
|
||||
];
|
||||
|
||||
export default function PipelineProgress({ events, error, done }: Props) {
|
||||
const latest = events[events.length - 1];
|
||||
const estimatedTotal = latest?.estimated_total || 60;
|
||||
|
||||
function stageState(s: number): 'pending' | 'running' | 'done' | 'error' {
|
||||
const evts = events.filter((e) => e.stage === s);
|
||||
const last = evts[evts.length - 1];
|
||||
if (!last) return 'pending';
|
||||
if (last.status === 'error') return 'error';
|
||||
if (last.status === 'done') return 'done';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function stageElapsed(s: number): string {
|
||||
const doneEvent = events.find((e) => e.stage === s && e.status === 'done');
|
||||
if (doneEvent?.elapsed) return `${doneEvent.elapsed}s`;
|
||||
const lastRun = events.filter((e) => e.stage === s).pop();
|
||||
if (lastRun?.elapsed) return `${lastRun.elapsed.toFixed(0)}s`;
|
||||
return '';
|
||||
}
|
||||
|
||||
const totalElapsed = events.reduce((sum, e) => sum + (e.elapsed || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
{/* Header row */}
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{done ? '生成完成' : error ? '生成失败' : '正在生成 IR'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{!done && !error && (
|
||||
<span className="text-xs text-gray-400">
|
||||
预计 {estimatedTotal.toFixed(0)}s · 已过 {totalElapsed.toFixed(0)}s
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className="text-xs font-medium text-green-600">
|
||||
{totalElapsed.toFixed(0)}s · {latest?.total_rules || 0} 条规则
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline: stage nodes + connecting pipes */}
|
||||
<div className="flex items-center justify-center gap-0 px-4">
|
||||
{STAGES.map((stage, i) => (
|
||||
<div key={stage.id} className="flex items-center">
|
||||
{/* Stage node */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-16 w-16 items-center justify-center rounded-full text-2xl shadow-sm transition-all duration-500
|
||||
${stageState(stage.id) === 'done' ? 'bg-green-100 ring-2 ring-green-400' :
|
||||
stageState(stage.id) === 'running' ? 'bg-blue-100 ring-2 ring-blue-400 scale-110 animate-pulse' :
|
||||
stageState(stage.id) === 'error' ? 'bg-red-100 ring-2 ring-red-400' :
|
||||
'bg-gray-100 ring-1 ring-gray-300'}`}
|
||||
>
|
||||
<span className={stageState(stage.id) === 'pending' ? 'opacity-30' : ''}>
|
||||
{stageState(stage.id) === 'done' ? '✓' :
|
||||
stageState(stage.id) === 'error' ? '✗' :
|
||||
stage.icon}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`mt-2 text-xs font-medium whitespace-nowrap
|
||||
${stageState(stage.id) === 'done' ? 'text-green-700' :
|
||||
stageState(stage.id) === 'running' ? 'text-blue-700' :
|
||||
stageState(stage.id) === 'error' ? 'text-red-700' :
|
||||
'text-gray-400'}`}
|
||||
>
|
||||
{stage.label}
|
||||
</span>
|
||||
{stageElapsed(stage.id) && (
|
||||
<span className="text-[10px] text-gray-400 mt-0.5">{stageElapsed(stage.id)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pipe connector */}
|
||||
{i < STAGES.length - 1 && (
|
||||
<div className="mx-2 flex h-2.5 flex-1 items-center" style={{ minWidth: '80px' }}>
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
||||
{/* Done pipe: full green */}
|
||||
{stageState(stage.id) === 'done' && (
|
||||
<div className="absolute inset-0 rounded-full bg-green-400 transition-all duration-700" />
|
||||
)}
|
||||
{/* Running pipe: animated blue bar */}
|
||||
{stageState(stage.id) === 'running' && (
|
||||
<div className="absolute inset-y-0 rounded-full bg-blue-400/60 animate-pipe-flow" />
|
||||
)}
|
||||
{/* Running pipe: dashing dots */}
|
||||
{stageState(stage.id) === 'running' && (
|
||||
<>
|
||||
<div className="absolute left-0 top-1/2 h-1 w-1.5 -translate-y-1/2 rounded-full bg-blue-500 animate-pipe-dash" />
|
||||
<div className="absolute left-0 top-1/2 h-1 w-1.5 -translate-y-1/2 rounded-full bg-blue-500 animate-pipe-dash" style={{ animationDelay: '0.5s' }} />
|
||||
<div className="absolute left-0 top-1/2 h-1 w-1.5 -translate-y-1/2 rounded-full bg-blue-500 animate-pipe-dash" style={{ animationDelay: '1s' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stage 2 progress bar */}
|
||||
{stageState(2) === 'running' && latest?.total && (
|
||||
<div className="mt-5">
|
||||
<div className="mb-1.5 flex justify-between text-[10px] text-gray-400">
|
||||
<span>{latest.detail || '处理中...'}</span>
|
||||
<span>{latest.completed || 0}/{latest.total} 单元</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.max(((latest.completed || 0) / latest.total) * 100, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current status text */}
|
||||
{!done && !error && latest && (
|
||||
<p className="mt-3 text-center text-xs text-gray-400">{latest.message}</p>
|
||||
)}
|
||||
{done && (
|
||||
<p className="mt-3 text-center text-xs text-green-600">全部完成,正在加载工作台...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 流水线进度面板 — 实时展示 IR 生成的 3 个阶段
|
||||
|
||||
'use client';
|
||||
|
||||
interface PipelineEvent {
|
||||
stage: number;
|
||||
status: 'running' | 'done' | 'error';
|
||||
message: string;
|
||||
elapsed?: number;
|
||||
stage_total: number;
|
||||
detail?: string;
|
||||
completed?: number;
|
||||
total?: number;
|
||||
rules_so_far?: number;
|
||||
n_units?: number;
|
||||
estimated_total?: number;
|
||||
total_rules?: number;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
interface ProgressOverlayProps {
|
||||
events: PipelineEvent[];
|
||||
error: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<number, string> = {
|
||||
1: '语义索引',
|
||||
2: 'IR 提取',
|
||||
3: '合并审计',
|
||||
};
|
||||
|
||||
const STAGE_ICONS: Record<number, string> = {
|
||||
1: '🔍',
|
||||
2: '📝',
|
||||
3: '📊',
|
||||
};
|
||||
|
||||
export default function ProgressOverlay({ events, error, done }: ProgressOverlayProps) {
|
||||
const stages = [1, 2, 3];
|
||||
const latest = events[events.length - 1];
|
||||
const estimatedTotal = latest?.estimated_total || 60;
|
||||
|
||||
function stageState(s: number) {
|
||||
// Find the latest event for this stage
|
||||
const stageEvents = events.filter((e) => e.stage === s);
|
||||
const last = stageEvents[stageEvents.length - 1];
|
||||
if (!last) return 'pending';
|
||||
if (last.status === 'error') return 'error';
|
||||
if (last.status === 'done') return 'done';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function stageElapsed(s: number): string {
|
||||
const doneEvent = events.find((e) => e.stage === s && e.status === 'done');
|
||||
if (doneEvent?.elapsed) return `${doneEvent.elapsed}s`;
|
||||
const runEvent = events.filter((e) => e.stage === s).pop();
|
||||
if (runEvent?.elapsed) return `${runEvent.elapsed}s...`;
|
||||
return '-';
|
||||
}
|
||||
|
||||
const totalElapsed = events.reduce((sum, e) => sum + (e.elapsed || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-8 shadow-2xl">
|
||||
{/* Title */}
|
||||
<h2 className="text-center text-lg font-bold text-gray-900">
|
||||
{done ? 'IR 生成完成' : error ? '生成失败' : '正在生成 IR'}
|
||||
</h2>
|
||||
|
||||
{/* Total progress estimate */}
|
||||
{!done && !error && (
|
||||
<p className="mt-1 text-center text-xs text-gray-400">
|
||||
预计总耗时 {estimatedTotal.toFixed(0)}s · 已过 {totalElapsed.toFixed(0)}s
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stages */}
|
||||
<div className="mt-6 space-y-4">
|
||||
{stages.map((s) => {
|
||||
const state = stageState(s);
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-3">
|
||||
{/* Status icon */}
|
||||
<div className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm
|
||||
${state === 'done' ? 'bg-green-100 text-green-600' :
|
||||
state === 'running' ? 'bg-blue-100 text-blue-600 animate-pulse' :
|
||||
state === 'error' ? 'bg-red-100 text-red-600' :
|
||||
'bg-gray-100 text-gray-300'}`}
|
||||
>
|
||||
{state === 'done' ? '✓' :
|
||||
state === 'running' ? STAGE_ICONS[s] :
|
||||
state === 'error' ? '✗' : STAGE_ICONS[s]}
|
||||
</div>
|
||||
|
||||
{/* Stage info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-medium
|
||||
${state === 'done' ? 'text-green-700' :
|
||||
state === 'running' ? 'text-blue-700' :
|
||||
state === 'error' ? 'text-red-700' : 'text-gray-400'}`}
|
||||
>
|
||||
Stage {s}: {STAGE_LABELS[s]}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{stageElapsed(s)}</span>
|
||||
</div>
|
||||
|
||||
{/* Detail message */}
|
||||
{state !== 'pending' && (
|
||||
<p className={`mt-0.5 text-xs truncate
|
||||
${state === 'error' ? 'text-red-500' : 'text-gray-500'}`}
|
||||
>
|
||||
{events.filter((e) => e.stage === s).pop()?.detail || ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar for Stage 2 */}
|
||||
{s === 2 && state === 'running' && latest?.total && (
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-500"
|
||||
style={{ width: `${((latest.completed || 0) / latest.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rule count for Stage 3 */}
|
||||
{s === 3 && state === 'done' && latest?.total_rules !== undefined && (
|
||||
<p className="mt-0.5 text-xs font-medium text-green-600">
|
||||
最终生成 {latest.total_rules} 条规则
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done message */}
|
||||
{done && (
|
||||
<p className="mt-4 text-center text-sm text-gray-500">即将跳转到 IR 工作台...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 测试用例富表格:可编辑、可排序、可筛选、行内修改
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { TestCase } from '@/lib/types';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
|
||||
interface TestCaseTableProps {
|
||||
cases: TestCase[];
|
||||
loading?: boolean;
|
||||
onUpdate?: (updated: TestCase[]) => void;
|
||||
}
|
||||
|
||||
type EditCell = { id: string; field: keyof TestCase } | null;
|
||||
|
||||
function EditableCell({ value, editing, onSave, className, asTextarea }: {
|
||||
value: string; editing: boolean; onSave: (v: string) => void;
|
||||
className?: string; asTextarea?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
if (!editing) return <span className={className}>{value}</span>;
|
||||
const Input = asTextarea ? 'textarea' : 'input';
|
||||
return (
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e: any) => setDraft(e.target.value)}
|
||||
onBlur={() => { if (draft !== value) onSave(draft); }}
|
||||
onKeyDown={(e: any) => { if (e.key === 'Enter' && !asTextarea) { e.preventDefault(); if (draft !== value) onSave(draft); } }}
|
||||
className="w-full rounded border border-blue-300 px-1 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
rows={asTextarea ? 3 : 1}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TestCaseTable({ cases, loading, onUpdate }: TestCaseTableProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const setSelectedCase = useAppStore((s) => s.setSelectedCase);
|
||||
const selectedCase = useAppStore((s) => s.selectedCase);
|
||||
const [editCell, setEditCell] = useState<EditCell>(null);
|
||||
|
||||
const handleRowClick = (tc: TestCase) => {
|
||||
setSelectedCase({
|
||||
id: tc.id, title: tc.case_title,
|
||||
steps: tc.steps, expected: tc.expected_result, priority: tc.priority,
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (id: string, field: keyof TestCase) => {
|
||||
setEditCell({ id, field });
|
||||
};
|
||||
|
||||
const saveEdit = (tc: TestCase, field: keyof TestCase, value: string) => {
|
||||
const updated = cases.map((c) => c.id === tc.id ? { ...c, [field]: value } : c);
|
||||
onUpdate?.(updated);
|
||||
setEditCell(null);
|
||||
};
|
||||
|
||||
const isEditing = (id: string, field: keyof TestCase) =>
|
||||
editCell?.id === id && editCell?.field === field;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-64 items-center justify-center text-sm text-gray-400">正在生成测试用例...</div>;
|
||||
}
|
||||
if (cases.length === 0) {
|
||||
return <div className="flex h-64 items-center justify-center text-sm text-gray-400">暂无用例数据</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">模块</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">用例标题</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">前置条件</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">步骤</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">预期结果</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">优先级</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">标签</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{cases.map((tc) => (
|
||||
<tr key={tc.id}
|
||||
onClick={() => handleRowClick(tc)}
|
||||
className={`cursor-pointer hover:bg-gray-50 ${selectedCase?.id === tc.id ? 'bg-blue-50 ring-1 ring-blue-200' : ''}`}>
|
||||
{/* Module */}
|
||||
<td className="px-3 py-2" onClick={(e) => { e.stopPropagation(); startEdit(tc.id, 'module'); }}>
|
||||
<EditableCell value={tc.module} editing={isEditing(tc.id, 'module')}
|
||||
onSave={(v) => saveEdit(tc, 'module', v)} className="text-gray-900" />
|
||||
</td>
|
||||
{/* ID */}
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">{tc.id}</td>
|
||||
{/* Case title */}
|
||||
<td className="px-3 py-2" onClick={(e) => { e.stopPropagation(); startEdit(tc.id, 'case_title'); }}>
|
||||
<EditableCell value={tc.case_title} editing={isEditing(tc.id, 'case_title')}
|
||||
onSave={(v) => saveEdit(tc, 'case_title', v)} className="text-gray-900" />
|
||||
</td>
|
||||
{/* Preconditions */}
|
||||
<td className="px-3 py-2 max-w-[120px]" onClick={(e) => { e.stopPropagation(); startEdit(tc.id, 'preconditions'); }}>
|
||||
<EditableCell value={tc.preconditions} editing={isEditing(tc.id, 'preconditions')}
|
||||
onSave={(v) => saveEdit(tc, 'preconditions', v)} className="text-gray-500 truncate block" />
|
||||
</td>
|
||||
{/* Steps */}
|
||||
<td className="px-3 py-2 max-w-[180px]" onClick={(e) => { e.stopPropagation(); startEdit(tc.id, 'steps'); }}>
|
||||
<EditableCell value={tc.steps} editing={isEditing(tc.id, 'steps')}
|
||||
onSave={(v) => saveEdit(tc, 'steps', v)} className="text-gray-500 truncate block" asTextarea />
|
||||
</td>
|
||||
{/* Expected */}
|
||||
<td className="px-3 py-2 max-w-[150px]" onClick={(e) => { e.stopPropagation(); startEdit(tc.id, 'expected_result'); }}>
|
||||
<EditableCell value={tc.expected_result} editing={isEditing(tc.id, 'expected_result')}
|
||||
onSave={(v) => saveEdit(tc, 'expected_result', v)} className="text-gray-500 truncate block" />
|
||||
</td>
|
||||
{/* Priority */}
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
{isEditing(tc.id, 'priority') ? (
|
||||
<select
|
||||
value={tc.priority}
|
||||
onChange={(e) => saveEdit(tc, 'priority', e.target.value)}
|
||||
onBlur={() => setEditCell(null)}
|
||||
autoFocus
|
||||
className="rounded border border-blue-300 px-1 py-0.5 text-sm"
|
||||
>
|
||||
<option>P0</option><option>P1</option><option>P2</option>
|
||||
</select>
|
||||
) : (
|
||||
<span onClick={() => startEdit(tc.id, 'priority')}
|
||||
className={`cursor-pointer rounded px-2 py-0.5 text-xs font-medium
|
||||
${tc.priority === 'P0' ? 'bg-red-100 text-red-700' : ''}
|
||||
${tc.priority === 'P1' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
${tc.priority === 'P2' ? 'bg-blue-100 text-blue-700' : ''}`}>
|
||||
{tc.priority}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Tags */}
|
||||
<td className="px-3 py-2" onClick={(e) => { e.stopPropagation(); startEdit(tc.id, 'tags' as any); }}>
|
||||
<EditableCell value={(tc.tags || []).join(', ')} editing={isEditing(tc.id, 'tags' as any)}
|
||||
onSave={(v) => saveEdit(tc, 'tags' as any, v.split(',').map((t: string) => t.trim()).filter(Boolean).join(', '))}
|
||||
className="text-xs text-gray-400" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// PRD 上传与 Skill 选择组件
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
interface UploadZoneProps {
|
||||
onUpload: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function UploadZone({ onUpload, disabled }: UploadZoneProps) {
|
||||
const [mode, setMode] = useState<'file' | 'paste'>('file');
|
||||
const [pastedText, setPastedText] = useState('');
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
onUpload(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[onUpload]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
disabled,
|
||||
accept: {
|
||||
'text/*': ['.md', '.txt'],
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setMode('file')}
|
||||
className={`rounded px-4 py-2 text-sm font-medium transition
|
||||
${mode === 'file' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
文件上传
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('paste')}
|
||||
className={`rounded px-4 py-2 text-sm font-medium transition
|
||||
${mode === 'paste' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
文本粘贴
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'file' ? (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-16 text-center transition
|
||||
${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white hover:border-gray-400'}
|
||||
${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<p className="text-lg text-gray-600">
|
||||
{isDragActive
|
||||
? '松开以上传文件...'
|
||||
: '将 PRD 文件拖拽到此处,或点击选择'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
支持 .md, .txt, .docx, .pdf (最大 10MB)
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-300 bg-white p-4">
|
||||
<textarea
|
||||
className="h-64 w-full resize-none rounded border border-gray-200 p-4 text-sm text-gray-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="在此粘贴 PRD 文本内容..."
|
||||
value={pastedText}
|
||||
onChange={(e) => setPastedText(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (pastedText.trim()) {
|
||||
const blob = new Blob([pastedText], { type: 'text/plain' });
|
||||
const file = new File([blob], 'pasted-prd.txt', { type: 'text/plain' });
|
||||
onUpload(file);
|
||||
}
|
||||
}}
|
||||
disabled={!pastedText.trim() || disabled}
|
||||
className="rounded bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
提交文本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// IR 状态管理 Hook
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { IRVersion, IRValidation } from '@/lib/types';
|
||||
import { generateIR, getIR, validateIR } from '@/lib/api';
|
||||
|
||||
export function useIr() {
|
||||
const [ir, setIr] = useState<IRVersion | null>(null);
|
||||
const [validation, setValidation] = useState<IRValidation | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generate = useCallback(async (prdId: string, skillName: string = 'default') => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await generateIR(prdId, skillName);
|
||||
setIr(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Generation failed');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchIr = useCallback(async (irId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getIR(irId);
|
||||
setIr(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fetch failed');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const validate = useCallback(async (irId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await validateIR(irId);
|
||||
setValidation(result.validation);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Validation failed');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { ir, validation, loading, error, generate, fetchIr, validate };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// PRD 状态管理 Hook
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { PRDVersion } from '@/lib/types';
|
||||
import { uploadPRD, getPRD } from '@/lib/api';
|
||||
|
||||
export function usePrd() {
|
||||
const [prd, setPrd] = useState<PRDVersion | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const upload = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadPRD(file);
|
||||
setPrd(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchPrd = useCallback(async (prdId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getPRD(prdId);
|
||||
setPrd(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fetch failed');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { prd, loading, error, upload, fetchPrd };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 测试用例状态管理 Hook
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { TestCaseSet, ExportFormat } from '@/lib/types';
|
||||
import { generateTestCases, exportTestCases } from '@/lib/api';
|
||||
|
||||
export function useTestcases() {
|
||||
const [tcSet, setTcSet] = useState<TestCaseSet | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generate = useCallback(async (irId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await generateTestCases(irId);
|
||||
setTcSet(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Generation failed');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exportCases = useCallback(async (tcSetId: string, format: ExportFormat) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportTestCases(tcSetId, format);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `testcases.${format === 'csv' ? 'csv' : format === 'yaml' ? 'yaml' : 'xmind'}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Export failed');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tcSet, loading, exporting, error, generate, exportCases };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 后端 API 封装
|
||||
|
||||
import type { PRDVersion, IRVersion, TestCaseSet, ExportFormat } from './types';
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 600_000); // 10 min timeout for LLM
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(body || `API Error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// --- PRD APIs ---
|
||||
|
||||
export async function uploadPRD(file: File): Promise<PRDVersion> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch(`${BASE_URL}/prd/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getPRD(prdId: string): Promise<PRDVersion> {
|
||||
return fetchJSON(`/prd/${prdId}`);
|
||||
}
|
||||
|
||||
// --- IR APIs ---
|
||||
|
||||
export async function generateIR(
|
||||
prdId: string,
|
||||
skillName: string = 'default'
|
||||
): Promise<IRVersion> {
|
||||
return fetchJSON(`/ir/generate?prd_id=${prdId}&skill_name=${skillName}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIR(irId: string): Promise<IRVersion> {
|
||||
return fetchJSON(`/ir/${irId}`);
|
||||
}
|
||||
|
||||
export async function updateIR(irId: string, yamlContent: string): Promise<void> {
|
||||
return fetchJSON(`/ir/${irId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ yaml_content: yamlContent }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateIR(irId: string): Promise<IRVersion> {
|
||||
return fetchJSON(`/ir/${irId}/validate`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function diffIR(irIdA: string, irIdB: string): Promise<{ diff: unknown }> {
|
||||
return fetchJSON(`/ir/diff?ir_id_a=${irIdA}&ir_id_b=${irIdB}`);
|
||||
}
|
||||
|
||||
// --- Chat API ---
|
||||
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
context: Record<string, any>,
|
||||
history: { role: string; content: string }[] = []
|
||||
): Promise<{ reply: string; actions: any[] }> {
|
||||
return fetchJSON('/chat/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, context, history }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Skills APIs ---
|
||||
|
||||
export async function fetchSkills(): Promise<{ skills: Skill[]; count: number }> {
|
||||
return fetchJSON('/skills/');
|
||||
}
|
||||
|
||||
// --- TestCase APIs ---
|
||||
|
||||
export async function generateTestCases(irId: string): Promise<TestCaseSet> {
|
||||
return fetchJSON(`/testcase/generate?ir_id=${irId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function exportTestCases(
|
||||
tcSetId: string,
|
||||
format: ExportFormat
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(`${BASE_URL}/testcase/export/${format}?tc_set_id=${tcSetId}`);
|
||||
if (!res.ok) throw new Error(`Export failed: ${res.statusText}`);
|
||||
return res.blob();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 全局共享状态 — ChatPanel 和主页面之间的数据桥梁
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
actions?: any[];
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
prdText: string;
|
||||
prdId: string;
|
||||
irContent: string;
|
||||
irId: string;
|
||||
irRules: { rule_id: string; description: string; priority: string }[];
|
||||
testcases: any[];
|
||||
tcSetId: string;
|
||||
currentPage: string;
|
||||
|
||||
setPrd: (id: string, text: string) => void;
|
||||
setIr: (id: string, content: string, rules?: { rule_id: string; description: string; priority: string }[]) => void;
|
||||
setTestcases: (id: string, cases: any[]) => void;
|
||||
setCurrentPage: (page: string) => void;
|
||||
|
||||
irRefreshTrigger: number;
|
||||
triggerIrRefresh: () => void;
|
||||
|
||||
pendingEdits: { action: string; data: any }[];
|
||||
setPendingEdits: (edits: { action: string; data: any }[]) => void;
|
||||
|
||||
// Chat messages — persists across page navigation
|
||||
chatMessages: ChatMessage[];
|
||||
setChatMessages: (msgs: ChatMessage[]) => void;
|
||||
addChatMessage: (msg: ChatMessage) => void;
|
||||
|
||||
// Session ID — regenerated on page refresh to start fresh
|
||||
sessionId: string;
|
||||
newSession: () => void;
|
||||
|
||||
// Currently selected item (for "this/这条" context in chat)
|
||||
selectedCase: { id: string; title: string; steps: string; expected: string; priority: string } | null;
|
||||
setSelectedCase: (tc: { id: string; title: string; steps: string; expected: string; priority: string } | null) => void;
|
||||
selectedRule: { rule_id: string; description: string; priority: string } | null;
|
||||
setSelectedRule: (rule: { rule_id: string; description: string; priority: string } | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
prdText: '', prdId: '',
|
||||
irContent: '', irId: '', irRules: [],
|
||||
testcases: [], tcSetId: '',
|
||||
currentPage: 'home',
|
||||
|
||||
setPrd: (id, text) => set({ prdId: id, prdText: text }),
|
||||
setIr: (id, content, rules) => set({ irId: id, irContent: content, irRules: rules || [] }),
|
||||
setTestcases: (id, cases) => set({ tcSetId: id, testcases: cases }),
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
irRefreshTrigger: 0,
|
||||
triggerIrRefresh: () => set((s) => ({ irRefreshTrigger: s.irRefreshTrigger + 1 })),
|
||||
pendingEdits: [],
|
||||
setPendingEdits: (edits) => set({ pendingEdits: edits }),
|
||||
|
||||
chatMessages: [],
|
||||
setChatMessages: (msgs) => set({ chatMessages: msgs }),
|
||||
addChatMessage: (msg) => set((s) => ({ chatMessages: [...s.chatMessages, msg] })),
|
||||
|
||||
sessionId: `sess_${Date.now().toString(36)}`,
|
||||
newSession: () => set({ sessionId: `sess_${Date.now().toString(36)}`, chatMessages: [] }),
|
||||
|
||||
selectedCase: null,
|
||||
setSelectedCase: (tc) => set({ selectedCase: tc }),
|
||||
selectedRule: null,
|
||||
setSelectedRule: (rule) => set({ selectedRule: rule }),
|
||||
}));
|
||||
|
||||
export type { ChatMessage };
|
||||
@@ -0,0 +1,98 @@
|
||||
// 前端核心类型定义 - 对齐后端 API 返回格式
|
||||
|
||||
export interface PRDVersion {
|
||||
prd_id: string;
|
||||
filename: string;
|
||||
uploaded_at: string;
|
||||
status: 'uploaded' | 'parsing' | 'ready';
|
||||
full_text?: string;
|
||||
full_text_length?: number;
|
||||
sections_count?: number;
|
||||
images_count?: number;
|
||||
sections?: Section[];
|
||||
images?: ImageAnalysis[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
heading: string;
|
||||
heading_level: number;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
export interface ImageAnalysis {
|
||||
rid: string;
|
||||
path: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
module: string;
|
||||
feature_name: string;
|
||||
description: string;
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
preconditions: string[];
|
||||
constraints: string[];
|
||||
priority: 'P0' | 'P1' | 'P2';
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
export interface IRSchema {
|
||||
meta: {
|
||||
prd_title: string;
|
||||
extraction_date: string;
|
||||
skill_used: string;
|
||||
};
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
export interface IRVersion {
|
||||
ir_id: string;
|
||||
prd_id: string;
|
||||
yaml_content: string;
|
||||
skill_used: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface IRValidation {
|
||||
valid: boolean;
|
||||
issues: IRValidationIssue[];
|
||||
}
|
||||
|
||||
export interface IRValidationIssue {
|
||||
severity: 'error' | 'warning';
|
||||
message: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface TestCase {
|
||||
id: string;
|
||||
ir_rule_id?: string;
|
||||
module: string;
|
||||
feature: string;
|
||||
case_title: string;
|
||||
preconditions: string;
|
||||
steps: string;
|
||||
expected_result: string;
|
||||
priority: 'P0' | 'P1' | 'P2';
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface TestCaseSet {
|
||||
tc_set_id: string;
|
||||
ir_id: string;
|
||||
cases: TestCase[];
|
||||
case_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ExportFormat = 'yaml' | 'csv' | 'xmind' | 'json';
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
version: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Next.js App 入口
|
||||
|
||||
import type { AppProps } from 'next/app';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-right" />
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// 用例预览与导出页
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '@/components/Layout';
|
||||
import TestCaseTable from '@/components/TestCaseTable';
|
||||
import ExportPanel from '@/components/ExportPanel';
|
||||
import { generateTestCases, getIR } from '@/lib/api';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import type { TestCase } from '@/lib/types';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function CasesPage() {
|
||||
const router = useRouter();
|
||||
const { irId } = router.query;
|
||||
const setTestcases = useAppStore((s) => s.setTestcases);
|
||||
const syncStore = (updated: TestCase[]) => {
|
||||
const st = useAppStore.getState();
|
||||
st.setTestcases(st.tcSetId, updated);
|
||||
};
|
||||
|
||||
const [cases, setCases] = useState<TestCase[]>([]);
|
||||
const [tcSetId, setTcSetId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeView, setActiveView] = useState<'table' | 'yaml' | 'json'>('table');
|
||||
const [yamlPreview, setYamlPreview] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
const regenerateCases = async () => {
|
||||
if (!irId) return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const result = await generateTestCases(irId as string);
|
||||
setCases(result.cases || []);
|
||||
setTcSetId(result.tc_set_id || '');
|
||||
setTestcases(result.tc_set_id || '', result.cases || []);
|
||||
toast.success(`重新生成了 ${result.case_count} 条用例`);
|
||||
} catch (err: any) {
|
||||
toast.error('重新生成失败');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingEdits = useAppStore((s) => s.pendingEdits);
|
||||
const setPendingEdits = useAppStore((s) => s.setPendingEdits);
|
||||
|
||||
// Manual add case form
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newCase, setNewCase] = useState<Partial<TestCase>>({
|
||||
id: `TC-${Date.now().toString(36).toUpperCase()}`, module: '', feature: '',
|
||||
case_title: '', preconditions: '', steps: '', expected_result: '', priority: 'P2', tags: ['手动添加'],
|
||||
});
|
||||
|
||||
// Listen for chat-driven test case modifications (batch)
|
||||
useEffect(() => {
|
||||
if (!pendingEdits || pendingEdits.length === 0) return;
|
||||
|
||||
let runningMaxNum = 0;
|
||||
const nextId = (prev: TestCase[]) => {
|
||||
if (runningMaxNum === 0) {
|
||||
const nums = prev.map((c) => { const m = c.id?.match(/-(\d{3})$/); return m ? parseInt(m[1]) : 0; });
|
||||
runningMaxNum = nums.length > 0 ? Math.max(...nums) : 0;
|
||||
}
|
||||
runningMaxNum++;
|
||||
const prefix = prev[0]?.id?.replace(/-\d{3}$/, '') || 'TC-FEAT';
|
||||
return `${prefix}-${String(runningMaxNum).padStart(3, '0')}`;
|
||||
};
|
||||
|
||||
for (const { action, data } of pendingEdits) {
|
||||
const isAdd = action === 'add_rule' || action === 'add_case';
|
||||
const isDelete = action === 'delete_rule' || action === 'delete_case';
|
||||
const isModify = action === 'modify_rule' || action === 'modify_case';
|
||||
const targetId = data.rule_id || data.case_id;
|
||||
|
||||
if (isAdd && data.rule?.case_title) {
|
||||
const tc = { ...data.rule } as TestCase;
|
||||
if (!tc.module) tc.module = data.rule.module || (cases[0]?.module || '');
|
||||
if (!tc.priority) tc.priority = 'P2';
|
||||
if (!tc.tags?.length) tc.tags = ['手动添加'];
|
||||
setCases((prev) => {
|
||||
tc.id = nextId(prev);
|
||||
const u = [...prev, tc]; syncStore(u); return u;
|
||||
});
|
||||
toast.success(`Chat 添加了: ${tc.id} ${tc.case_title?.slice(0, 30)}`);
|
||||
} else if (isDelete && targetId) {
|
||||
setCases((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === targetId);
|
||||
if (idx >= 0) {
|
||||
toast.success(`Chat 删除了用例: ${prev[idx].case_title?.slice(0, 30)}`);
|
||||
const filtered = prev.filter((_, i) => i !== idx);
|
||||
syncStore(filtered);
|
||||
return filtered;
|
||||
}
|
||||
toast.error(`未找到用例: ${targetId}`);
|
||||
return prev;
|
||||
});
|
||||
} else if (isModify && targetId && data.changes) {
|
||||
setCases((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === targetId);
|
||||
if (idx >= 0) {
|
||||
toast.success(`Chat 修改了用例: ${targetId}`);
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...data.changes };
|
||||
syncStore(updated);
|
||||
return updated;
|
||||
}
|
||||
toast.error(`未找到用例: ${targetId}`);
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setPendingEdits([]);
|
||||
}, [pendingEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
if (!irId) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await generateTestCases(irId as string);
|
||||
setCases(result.cases || []);
|
||||
setTcSetId(result.tc_set_id || '');
|
||||
setTestcases(result.tc_set_id || '', result.cases || []);
|
||||
|
||||
// Load IR context if not in store (e.g. direct page refresh)
|
||||
const s = useAppStore.getState();
|
||||
if (!s.irRules.length && s.irId) {
|
||||
try {
|
||||
const irRes = await getIR(s.irId);
|
||||
const rules = irRes.ir_json?.rules || irRes.rules || [];
|
||||
if (Array.isArray(rules) && rules.length > 0) {
|
||||
const rulesMeta = rules.map((r: any) => ({
|
||||
rule_id: r.rule_id || '', description: r.description || '', priority: r.priority || 'P2',
|
||||
}));
|
||||
s.setIr(s.irId, irRes.yaml_content || '', rulesMeta);
|
||||
}
|
||||
} catch { /* IR unavailable */ }
|
||||
}
|
||||
|
||||
toast.success(`生成了 ${result.case_count} 条测试用例`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '用例生成失败');
|
||||
toast.error('用例生成失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [irId, router.isReady]);
|
||||
|
||||
const handleExport = (format: 'yaml' | 'csv' | 'json') => {
|
||||
if (cases.length === 0) {
|
||||
toast.error('暂无可导出的用例');
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
let content = '';
|
||||
let mime = 'text/plain';
|
||||
const ext = format === 'csv' ? 'csv' : format === 'yaml' ? 'yaml' : 'json';
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify({ testcases: cases, exported_at: new Date().toISOString(), count: cases.length }, null, 2);
|
||||
mime = 'application/json';
|
||||
} else if (format === 'yaml') {
|
||||
const lines = ['testcases:'];
|
||||
for (const c of cases) {
|
||||
lines.push(` - id: "${c.id}"`);
|
||||
lines.push(` module: "${c.module || ''}"`);
|
||||
lines.push(` feature: "${c.feature || ''}"`);
|
||||
lines.push(` case_title: "${c.case_title || ''}"`);
|
||||
lines.push(` priority: "${c.priority || 'P2'}"`);
|
||||
lines.push(` preconditions: "${c.preconditions || ''}"`);
|
||||
lines.push(` steps: |`);
|
||||
(c.steps || '').split('\n').forEach((s: string) => lines.push(` ${s.trim()}`));
|
||||
lines.push(` expected_result: "${c.expected_result || ''}"`);
|
||||
lines.push(` tags: [${(c.tags || []).map((t: string) => `"${t}"`).join(', ')}]`);
|
||||
}
|
||||
content = lines.join('\n');
|
||||
mime = 'application/x-yaml';
|
||||
} else if (format === 'csv') {
|
||||
const headers = ['id', 'module', 'feature', 'case_title', 'preconditions', 'steps', 'expected_result', 'priority', 'tags'];
|
||||
const rows = [headers.join(',')];
|
||||
for (const c of cases) {
|
||||
const vals = [
|
||||
c.id, c.module, c.feature, c.case_title, c.preconditions,
|
||||
`"${(c.steps || '').replace(/"/g, '""')}"`,
|
||||
`"${(c.expected_result || '').replace(/"/g, '""')}"`,
|
||||
c.priority, `"${(c.tags || []).join(';')}"`,
|
||||
];
|
||||
rows.push(vals.join(','));
|
||||
}
|
||||
content = rows.join('\n');
|
||||
mime = 'text/csv';
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `testcases.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`导出 ${format.toUpperCase()} 成功`);
|
||||
} catch {
|
||||
toast.error('导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate YAML preview from cases (manual builder, no external deps needed)
|
||||
useEffect(() => {
|
||||
if (cases.length > 0 && activeView === 'yaml') {
|
||||
const lines = ['testcases:'];
|
||||
for (const c of cases) {
|
||||
lines.push(` - id: "${c.id}"`);
|
||||
lines.push(` module: "${c.module}"`);
|
||||
lines.push(` feature: "${c.feature}"`);
|
||||
lines.push(` case_title: "${c.case_title}"`);
|
||||
lines.push(` priority: "${c.priority}"`);
|
||||
lines.push(` preconditions: "${c.preconditions}"`);
|
||||
lines.push(` steps: |`);
|
||||
c.steps.split('\n').forEach((s: string) => lines.push(` ${s.trim()}`));
|
||||
lines.push(` expected_result: "${c.expected_result}"`);
|
||||
lines.push(` tags: [${(c.tags || []).map((t: string) => `"${t}"`).join(', ')}]`);
|
||||
}
|
||||
setYamlPreview(lines.join('\n'));
|
||||
}
|
||||
}, [cases, activeView]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto" />
|
||||
<p className="text-gray-500">正在生成测试用例...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">生成失败</p>
|
||||
<p className="mt-2 text-sm">{error}</p>
|
||||
<button onClick={() => router.push('/')} className="mt-4 text-blue-600 hover:underline">
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Toaster position="top-right" />
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">测试用例预览与导出</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
基于 IR 生成了 {cases.length} 条测试用例
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={regenerateCases}
|
||||
disabled={regenerating}
|
||||
className="rounded bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? '重新生成中...' : '🔄 从最新 IR 重新生成'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="rounded bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
+ 添加用例
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline add form */}
|
||||
{showAddForm && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50/50 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">用例标题 *</label>
|
||||
<input value={newCase.case_title || ''} onChange={(e) => setNewCase({ ...newCase, case_title: e.target.value })}
|
||||
placeholder="正向/异常/边界-简短描述" className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">模块</label>
|
||||
<input value={newCase.module || ''} onChange={(e) => setNewCase({ ...newCase, module: e.target.value })}
|
||||
className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">功能点</label>
|
||||
<input value={newCase.feature || ''} onChange={(e) => setNewCase({ ...newCase, feature: e.target.value })}
|
||||
className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">优先级</label>
|
||||
<select value={newCase.priority || 'P2'} onChange={(e) => setNewCase({ ...newCase, priority: e.target.value as any })}
|
||||
className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm">
|
||||
<option>P0</option><option>P1</option><option>P2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">前置条件</label>
|
||||
<input value={newCase.preconditions || ''} onChange={(e) => setNewCase({ ...newCase, preconditions: e.target.value })}
|
||||
className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">标签 (逗号分隔)</label>
|
||||
<input value={(newCase.tags || []).join(',')} onChange={(e) => setNewCase({ ...newCase, tags: e.target.value.split(',').map((t: string) => t.trim()) })}
|
||||
className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-gray-500">测试步骤 (Given-When-Then)</label>
|
||||
<textarea value={newCase.steps || ''} onChange={(e) => setNewCase({ ...newCase, steps: e.target.value })}
|
||||
rows={3} placeholder="Given ...\nWhen ...\nThen ..." className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-gray-500">预期结果</label>
|
||||
<input value={newCase.expected_result || ''} onChange={(e) => setNewCase({ ...newCase, expected_result: e.target.value })}
|
||||
className="mt-0.5 w-full rounded border border-gray-300 px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!newCase.case_title?.trim()) { toast.error('请填写用例标题'); return; }
|
||||
const existingNums = cases.map((c) => { const m = c.id?.match(/-(\d{3})$/); return m ? parseInt(m[1]) : 0; });
|
||||
const maxNum = existingNums.length > 0 ? Math.max(...existingNums) : 0;
|
||||
const prefix = cases[0]?.id?.replace(/-\d{3}$/, '') || 'TC';
|
||||
const newId = `${prefix}-${String(maxNum + 1).padStart(3, '0')}`;
|
||||
const tc = { ...newCase, id: newId } as TestCase;
|
||||
setCases([...cases, tc]);
|
||||
setTestcases(tcSetId, [...cases, tc]);
|
||||
setShowAddForm(false);
|
||||
setNewCase({ id: '', module: '', feature: '', case_title: '', preconditions: '', steps: '', expected_result: '', priority: 'P2', tags: [] });
|
||||
toast.success(`已添加 ${newId}`);
|
||||
}}
|
||||
className="rounded bg-green-600 px-4 py-1.5 text-sm text-white hover:bg-green-700"
|
||||
>
|
||||
确认添加
|
||||
</button>
|
||||
<button onClick={() => setShowAddForm(false)}
|
||||
className="rounded bg-gray-200 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-300">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 格式切换标签 */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{[
|
||||
{ key: 'table' as const, label: '表格预览' },
|
||||
{ key: 'yaml' as const, label: 'YAML 预览' },
|
||||
{ key: 'json' as const, label: 'JSON 预览' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveView(tab.key)}
|
||||
className={`border-b-2 px-4 py-2 text-sm font-medium transition
|
||||
${activeView === tab.key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{activeView === 'table' && (
|
||||
<TestCaseTable cases={cases} onUpdate={(updated) => { setCases(updated); syncStore(updated); }} />
|
||||
)}
|
||||
{activeView === 'yaml' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-900 p-4">
|
||||
<pre className="overflow-auto text-sm text-green-400 max-h-[60vh]">{yamlPreview || '暂无可预览内容'}</pre>
|
||||
</div>
|
||||
)}
|
||||
{activeView === 'json' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-900 p-4">
|
||||
<pre className="overflow-auto text-sm text-green-400 max-h-[60vh]">{JSON.stringify(cases, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出面板 */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">导出测试用例</h3>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleExport('yaml')}
|
||||
disabled={exporting}
|
||||
className="rounded bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
导出 YAML
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
disabled={exporting}
|
||||
className="rounded bg-green-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
导出 CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('json')}
|
||||
disabled={exporting}
|
||||
className="rounded bg-gray-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
导出 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 主页:PRD 上传与 Skill 选择(动态加载)
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '@/components/Layout';
|
||||
import UploadZone from '@/components/UploadZone';
|
||||
import { uploadPRD, fetchSkills } from '@/lib/api';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import type { Skill } from '@/lib/types';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedSkill, setSelectedSkill] = useState('default');
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = await fetchSkills();
|
||||
setSkills(data.skills || []);
|
||||
if (data.skills?.length > 0) {
|
||||
setSelectedSkill(data.skills[0].name);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to default
|
||||
setSkills([
|
||||
{ name: 'default', display_name: '通用测试设计', description: '适用于通用 Web 后台', domain: 'general', version: '1.0.0' },
|
||||
{ name: 'ecommerce', display_name: '电商后台测试设计', description: '专为电商系统设计', domain: 'ecommerce', version: '1.0.0' },
|
||||
]);
|
||||
} finally {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setPrd = useAppStore((s) => s.setPrd);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadPRD(file);
|
||||
setPrd(result.prd_id, result.full_text || '');
|
||||
toast.success(`上传成功!解析了 ${result.sections_count} 个章节`);
|
||||
router.push(`/ir-confirm?prdId=${result.prd_id}&skill=${selectedSkill}`);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Toaster position="top-right" />
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-10 text-center">
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
将需求转化为逻辑,用逻辑生成用例
|
||||
</h2>
|
||||
<p className="mt-3 text-gray-500">
|
||||
上传 PRD 文档,AI 自动提取功能点并生成结构化测试用例
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UploadZone onUpload={handleUpload} disabled={uploading} />
|
||||
|
||||
{uploading && (
|
||||
<div className="mt-4 text-center text-sm text-blue-600">
|
||||
正在解析文档...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill 选择卡片 - 动态加载 */}
|
||||
<div className="mt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
选择测试方法论 (Skill){skillsLoading ? ' - 加载中...' : ` - ${skills.length} 个可用`}
|
||||
</h3>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const data = await fetchSkills();
|
||||
setSkills(data.skills || []);
|
||||
toast.success('Skill 列表已刷新');
|
||||
} catch {
|
||||
toast.error('刷新失败');
|
||||
}
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
刷新列表
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<button
|
||||
key={skill.name}
|
||||
onClick={() => setSelectedSkill(skill.name)}
|
||||
className={`rounded-lg border p-4 text-left transition
|
||||
${selectedSkill === skill.name
|
||||
? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{skill.display_name}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium
|
||||
${skill.domain === 'ecommerce' ? 'bg-orange-100 text-orange-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{skill.domain}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{skill.description}</div>
|
||||
<div className="mt-2 text-xs text-gray-400">v{skill.version}</div>
|
||||
</button>
|
||||
))}
|
||||
{!skillsLoading && skills.length === 0 && (
|
||||
<div className="col-span-2 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center">
|
||||
<p className="text-sm text-gray-400">暂无可用的 Skill 包</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
请在 .zeekerwatchmen/skills/ 下添加 Skill 目录
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// IR 确认页 — SSE 流式生成 + 内嵌水管动画 + 思维导图
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '@/components/Layout';
|
||||
import IrWorkspace from '@/components/IrWorkspace';
|
||||
import PipelineProgress from '@/components/PipelineProgress';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
interface PipelineEvent {
|
||||
stage: number;
|
||||
status: 'running' | 'done' | 'error';
|
||||
message: string;
|
||||
detail?: string;
|
||||
elapsed?: number;
|
||||
stage_total: number;
|
||||
completed?: number;
|
||||
total?: number;
|
||||
rules_so_far?: number;
|
||||
total_rules?: number;
|
||||
estimated_total?: number;
|
||||
done?: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function IrConfirmPage() {
|
||||
const router = useRouter();
|
||||
const { prdId, skill } = router.query;
|
||||
const setIr = useAppStore((s) => s.setIr);
|
||||
|
||||
const [irContent, setIrContent] = useState('');
|
||||
const [irId, setIrId] = useState('');
|
||||
const [auditReport, setAuditReport] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [events, setEvents] = useState<PipelineEvent[]>([]);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const readerRef = useRef<ReadableStreamDefaultReader | null>(null);
|
||||
|
||||
const applyResultToStore = (result: any) => {
|
||||
const rules = result.ir_json?.rules || [];
|
||||
const rulesMeta = rules.map((r: any) => ({
|
||||
rule_id: r.rule_id || '',
|
||||
description: r.description || '',
|
||||
priority: r.priority || 'P2',
|
||||
}));
|
||||
setIrContent(result.yaml_content || '');
|
||||
setIrId(result.ir_id || '');
|
||||
setAuditReport(result.audit_report || '');
|
||||
setIr(result.ir_id || '', result.yaml_content || '', rulesMeta);
|
||||
setDone(true);
|
||||
setStreaming(false);
|
||||
};
|
||||
|
||||
// Start SSE stream when query params are ready
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !prdId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
setStreaming(true);
|
||||
setError('');
|
||||
setEvents([]);
|
||||
setDone(false);
|
||||
|
||||
try {
|
||||
// Bypass Next.js proxy — SSE requires direct connection
|
||||
const res = await fetch(
|
||||
`http://localhost:8765/api/ir/generate-stream?prd_id=${prdId}&skill_name=${skill || 'default'}`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(body || `Server error: ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
readerRef.current = reader;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done: streamDone, value } = await reader.read();
|
||||
if (streamDone) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const event: PipelineEvent = JSON.parse(line.slice(6));
|
||||
setEvents((prev) => [...prev, event]);
|
||||
|
||||
if (event.done && event.result) {
|
||||
applyResultToStore(event.result);
|
||||
toast.success(`生成了 ${event.result.ir_json?.rules?.length || 0} 条规则`);
|
||||
}
|
||||
if (event.error) {
|
||||
setError(event.error);
|
||||
setStreaming(false);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended — flush buffer and safety check
|
||||
if (buffer.startsWith('data: ')) {
|
||||
try {
|
||||
const event: PipelineEvent = JSON.parse(buffer.slice(6));
|
||||
if (event.done && event.result) {
|
||||
applyResultToStore(event.result);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setStreaming(false);
|
||||
setEvents((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.done && last.result && !done) {
|
||||
setTimeout(() => { applyResultToStore(last.result); }, 0);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setError(err.message || '生成失败');
|
||||
setStreaming(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
readerRef.current?.cancel();
|
||||
};
|
||||
}, [prdId, skill, router.isReady]);
|
||||
|
||||
const handleSave = useCallback((content: string) => {
|
||||
setIrContent(content);
|
||||
toast.success('IR 已保存');
|
||||
if (irId) router.push(`/cases?irId=${irId}`);
|
||||
}, [irId, router]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Toaster position="top-right" />
|
||||
|
||||
{/* Always show the page structure, populate when done */}
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>项目</span>
|
||||
<span>/</span>
|
||||
<span className="font-medium text-gray-900">PRD</span>
|
||||
<span>/</span>
|
||||
<span className="font-medium text-blue-600">IR 生成</span>
|
||||
</div>
|
||||
|
||||
{/* IR Workspace (hidden until done) */}
|
||||
{done && irContent ? (
|
||||
<IrWorkspace irContent={irContent} auditReport={auditReport} onSave={handleSave} loading={false} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50/50">
|
||||
{error ? (
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium text-red-500">生成失败</p>
|
||||
<p className="mt-2 max-w-md text-sm text-gray-500">{error}</p>
|
||||
<button onClick={() => router.push('/')} className="mt-4 text-blue-600 hover:underline">
|
||||
返回首页重新上传
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">
|
||||
{streaming ? 'IR 生成中,请稍候...' : '准备就绪'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline progress bar (always visible during streaming) */}
|
||||
{streaming && <PipelineProgress events={events} error={error} done={done} />}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
'pipe-flow': {
|
||||
'0%': { width: '5%', opacity: '0.4' },
|
||||
'50%': { width: '70%', opacity: '1' },
|
||||
'100%': { width: '5%', opacity: '0.4' },
|
||||
},
|
||||
'pipe-dash': {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(60vw)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pipe-flow': 'pipe-flow 2s ease-in-out infinite',
|
||||
'pipe-dash': 'pipe-dash 1.5s linear infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user