init the project

This commit is contained in:
evyzacq
2026-05-25 15:09:42 +08:00
commit 7fc0e7852e
122 changed files with 14557 additions and 0 deletions
+6
View File
@@ -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.
+15
View File
@@ -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;
+2371
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+289
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+252
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+382
View File
@@ -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>
);
}
+161
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+58
View File
@@ -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 };
}
+43
View File
@@ -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 };
}
+46
View File
@@ -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 };
}
+106
View File
@@ -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();
}
+77
View File
@@ -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 };
+98
View File
@@ -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;
}
+14
View File
@@ -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} />
</>
);
}
+436
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+193
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+24
View File
@@ -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: [],
};
+23
View File
@@ -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"]
}