完善skills;测试用例生成页面功能初步实现

This commit is contained in:
2026-05-05 19:45:33 +08:00
parent 0c2ed67e2a
commit 69b49d28b2
35 changed files with 4396 additions and 658 deletions

View File

@@ -94,7 +94,7 @@ export default function NewChatPage() {
</p>
<Link
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
>
<Plus className="mr-2 h-4 w-4" />

View File

@@ -1,14 +1,17 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
ChevronRight,
Download,
FileJson,
FileText,
History,
Loader2,
Save,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
@@ -23,6 +26,23 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/components/ui/use-toast";
import {
downloadJson,
@@ -35,11 +55,15 @@ import {
} from "@/lib/document-mock";
import {
createSrsJob,
deleteSrsJob,
getSrsJobResult,
getSrsJobStatus,
listSrsHistory,
saveSrsRequirements,
SrsHistoryItem,
toExtractionResult,
} from "@/lib/srs-tools-api";
import { buildSectionTree, rebuildRawOutput, SectionTreeNode } from "@/lib/srs-json";
import { cn } from "@/lib/utils";
const formatDateTime = (value: string) => {
@@ -69,6 +93,89 @@ const wait = (ms: number) =>
const EXTRACTION_JOB_KEY = "doc_processing_extraction_job_id";
const normalizeRequirementType = (value: unknown): RequirementItem["requirementType"] => {
if (
value === "functional" ||
value === "interface" ||
value === "performance" ||
value === "security" ||
value === "reliability" ||
value === "other"
) {
return value;
}
if (value === "接口需求") {
return "interface";
}
if (value === "性能需求") {
return "performance";
}
if (value === "安全需求") {
return "security";
}
if (value === "可靠性需求") {
return "reliability";
}
if (value === "其他需求") {
return "other";
}
return "functional";
};
const hasInterfaceMetadata = (item: RequirementItem) => {
const candidates = [item.interfaceName, item.interfaceType, item.dataSource, item.dataDestination];
return candidates.some((field) => {
const value = (field || "").trim();
return Boolean(value) && !["未知", "unknown", "n/a", "-", "--", "无"].includes(value.toLowerCase());
});
};
const normalizeRequirement = (item: RequirementItem, index: number): RequirementItem => {
const normalizedType = normalizeRequirementType(item.requirementType);
const requirementType =
normalizedType === "interface" || hasInterfaceMetadata(item) ? "interface" : normalizedType;
return {
...item,
priority: item.priority || "中",
requirementType,
sectionNumber: item.sectionNumber || "",
sectionTitle: item.sectionTitle || "未归类章节",
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index,
interfaceName: requirementType === "interface" ? item.interfaceName || "" : "",
interfaceType: requirementType === "interface" ? item.interfaceType || "" : "",
dataSource: requirementType === "interface" ? item.dataSource || "" : "",
dataDestination: requirementType === "interface" ? item.dataDestination || "" : "",
};
};
const normalizeExtractionResult = (
extraction: RequirementExtractionResult
): RequirementExtractionResult => {
const requirements = extraction.requirements.map((item, index) =>
normalizeRequirement(item, index)
);
return {
...extraction,
requirements,
rawOutput: rebuildRawOutput(
extraction.rawOutput as Record<string, unknown> | undefined,
requirements,
extraction.documentName
),
};
};
const collectSectionKeys = (nodes: SectionTreeNode[]): string[] => {
const keys: string[] = [];
const walk = (node: SectionTreeNode) => {
keys.push(node.key);
node.children.forEach((child) => walk(child));
};
nodes.forEach((node) => walk(node));
return keys;
};
export default function RequirementExtractionPage() {
const [documentFile, setDocumentFile] = useState<File | null>(null);
const [extraction, setExtraction] = useState<RequirementExtractionResult | null>(
@@ -81,9 +188,35 @@ export default function RequirementExtractionPage() {
const [isSaving, setIsSaving] = useState(false);
const [activeJobId, setActiveJobId] = useState<number | null>(null);
const [isImportingJson, setIsImportingJson] = useState(false);
const [historyItems, setHistoryItems] = useState<SrsHistoryItem[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [expandedSectionKeys, setExpandedSectionKeys] = useState<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const jsonInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const setExtractionAndPersist = useCallback((value: RequirementExtractionResult | null) => {
if (!value) {
setExtraction(null);
return;
}
const normalized = normalizeExtractionResult(value);
setExtraction(normalized);
saveExtractionDraft(normalized);
}, []);
const loadHistory = useCallback(async () => {
setIsHistoryLoading(true);
try {
const items = await listSrsHistory();
setHistoryItems(items);
} catch {
setHistoryItems([]);
} finally {
setIsHistoryLoading(false);
}
}, []);
useEffect(() => {
const storedJobId = window.localStorage.getItem(EXTRACTION_JOB_KEY);
if (storedJobId) {
@@ -95,12 +228,15 @@ export default function RequirementExtractionPage() {
const draft = loadExtractionDraft();
if (!draft) {
void loadHistory();
return;
}
setExtraction(draft);
const normalized = normalizeExtractionResult(draft);
setExtraction(normalized);
setSelectedRequirementId(draft.requirements[0]?.id ?? null);
}, []);
void loadHistory();
}, [loadHistory]);
const onDrop = useCallback((acceptedFiles: File[]) => {
const first = acceptedFiles[0];
@@ -125,24 +261,74 @@ export default function RequirementExtractionPage() {
(item) => item.id === selectedRequirementId
);
const sectionTree = useMemo(() => {
if (!extraction) {
return [];
}
return buildSectionTree(
extraction.rawOutput as Record<string, unknown> | undefined,
extraction.requirements
);
}, [extraction]);
useEffect(() => {
if (!sectionTree.length) {
setExpandedSectionKeys([]);
return;
}
setExpandedSectionKeys((prev) => {
if (prev.length > 0) {
return prev;
}
return collectSectionKeys(sectionTree).slice(0, 2);
});
}, [sectionTree]);
const updateRequirement = (
requirementId: string,
updater: (item: RequirementItem) => RequirementItem
) => {
let nextSelected = selectedRequirementId;
setExtraction((prev) => {
if (!prev) {
return prev;
}
const index = prev.requirements.findIndex((item) => item.id === requirementId);
if (index < 0) {
return prev;
}
const current = prev.requirements[index];
const updated = normalizeRequirement(updater(current), index);
const requirements = [...prev.requirements];
requirements[index] = {
...updated,
sortOrder: index,
};
const next: RequirementExtractionResult = {
...prev,
requirements: prev.requirements.map((item) =>
item.id === requirementId ? updater(item) : item
requirements,
rawOutput: rebuildRawOutput(
prev.rawOutput as Record<string, unknown> | undefined,
requirements,
prev.documentName
),
};
saveExtractionDraft(next);
if (selectedRequirementId === requirementId) {
nextSelected = updated.id;
}
return next;
});
if (nextSelected) {
setSelectedRequirementId(nextSelected);
}
};
const handleExtract = async () => {
@@ -167,7 +353,7 @@ export default function RequirementExtractionPage() {
const status = await getSrsJobStatus(job.job_id);
if (status.status === "completed") {
const rawResult = await getSrsJobResult(job.job_id);
finalResult = toExtractionResult(rawResult);
finalResult = normalizeExtractionResult(toExtractionResult(rawResult));
break;
}
if (status.status === "failed") {
@@ -180,9 +366,9 @@ export default function RequirementExtractionPage() {
throw new Error("提取任务超时,请稍后重试");
}
setExtraction(finalResult);
setExtractionAndPersist(finalResult);
setSelectedRequirementId(finalResult.requirements[0]?.id ?? null);
saveExtractionDraft(finalResult);
await loadHistory();
toast({
title: "提取完成",
description: `已生成 ${finalResult.requirements.length} 条需求项。`,
@@ -210,11 +396,10 @@ export default function RequirementExtractionPage() {
setIsImportingJson(true);
try {
const content = await file.text();
const parsed = parseRequirementJson(content);
setExtraction(parsed);
const parsed = normalizeExtractionResult(parseRequirementJson(content));
setExtractionAndPersist(parsed);
setSelectedRequirementId(parsed.requirements[0]?.id ?? null);
setActiveJobId(null);
saveExtractionDraft(parsed);
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
toast({
title: "导入成功",
@@ -238,9 +423,11 @@ export default function RequirementExtractionPage() {
return;
}
const normalized = normalizeExtractionResult(extraction);
const persist = async () => {
if (!activeJobId) {
saveExtractionDraft(extraction);
setExtractionAndPersist(normalized);
toast({
title: "保存成功",
description: "当前需求编辑结果已保存到本地。",
@@ -250,10 +437,10 @@ export default function RequirementExtractionPage() {
setIsSaving(true);
try {
const saved = await saveSrsRequirements(activeJobId, extraction);
const next = toExtractionResult(saved);
setExtraction(next);
saveExtractionDraft(next);
const saved = await saveSrsRequirements(activeJobId, normalized);
const next = normalizeExtractionResult(toExtractionResult(saved));
setExtractionAndPersist(next);
await loadHistory();
toast({
title: "保存成功",
description: "修改内容已保存到服务端。",
@@ -279,13 +466,125 @@ export default function RequirementExtractionPage() {
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
downloadJson(`requirements-${timestamp}.json`, extraction);
const payload = rebuildRawOutput(
extraction.rawOutput as Record<string, unknown> | undefined,
extraction.requirements,
extraction.documentName
);
downloadJson(`requirements-${timestamp}.json`, payload);
toast({
title: "导出成功",
description: "JSON 文件已下载。",
});
};
const toggleSection = (sectionKey: string) => {
setExpandedSectionKeys((prev) =>
prev.includes(sectionKey)
? prev.filter((key) => key !== sectionKey)
: [...prev, sectionKey]
);
};
const handleLoadHistoryItem = async (jobId: number) => {
try {
const result = await getSrsJobResult(jobId);
const normalized = normalizeExtractionResult(toExtractionResult(result));
setExtractionAndPersist(normalized);
setSelectedRequirementId(normalized.requirements[0]?.id ?? null);
setActiveJobId(jobId);
window.localStorage.setItem(EXTRACTION_JOB_KEY, String(jobId));
setIsHistoryOpen(false);
toast({
title: "加载成功",
description: `已加载 ${normalized.documentName}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "加载历史文件失败";
toast({
title: "加载失败",
description: message,
variant: "destructive",
});
}
};
const handleDeleteHistoryItem = async (jobId: number) => {
try {
await deleteSrsJob(jobId);
if (activeJobId === jobId) {
setActiveJobId(null);
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
setExtraction(null);
setSelectedRequirementId(null);
}
await loadHistory();
toast({
title: "删除成功",
description: "历史文件已删除。",
});
} catch (error) {
const message = error instanceof Error ? error.message : "删除历史文件失败";
toast({
title: "删除失败",
description: message,
variant: "destructive",
});
}
};
const renderSectionNode = (node: SectionTreeNode) => {
const expanded = expandedSectionKeys.includes(node.key);
const sectionLabel =
`${node.sectionNumber ? `${node.sectionNumber} ` : ""}${node.sectionTitle}`.trim() ||
"未归类章节";
return (
<div key={node.key} className="space-y-2">
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted"
onClick={() => toggleSection(node.key)}
>
<ChevronRight
className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-90")}
/>
<div className="flex flex-1 items-center justify-between gap-2">
<p className="text-sm font-medium">{sectionLabel}</p>
<Badge variant="outline">{node.requirements.length}</Badge>
</div>
</button>
{expanded && (
<div className="space-y-2 border-l pl-4">
{node.requirements.map((item) => {
const active = item.id === selectedRequirementId;
return (
<button
key={`${node.key}-${item.id}`}
onClick={() => setSelectedRequirementId(item.id)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
active ? "border-primary bg-primary/5" : "hover:border-primary/40"
)}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm line-clamp-1">{item.id}</p>
<Badge variant={priorityVariant[item.priority]}>{item.priority}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{item.description}
</p>
<p className="mt-2 text-[11px] text-muted-foreground">{item.id}</p>
</button>
);
})}
{node.children.map((child) => renderSectionNode(child))}
</div>
)}
</div>
);
};
return (
<DashboardLayout>
<div className="space-y-6">
@@ -359,6 +658,78 @@ export default function RequirementExtractionPage() {
<Download className="mr-2 h-4 w-4" />
JSON
</Button>
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => void loadHistory()}>
<History className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isHistoryLoading && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
...
</TableCell>
</TableRow>
)}
{!isHistoryLoading && historyItems.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
{!isHistoryLoading &&
historyItems.map((item) => (
<TableRow key={item.jobId}>
<TableCell>{item.documentName}</TableCell>
<TableCell>{item.totalRequirements}</TableCell>
<TableCell>{formatDateTime(item.generatedAt)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleLoadHistoryItem(item.jobId)}
>
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void handleDeleteHistoryItem(item.jobId)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DialogContent>
</Dialog>
<input
ref={jsonInputRef}
type="file"
@@ -380,8 +751,8 @@ export default function RequirementExtractionPage() {
<div className="grid gap-6 lg:grid-cols-[320px_1fr]">
<Card className="min-h-[500px]">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{!extraction && (
@@ -391,34 +762,12 @@ export default function RequirementExtractionPage() {
)}
{extraction && (
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1">
{extraction.requirements.map((item) => {
const active = item.id === selectedRequirementId;
return (
<button
key={item.id}
onClick={() => setSelectedRequirementId(item.id)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "hover:border-primary/40"
)}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm line-clamp-1">{item.title}</p>
<Badge variant={priorityVariant[item.priority]}>
{item.priority}
</Badge>
</div>
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{item.description}
</p>
<p className="mt-2 text-[11px] text-muted-foreground">
{item.id} · {item.sourceField}
</p>
</button>
);
})}
{sectionTree.length === 0 && (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
</div>
)}
{sectionTree.map((node) => renderSectionNode(node))}
</div>
)}
</CardContent>
@@ -427,7 +776,7 @@ export default function RequirementExtractionPage() {
<Card className="min-h-[500px]">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription> JSON</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{!selectedRequirement && (
@@ -452,20 +801,6 @@ export default function RequirementExtractionPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-title"></Label>
<Input
id="req-title"
value={selectedRequirement.title}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
title: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-priority"></Label>
<select
@@ -486,17 +821,28 @@ export default function RequirementExtractionPage() {
</div>
<div className="space-y-2">
<Label htmlFor="req-source"></Label>
<Input
id="req-source"
value={selectedRequirement.sourceField}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
sourceField: event.target.value,
}))
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<Label htmlFor="is-interface"></Label>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Switch
id="is-interface"
checked={selectedRequirement.requirementType === "interface"}
onCheckedChange={(checked) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
requirementType: checked ? "interface" : "functional",
interfaceName: checked ? item.interfaceName || "" : "",
interfaceType: checked ? item.interfaceType || "" : "",
dataSource: checked ? item.dataSource || "" : "",
dataDestination: checked ? item.dataDestination || "" : "",
}))
}
/>
</div>
</div>
<div className="space-y-2">
@@ -528,6 +874,66 @@ export default function RequirementExtractionPage() {
}
/>
</div>
{selectedRequirement.requirementType === "interface" && (
<>
<div className="space-y-2">
<Label htmlFor="req-interface-name"></Label>
<Input
id="req-interface-name"
value={selectedRequirement.interfaceName || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
interfaceName: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-interface-type"></Label>
<Input
id="req-interface-type"
value={selectedRequirement.interfaceType || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
interfaceType: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-data-source"></Label>
<Input
id="req-data-source"
value={selectedRequirement.dataSource || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
dataSource: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-data-destination"></Label>
<Input
id="req-data-destination"
value={selectedRequirement.dataDestination || ""}
onChange={(event) =>
updateRequirement(selectedRequirement.id, (item) => ({
...item,
dataDestination: event.target.value,
}))
}
/>
</div>
</>
)}
</div>
)}
</CardContent>

View File

@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { Braces, FolderCog, Wrench } from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
const plannedFeatures = [
{
title: "仓库接入",
description: "后续接入代码仓库、分支与目录范围管理能力。",
icon: FolderCog,
},
{
title: "代码索引",
description: "后续支持代码解析、结构化索引与语义检索。",
icon: Braces,
},
{
title: "能力扩展",
description: "后续补充问答、关联分析与工程化工具链集成。",
icon: Wrench,
},
];
export default function CodeKnowledgeBasePage() {
return (
<DashboardLayout>
<div className="space-y-8">
<div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="max-w-3xl text-muted-foreground">
</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
{plannedFeatures.map((item) => (
<section
key={item.title}
className="space-y-4 rounded-lg border bg-card p-6"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<item.icon className="h-5 w-5" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</section>
))}
</div>
<section className="space-y-4 rounded-lg border border-dashed bg-card p-6">
<div className="space-y-2">
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/dashboard/knowledge/document"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
</Link>
</div>
</section>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FileIcon, defaultStyles } from "react-file-icon";
import {
ArrowRight,
ChevronRight,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
import { api, ApiError } from "@/lib/api";
import { useToast } from "@/components/ui/use-toast";
interface KnowledgeBase {
id: number;
name: string;
description: string;
documents: Document[];
created_at: string;
}
interface Document {
id: number;
file_name: string;
file_path: string;
file_size: number;
content_type: string;
knowledge_base_id: number;
created_at: string;
updated_at: string;
processing_tasks: any[];
}
export default function DocumentKnowledgeBasePage() {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [collapsedDocumentSections, setCollapsedDocumentSections] = useState<
Record<number, boolean>
>({});
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
void fetchKnowledgeBases();
}, []);
const fetchKnowledgeBases = async () => {
try {
const data = await api.get("/api/knowledge-base");
setKnowledgeBases(data);
} catch (error) {
console.error("Failed to fetch knowledge bases:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm("确定要删除这个知识库吗?")) {
return;
}
try {
await api.delete(`/api/knowledge-base/${id}`);
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
toast({
title: "成功",
description: "知识库删除成功",
});
} catch (error) {
console.error("Failed to delete knowledge base:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
}
};
const toggleDocumentsSection = (knowledgeBaseId: number) => {
setCollapsedDocumentSections((prev) => ({
...prev,
[knowledgeBaseId]: !prev[knowledgeBaseId],
}));
};
return (
<DashboardLayout>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Link
href="/dashboard/knowledge/new"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-2 h-4 w-4" />
</Link>
</div>
<div className="grid gap-6">
{knowledgeBases.map((kb) => {
const isDocumentsCollapsed =
collapsedDocumentSections[kb.id] ?? true;
return (
<div
key={kb.id}
className="space-y-4 rounded-lg border bg-card p-6"
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{kb.name}</h3>
<p className="text-sm text-muted-foreground">
{kb.description || "暂无描述"}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{kb.documents.length} ·{" "}
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
<div className="flex space-x-2">
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-secondary"
>
<Settings className="h-4 w-4" />
</Link>
<Link
href={`/dashboard/test-retrieval/${kb.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-secondary"
>
<Search className="h-4 w-4" />
</Link>
<button
onClick={() => void handleDelete(kb.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</div>
{kb.documents.length > 0 && (
<div className="border-t pt-4">
<button
type="button"
onClick={() => toggleDocumentsSection(kb.id)}
className="mb-2 flex w-full items-center justify-between rounded-md px-1 py-1 text-left hover:bg-accent/50"
>
<h4 className="text-sm font-medium"></h4>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{isDocumentsCollapsed ? "展开" : "收起"}</span>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isDocumentsCollapsed ? "" : "rotate-90"
}`}
/>
</div>
</button>
{!isDocumentsCollapsed && (
<div className="flex max-h-[400px] flex-wrap gap-2 overflow-y-auto">
{kb.documents.slice(0, 9).map((doc) => (
<div
key={doc.id}
className="flex h-[150px] w-[150px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border bg-card p-2 transition-colors hover:bg-accent/50"
>
<div className="mb-2 h-8 w-8">
{doc.content_type.toLowerCase().includes("pdf") ? (
<FileIcon extension="pdf" {...defaultStyles.pdf} />
) : doc.content_type.toLowerCase().includes("doc") ? (
<FileIcon extension="doc" {...defaultStyles.docx} />
) : doc.content_type.toLowerCase().includes("txt") ? (
<FileIcon extension="txt" {...defaultStyles.txt} />
) : doc.content_type.toLowerCase().includes("md") ? (
<FileIcon extension="md" {...defaultStyles.md} />
) : (
<FileIcon
extension={doc.file_name.split(".").pop() || ""}
color="#E2E8F0"
labelColor="#94A3B8"
/>
)}
</div>
<div className="max-w-[100px] text-center text-sm font-medium">
<div className="line-clamp-2 overflow-hidden text-ellipsis">
{doc.file_name}
</div>
</div>
<span className="mt-1 text-xs text-muted-foreground">
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
</span>
</div>
))}
{kb.documents.length > 9 && (
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="flex h-[150px] w-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border bg-card p-2 transition-colors hover:bg-accent/50"
>
<div className="mb-2 flex h-8 w-8 items-center justify-center">
<ArrowRight className="h-6 w-6" />
</div>
<span className="text-center text-sm font-medium">
</span>
<span className="mt-1 text-xs text-muted-foreground">
{kb.documents.length}
</span>
</Link>
)}
</div>
)}
</div>
)}
</div>
);
})}
{!loading && knowledgeBases.length === 0 && (
<div className="py-12 text-center">
<p className="text-muted-foreground"></p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="space-y-4">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
<p className="animate-pulse text-muted-foreground">
...
</p>
</div>
</div>
)}
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -1,259 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FileIcon, defaultStyles } from "react-file-icon";
import {
ArrowRight,
ChevronRight,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
import { api, ApiError } from "@/lib/api";
import { useToast } from "@/components/ui/use-toast";
interface KnowledgeBase {
id: number;
name: string;
description: string;
documents: Document[];
created_at: string;
}
interface Document {
id: number;
file_name: string;
file_path: string;
file_size: number;
content_type: string;
knowledge_base_id: number;
created_at: string;
updated_at: string;
processing_tasks: any[];
}
export default function KnowledgeBasePage() {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [collapsedDocumentSections, setCollapsedDocumentSections] = useState<
Record<number, boolean>
>({});
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
fetchKnowledgeBases();
}, []);
const fetchKnowledgeBases = async () => {
try {
const data = await api.get("/api/knowledge-base");
setKnowledgeBases(data);
} catch (error) {
console.error("Failed to fetch knowledge bases:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm("确定要删除这个知识库吗?"))
return;
try {
await api.delete(`/api/knowledge-base/${id}`);
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
toast({
title: "成功",
description: "知识库删除成功",
});
} catch (error) {
console.error("Failed to delete knowledge base:", error);
if (error instanceof ApiError) {
toast({
title: "错误",
description: error.message,
variant: "destructive",
});
}
}
};
const toggleDocumentsSection = (knowledgeBaseId: number) => {
setCollapsedDocumentSections((prev) => ({
...prev,
[knowledgeBaseId]: !prev[knowledgeBaseId],
}));
};
return (
<DashboardLayout>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold tracking-tight">
</h2>
<p className="text-muted-foreground">
</p>
</div>
<Link
href="/dashboard/knowledge/new"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-2 h-4 w-4" />
</Link>
</div>
<div className="grid gap-6">
{knowledgeBases.map((kb) => {
const isDocumentsCollapsed =
collapsedDocumentSections[kb.id] ?? true;
return (
<div
key={kb.id}
className="rounded-lg border bg-card p-6 space-y-4"
>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">{kb.name}</h3>
<p className="text-sm text-muted-foreground">
{kb.description || "暂无描述"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{kb.documents.length} {" "}
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
<div className="flex space-x-2">
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
>
<Settings className="h-4 w-4" />
</Link>
<Link
href={`/dashboard/test-retrieval/${kb.id}`}
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
>
<Search className="h-4 w-4" />
</Link>
<button
onClick={() => handleDelete(kb.id)}
className="inline-flex items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20 w-8 h-8"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</div>
{kb.documents.length > 0 && (
<div className="border-t pt-4">
<button
type="button"
onClick={() => toggleDocumentsSection(kb.id)}
className="mb-2 flex w-full items-center justify-between rounded-md px-1 py-1 text-left hover:bg-accent/50"
>
<h4 className="text-sm font-medium"></h4>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{isDocumentsCollapsed ? "展开" : "收起"}</span>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isDocumentsCollapsed ? "" : "rotate-90"
}`}
/>
</div>
</button>
{!isDocumentsCollapsed && (
<div className="flex flex-wrap gap-2 max-h-[400px] overflow-y-auto">
{kb.documents.slice(0, 9).map((doc) => (
<div
key={doc.id}
className="flex flex-col items-center gap-2 p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
>
<div className="w-8 h-8 mb-2">
{doc.content_type.toLowerCase().includes("pdf") ? (
<FileIcon extension="pdf" {...defaultStyles.pdf} />
) : doc.content_type.toLowerCase().includes("doc") ? (
<FileIcon extension="doc" {...defaultStyles.docx} />
) : doc.content_type.toLowerCase().includes("txt") ? (
<FileIcon extension="txt" {...defaultStyles.txt} />
) : doc.content_type.toLowerCase().includes("md") ? (
<FileIcon extension="md" {...defaultStyles.md} />
) : (
<FileIcon
extension={doc.file_name.split(".").pop() || ""}
color="#E2E8F0"
labelColor="#94A3B8"
/>
)}
</div>
<div className="text-sm font-medium text-center max-w-[100px]">
<div className="line-clamp-2 overflow-hidden text-ellipsis">
{doc.file_name}
</div>
</div>
<span className="text-xs text-muted-foreground mt-1">
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
</span>
</div>
))}
{kb.documents.length > 9 && (
<Link
href={`/dashboard/knowledge/${kb.id}`}
className="flex flex-col items-center p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
>
<div className="w-8 h-8 mb-2 flex items-center justify-center">
<ArrowRight className="w-6 h-6" />
</div>
<span className="text-sm font-medium text-center">
</span>
<span className="text-xs text-muted-foreground mt-1">
{kb.documents.length}
</span>
</Link>
)}
</div>
)}
</div>
)}
</div>
);
})}
{!loading && knowledgeBases.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">
</p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="space-y-4">
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto"></div>
<p className="text-muted-foreground animate-pulse">
...
</p>
</div>
</div>
)}
</div>
</div>
</DashboardLayout>
);
export default function KnowledgePage() {
redirect("/dashboard/knowledge/document");
}

View File

@@ -99,7 +99,7 @@ export default function DashboardPage() {
</div>
</div>
<a
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="mt-6 flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium"
>
@@ -152,7 +152,7 @@ export default function DashboardPage() {
</a>
<a
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="flex flex-col items-center justify-center rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all hover:border-indigo-500 dark:hover:border-indigo-500"
>
<div className="rounded-full bg-indigo-100 dark:bg-indigo-900/30 p-4 mb-4">
@@ -222,7 +222,7 @@ export default function DashboardPage() {
PDFDOCXMD TXT AI
</p>
<a
href="/dashboard/knowledge"
href="/dashboard/knowledge/document"
className="mt-4 inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 text-sm font-medium"
>

View File

@@ -1,29 +1,32 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import {
Book,
ChevronRight,
FileText,
MessageSquare,
LogOut,
Menu,
MessageSquare,
Search,
User,
} from "lucide-react";
import Breadcrumb from "@/components/ui/breadcrumb";
import { cn } from "@/lib/utils";
type NavigationChild = {
name: string;
href: string;
};
type NavigationItem = {
name: string;
icon: React.ComponentType<{ className?: string }>;
href?: string;
children?: Array<{
name: string;
href: string;
}>;
children?: NavigationChild[];
defaultOpen?: boolean;
};
export default function DashboardLayout({
@@ -34,9 +37,10 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isDocProcessingOpen, setIsDocProcessingOpen] = useState(
pathname.startsWith("/dashboard/doc-processing")
);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
knowledge: pathname.startsWith("/dashboard/knowledge"),
"doc-processing": pathname.startsWith("/dashboard/doc-processing"),
});
useEffect(() => {
const token = localStorage.getItem("token");
@@ -51,13 +55,31 @@ export default function DashboardLayout({
};
useEffect(() => {
if (pathname.startsWith("/dashboard/knowledge")) {
setOpenGroups((prev) => ({ ...prev, knowledge: true }));
}
if (pathname.startsWith("/dashboard/doc-processing")) {
setIsDocProcessingOpen(true);
setOpenGroups((prev) => ({ ...prev, "doc-processing": true }));
}
}, [pathname]);
const toggleGroup = (key: string) => {
setOpenGroups((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const navigation: NavigationItem[] = [
{ name: "知识库", href: "/dashboard/knowledge", icon: Book },
{
name: "知识库",
icon: Book,
defaultOpen: true,
children: [
{ name: "文档知识库", href: "/dashboard/knowledge/document" },
{ name: "代码知识库", href: "/dashboard/knowledge/code" },
],
},
{ name: "对话", href: "/dashboard/chat", icon: MessageSquare },
{
name: "文档处理",
@@ -80,42 +102,39 @@ export default function DashboardLayout({
return (
<div className="min-h-screen bg-background">
{/* Mobile menu button */}
<div className="lg:hidden fixed top-0 left-0 m-4 z-50">
<div className="fixed left-0 top-0 z-50 m-4 lg:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2 rounded-md bg-primary text-primary-foreground"
className="rounded-md bg-primary p-2 text-primary-foreground"
>
<Menu className="h-6 w-6" />
</button>
</div>
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-40 w-64 transform bg-card border-r transition-transform duration-200 ease-in-out lg:translate-x-0 ${
className={`fixed inset-y-0 left-0 z-40 w-64 transform border-r bg-card transition-transform duration-200 ease-in-out lg:translate-x-0 ${
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-full flex-col">
{/* Sidebar header */}
<div className="flex h-16 items-center border-b pl-8">
<Link
href="/dashboard"
className="flex items-center text-lg font-semibold hover:text-primary transition-colors"
className="flex items-center text-lg font-semibold transition-colors hover:text-primary"
>
<img
src="/logo.svg"
alt="标志"
className="w-16 h-16 rounded-lg"
/>
<img src="/logo.svg" alt="标志" className="h-16 w-16 rounded-lg" />
RAG
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-2 px-4 py-6">
{navigation.map((item) => {
if (item.children) {
const groupKey =
item.children[0]?.href.split("/")[2] === "knowledge"
? "knowledge"
: "doc-processing";
const isOpen = openGroups[groupKey] ?? item.defaultOpen ?? false;
const hasActiveChild = item.children.some((child) =>
pathname.startsWith(child.href)
);
@@ -124,7 +143,7 @@ export default function DashboardLayout({
<div key={item.name} className="space-y-2">
<button
type="button"
onClick={() => setIsDocProcessingOpen((prev) => !prev)}
onClick={() => toggleGroup(groupKey)}
className={cn(
"group flex w-full items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200",
hasActiveChild
@@ -136,7 +155,7 @@ export default function DashboardLayout({
className={cn(
"mr-3 h-5 w-5 transition-transform duration-200",
hasActiveChild
? "text-primary scale-110"
? "scale-110 text-primary"
: "group-hover:scale-110"
)}
/>
@@ -144,12 +163,12 @@ export default function DashboardLayout({
<ChevronRight
className={cn(
"ml-auto h-4 w-4 transition-transform duration-200",
isDocProcessingOpen ? "rotate-90" : ""
isOpen && "rotate-90"
)}
/>
</button>
{isDocProcessingOpen && (
{isOpen && (
<div className="ml-7 space-y-1 border-l pl-3">
{item.children.map((child) => {
const isChildActive = pathname.startsWith(child.href);
@@ -188,18 +207,20 @@ export default function DashboardLayout({
<Link
key={item.name}
href={item.href || "/dashboard"}
className={`group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200 ${
className={cn(
"group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200",
isActive
? "bg-gradient-to-r from-primary/10 to-primary/5 text-primary shadow-sm"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground hover:shadow-sm"
}`}
)}
>
<item.icon
className={`mr-3 h-5 w-5 transition-transform duration-200 ${
className={cn(
"mr-3 h-5 w-5 transition-transform duration-200",
isActive
? "text-primary scale-110"
? "scale-110 text-primary"
: "group-hover:scale-110"
}`}
)}
/>
<span className="font-medium">{item.name}</span>
{isActive && (
@@ -209,11 +230,11 @@ export default function DashboardLayout({
);
})}
</nav>
{/* User profile and logout */}
<div className="border-t p-4 space-y-4">
<div className="space-y-4 border-t p-4">
<button
onClick={handleLogout}
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors duration-200"
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-destructive transition-colors duration-200 hover:bg-destructive/10"
>
<LogOut className="mr-3 h-4 w-4" />
退
@@ -222,9 +243,8 @@ export default function DashboardLayout({
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
<main className="min-h-screen py-6 px-4 sm:px-6 lg:px-8">
<main className="min-h-screen px-4 py-6 sm:px-6 lg:px-8">
<Breadcrumb />
{children}
</main>
@@ -237,10 +257,15 @@ export const dashboardConfig = {
mainNav: [],
sidebarNav: [
{
title: "知识库",
href: "/dashboard/knowledge",
title: "文档知识库",
href: "/dashboard/knowledge/document",
icon: "database",
},
{
title: "代码知识库",
href: "/dashboard/knowledge/code",
icon: "braces",
},
{
title: "对话",
href: "/dashboard/chat",

View File

@@ -9,6 +9,8 @@ const Breadcrumb = () => {
const labelMap: Record<string, string> = {
dashboard: "主界面",
knowledge: "知识库",
document: "文档知识库",
code: "代码知识库",
chat: "对话",
"doc-processing": "文档处理",
extract: "需求提取",
@@ -24,11 +26,10 @@ const Breadcrumb = () => {
const generateBreadcrumbs = () => {
const paths = pathname.split("/").filter(Boolean);
const breadcrumbs = paths.map((path, index) => {
return paths.map((path, index) => {
const href = "/" + paths.slice(0, index + 1).join("/");
const label = labelMap[path] || path.replace(/-/g, " ");
const isLast = index === paths.length - 1;
const displayLabel = /^\d+$/.test(path) ? "详情" : label;
return {
@@ -37,8 +38,6 @@ const Breadcrumb = () => {
isLast,
};
});
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs();
@@ -46,25 +45,25 @@ const Breadcrumb = () => {
if (pathname === "/") return null;
return (
<nav className="flex items-center space-x-2 text-base text-muted-foreground mb-6">
<nav className="mb-6 flex items-center space-x-2 text-base text-muted-foreground">
<Link
href="/dashboard"
className="flex items-center hover:text-foreground transition-colors"
className="flex items-center transition-colors hover:text-foreground"
>
<Home className="h-4 w-4" />
</Link>
{breadcrumbs.map((breadcrumb, index) => (
{breadcrumbs.map((breadcrumb) => (
<div key={breadcrumb.href} className="flex items-center">
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/50" />
<ChevronRight className="mx-2 h-4 w-4 text-muted-foreground/50" />
{breadcrumb.isLast ? (
<span className="text-foreground font-medium">
<span className="font-medium text-foreground">
{breadcrumb.label}
</span>
) : (
<Link
href={breadcrumb.href}
className="hover:text-foreground transition-colors"
className="transition-colors hover:text-foreground"
>
{breadcrumb.label}
</Link>

View File

@@ -1,19 +1,36 @@
export type PriorityLevel = "高" | "中" | "低";
export type SeverityLevel = "高" | "中" | "低";
export type RequirementType =
| "functional"
| "interface"
| "performance"
| "security"
| "reliability"
| "other";
export interface RequirementItem {
id: string;
title: string;
title?: string;
description: string;
priority: PriorityLevel;
acceptanceCriteria: string[];
sourceField: string;
sectionUid?: string;
sectionNumber?: string;
sectionTitle?: string;
requirementType?: RequirementType;
interfaceName?: string;
interfaceType?: string;
dataSource?: string;
dataDestination?: string;
sortOrder?: number;
}
export interface RequirementExtractionResult {
documentName: string;
generatedAt: string;
requirements: RequirementItem[];
rawOutput?: Record<string, unknown>;
}
export interface TestCaseItem {
@@ -72,6 +89,52 @@ const asPriority = (value: unknown): PriorityLevel => {
return "中";
};
const asRequirementType = (value: unknown): RequirementType => {
if (
value === "functional" ||
value === "interface" ||
value === "performance" ||
value === "security" ||
value === "reliability" ||
value === "other"
) {
return value;
}
if (value === "接口需求") {
return "interface";
}
if (value === "性能需求") {
return "performance";
}
if (value === "安全需求") {
return "security";
}
if (value === "可靠性需求") {
return "reliability";
}
if (value === "其他需求") {
return "other";
}
return "functional";
};
const summarizeTitle = (value: string, fallbackIndex: number) => {
const source = (value || "").trim();
if (!source) {
return `需求项 ${fallbackIndex + 1}`;
}
for (const separator of ["。", "", "\n", ";", "."]) {
if (source.includes(separator)) {
const first = source.split(separator, 1)[0]?.trim();
if (first) {
return first.slice(0, 20);
}
}
}
return source.slice(0, 20);
};
const asSeverity = (value: unknown): SeverityLevel => {
if (value === "高" || value === "中" || value === "低") {
return value;
@@ -139,7 +202,7 @@ const normalizeRequirementItem = (
title:
typeof item.title === "string" && item.title.trim().length > 0
? item.title
: `未命名需求 ${fallbackIndex + 1}`,
: undefined,
description:
typeof item.description === "string" ? item.description : "",
priority: asPriority(item.priority),
@@ -149,9 +212,227 @@ const normalizeRequirementItem = (
typeof item.sourceField === "string" && item.sourceField.trim().length > 0
? item.sourceField
: `章节 ${fallbackIndex + 1}`,
sectionUid:
typeof item.sectionUid === "string" ? item.sectionUid : undefined,
sectionNumber:
typeof item.sectionNumber === "string" ? item.sectionNumber : undefined,
sectionTitle:
typeof item.sectionTitle === "string" ? item.sectionTitle : undefined,
requirementType: asRequirementType(item.requirementType),
interfaceName:
typeof item.interfaceName === "string" ? item.interfaceName : "",
interfaceType:
typeof item.interfaceType === "string" ? item.interfaceType : "",
dataSource:
typeof item.dataSource === "string" ? item.dataSource : "",
dataDestination:
typeof item.dataDestination === "string" ? item.dataDestination : "",
sortOrder:
typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder)
? item.sortOrder
: fallbackIndex,
};
};
const buildRawOutputFromRequirements = (
requirements: RequirementItem[],
documentName: string,
generatedAt: string
): Record<string, unknown> => {
const sectionMap = new Map<
string,
{
sectionNumber: string;
sectionTitle: string;
list: Array<Record<string, unknown>>;
}
>();
const byType: Record<string, number> = {};
requirements.forEach((req) => {
const number = req.sectionNumber || "";
const title = req.sectionTitle || "未归类章节";
const key = `${number}__${title}`;
if (!sectionMap.has(key)) {
sectionMap.set(key, {
sectionNumber: number,
sectionTitle: title,
list: [],
});
}
const reqType = asRequirementType(req.requirementType);
const typeLabel =
reqType === "interface"
? "接口需求"
: reqType === "performance"
? "性能需求"
: reqType === "security"
? "安全需求"
: reqType === "reliability"
? "可靠性需求"
: reqType === "other"
? "其他需求"
: "功能需求";
byType[typeLabel] = (byType[typeLabel] || 0) + 1;
const reqEntry: Record<string, unknown> = {
需求类型: typeLabel,
需求编号: req.id,
需求描述: req.description,
优先级: req.priority || "中",
};
if (reqType === "interface") {
reqEntry["接口名称"] = req.interfaceName || "";
reqEntry["接口类型"] = req.interfaceType || "";
reqEntry["数据来源"] = req.dataSource || "";
reqEntry["数据目的地"] = req.dataDestination || "";
}
sectionMap.get(key)?.list.push(reqEntry);
});
const content: Record<string, unknown> = {};
for (const section of sectionMap.values()) {
const display = `${section.sectionNumber} ${section.sectionTitle}`.trim();
content[display || "未归类章节"] = {
: {
章节编号: section.sectionNumber,
章节标题: section.sectionTitle,
章节级别: section.sectionNumber
? section.sectionNumber.split(".").length
: 1,
},
需求列表: section.list,
};
}
return {
: {
标题: documentName,
生成时间: generatedAt,
总需求数: requirements.length,
需求类型统计: byType,
},
需求内容: content,
};
};
const parseRawOutputRequirements = (parsed: Record<string, unknown>) => {
const metadata =
parsed["文档元数据"] && typeof parsed["文档元数据"] === "object"
? (parsed["文档元数据"] as Record<string, unknown>)
: {};
const content =
parsed["需求内容"] && typeof parsed["需求内容"] === "object"
? (parsed["需求内容"] as Record<string, unknown>)
: null;
if (!content) {
throw new Error("JSON 中缺少 需求内容 字段");
}
const requirements: RequirementItem[] = [];
const walk = (node: unknown) => {
if (!node || typeof node !== "object") {
return;
}
const sectionNode = node as Record<string, unknown>;
const sectionInfo =
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
? (sectionNode["章节信息"] as Record<string, unknown>)
: {};
const sectionNumber =
typeof sectionInfo["章节编号"] === "string" ? sectionInfo["章节编号"] : "";
const sectionTitle =
typeof sectionInfo["章节标题"] === "string"
? sectionInfo["章节标题"]
: "未归类章节";
const sectionUid =
typeof sectionInfo["章节UID"] === "string" ? sectionInfo["章节UID"] : undefined;
const reqList = Array.isArray(sectionNode["需求列表"])
? (sectionNode["需求列表"] as Array<Record<string, unknown>>)
: [];
reqList.forEach((req, index) => {
const description =
typeof req["需求描述"] === "string" ? req["需求描述"] : "";
const reqType = asRequirementType(req["需求类型"]);
requirements.push(
normalizeRequirementItem(
{
id:
typeof req["需求编号"] === "string" && req["需求编号"].trim().length > 0
? req["需求编号"]
: undefined,
title:
typeof req["需求标题"] === "string" && req["需求标题"].trim().length > 0
? req["需求标题"]
: undefined,
description,
priority: asPriority(req["优先级"]),
acceptanceCriteria: description ? [description] : ["待补充验收标准"],
sourceField: `${sectionNumber} ${sectionTitle}`.trim() || "文档解析",
sectionUid,
sectionNumber,
sectionTitle,
requirementType: reqType,
interfaceName:
reqType === "interface" && typeof req["接口名称"] === "string"
? req["接口名称"]
: "",
interfaceType:
reqType === "interface" && typeof req["接口类型"] === "string"
? req["接口类型"]
: "",
dataSource:
reqType === "interface" && typeof req["数据来源"] === "string"
? req["数据来源"]
: "",
dataDestination:
reqType === "interface" && typeof req["数据目的地"] === "string"
? req["数据目的地"]
: "",
sortOrder: requirements.length + index,
},
requirements.length + index
)
);
});
const children =
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
? (sectionNode["子章节"] as Record<string, unknown>)
: null;
if (!children) {
return;
}
Object.values(children).forEach((child) => walk(child));
};
Object.values(content).forEach((section) => walk(section));
const generatedAt =
typeof metadata["生成时间"] === "string" && metadata["生成时间"].trim().length > 0
? metadata["生成时间"]
: toIso();
const documentName =
typeof metadata["标题"] === "string" && metadata["标题"].trim().length > 0
? metadata["标题"]
: "导入需求文件";
return {
documentName,
generatedAt,
requirements,
rawOutput: parsed,
} as RequirementExtractionResult;
};
export const parseRequirementJson = (
content: string
): RequirementExtractionResult => {
@@ -165,8 +446,18 @@ export const parseRequirementJson = (
documentName?: unknown;
generatedAt?: unknown;
requirements?: unknown;
[key: string]: unknown;
};
if (
value["需求内容"] &&
typeof value["需求内容"] === "object" &&
value["文档元数据"] &&
typeof value["文档元数据"] === "object"
) {
return parseRawOutputRequirements(value as Record<string, unknown>);
}
if (!Array.isArray(value.requirements)) {
throw new Error("JSON 中缺少 requirements 数组字段");
}
@@ -185,6 +476,15 @@ export const parseRequirementJson = (
? value.generatedAt
: toIso(),
requirements,
rawOutput: buildRawOutputFromRequirements(
requirements,
typeof value.documentName === "string" && value.documentName.trim().length > 0
? value.documentName
: "导入需求文件",
typeof value.generatedAt === "string" && value.generatedAt.trim().length > 0
? value.generatedAt
: toIso()
),
};
};
@@ -238,31 +538,34 @@ export const mockGenerateTestCases = async (
): Promise<TestCaseGenerationResult> => {
await wait(WAIT_TIME.generateCases);
const cases: TestCaseItem[] = extraction.requirements.map((item, index) => ({
id: `TC-${String(index + 1).padStart(3, "0")}`,
requirementId: item.id,
requirementTitle: item.title,
title: `${item.title} - 功能验证`,
preconditions: [
"系统已启动并完成账号登录",
"测试数据准备完毕",
"目标模块已开启对应配置",
],
steps: [
`进入 ${item.sourceField} 对应功能页面`,
"输入合法数据并提交",
"观察页面反馈与状态变化",
"触发一次异常输入场景",
"再次执行提交并确认恢复能力",
],
expectedResults: [
"系统返回成功提示且状态更新正确",
"异常场景出现清晰错误提示,不会清空已填内容",
"操作日志可追踪,结果可导出",
],
priority: item.priority,
tags: ["自动生成", "需求映射", item.sourceField],
}));
const cases: TestCaseItem[] = extraction.requirements.map((item, index) => {
const displayTitle = item.title || summarizeTitle(item.description, index);
return {
id: `TC-${String(index + 1).padStart(3, "0")}`,
requirementId: item.id,
requirementTitle: displayTitle,
title: `${displayTitle} - 功能验证`,
preconditions: [
"系统已启动并完成账号登录",
"测试数据准备完毕",
"目标模块已开启对应配置",
],
steps: [
`进入 ${item.sourceField} 对应功能页面`,
"输入合法数据并提交",
"观察页面反馈与状态变化",
"触发一次异常输入场景",
"再次执行提交并确认恢复能力",
],
expectedResults: [
"系统返回成功提示且状态更新正确",
"异常场景出现清晰错误提示,不会清空已填内容",
"操作日志可追踪,结果可导出",
],
priority: item.priority,
tags: ["自动生成", "需求映射", item.sourceField],
};
});
return {
sourceDocument: extraction.documentName,
@@ -406,6 +709,16 @@ export const loadExtractionDraft = () => {
requirements: value.requirements.map((item, index) =>
normalizeRequirementItem(item, index)
),
rawOutput:
value.rawOutput && typeof value.rawOutput === "object"
? value.rawOutput
: buildRawOutputFromRequirements(
value.requirements.map((item, index) =>
normalizeRequirementItem(item, index)
),
value.documentName,
value.generatedAt
),
};
};

View File

@@ -0,0 +1,543 @@
import { RequirementItem, RequirementType } from "@/lib/document-mock";
export interface SectionTreeNode {
key: string;
sectionNumber: string;
sectionTitle: string;
level: number;
requirements: RequirementItem[];
children: SectionTreeNode[];
}
const TYPE_LABEL: Record<RequirementType, string> = {
functional: "功能需求",
interface: "接口需求",
performance: "性能需求",
security: "安全需求",
reliability: "可靠性需求",
other: "其他需求",
};
const LABEL_TYPE: Record<string, RequirementType> = Object.entries(TYPE_LABEL).reduce(
(acc, [type, label]) => {
acc[label] = type as RequirementType;
return acc;
},
{} as Record<string, RequirementType>
);
const asRequirementType = (value: unknown): RequirementType => {
if (
value === "functional" ||
value === "interface" ||
value === "performance" ||
value === "security" ||
value === "reliability" ||
value === "other"
) {
return value;
}
if (typeof value === "string" && LABEL_TYPE[value]) {
return LABEL_TYPE[value];
}
return "functional";
};
const sectionKey = (number?: string, title?: string) =>
`${(number || "").trim()}__${(title || "").trim()}`;
const normalizeSectionNumber = (value?: string) =>
(value || "").replace(/\s+/g, "").trim();
const parseSectionParts = (value?: string): number[] | null => {
const normalized = normalizeSectionNumber(value);
if (!normalized || !/^\d+(?:\.\d+)*$/.test(normalized)) {
return null;
}
return normalized.split(".").map((part) => Number(part));
};
const compareSectionNumbers = (left?: string, right?: string) => {
const leftParts = parseSectionParts(left);
const rightParts = parseSectionParts(right);
if (leftParts && rightParts) {
const len = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < len; index += 1) {
const a = leftParts[index];
const b = rightParts[index];
if (a === undefined) {
return -1;
}
if (b === undefined) {
return 1;
}
if (a !== b) {
return a - b;
}
}
return 0;
}
if (leftParts) {
return -1;
}
if (rightParts) {
return 1;
}
return (left || "").localeCompare(right || "", "zh-CN", { numeric: true });
};
const compareSectionNodes = (left: SectionTreeNode, right: SectionTreeNode) => {
const byNumber = compareSectionNumbers(left.sectionNumber, right.sectionNumber);
if (byNumber !== 0) {
return byNumber;
}
return left.sectionTitle.localeCompare(right.sectionTitle, "zh-CN", {
numeric: true,
});
};
const sortRequirements = (requirements: RequirementItem[]) => {
return [...requirements].sort((left, right) => {
const leftOrder = typeof left.sortOrder === "number" ? left.sortOrder : Number.MAX_SAFE_INTEGER;
const rightOrder = typeof right.sortOrder === "number" ? right.sortOrder : Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.id.localeCompare(right.id, "zh-CN", { numeric: true });
});
};
const sortSectionTree = (nodes: SectionTreeNode[]): SectionTreeNode[] => {
return [...nodes]
.map((node) => ({
...node,
requirements: sortRequirements(node.requirements),
children: sortSectionTree(node.children),
}))
.sort(compareSectionNodes);
};
const flattenSectionTree = (nodes: SectionTreeNode[]): SectionTreeNode[] => {
const result: SectionTreeNode[] = [];
const walk = (node: SectionTreeNode) => {
result.push(node);
node.children.forEach((child) => walk(child));
};
nodes.forEach((node) => walk(node));
return result;
};
const buildSectionTreeWithParents = (sourceNodes: SectionTreeNode[]): SectionTreeNode[] => {
const numbered = new Map<string, SectionTreeNode>();
const fallbackNodes: SectionTreeNode[] = [];
const flattened = flattenSectionTree(sourceNodes);
flattened.forEach((source) => {
const sectionNumber = normalizeSectionNumber(source.sectionNumber);
const normalized: SectionTreeNode = {
...source,
sectionNumber,
requirements: sortRequirements(source.requirements),
children: [],
};
const numberParts = parseSectionParts(sectionNumber);
if (!numberParts) {
fallbackNodes.push(normalized);
return;
}
const existing = numbered.get(sectionNumber);
if (!existing) {
numbered.set(sectionNumber, normalized);
return;
}
existing.requirements = sortRequirements([...existing.requirements, ...normalized.requirements]);
if (!existing.sectionTitle && normalized.sectionTitle) {
existing.sectionTitle = normalized.sectionTitle;
}
if (existing.sectionTitle === "未归类章节" && normalized.sectionTitle) {
existing.sectionTitle = normalized.sectionTitle;
}
});
Array.from(numbered.keys()).forEach((number) => {
const parts = parseSectionParts(number);
if (!parts || parts.length <= 1) {
return;
}
for (let index = 1; index < parts.length; index += 1) {
const parentNumber = parts.slice(0, index).join(".");
if (numbered.has(parentNumber)) {
continue;
}
numbered.set(parentNumber, {
key: `synthetic-${parentNumber}`,
sectionNumber: parentNumber,
sectionTitle: "",
level: index,
requirements: [],
children: [],
});
}
});
const roots: SectionTreeNode[] = [];
const sortedNumbers = Array.from(numbered.keys()).sort(compareSectionNumbers);
sortedNumbers.forEach((number) => {
const node = numbered.get(number);
if (!node) {
return;
}
const parts = parseSectionParts(number);
node.level = parts ? parts.length : node.level;
if (!parts || parts.length <= 1) {
roots.push(node);
return;
}
const parentNumber = parts.slice(0, -1).join(".");
const parent = numbered.get(parentNumber);
if (!parent) {
roots.push(node);
return;
}
parent.children.push(node);
});
const sortedRoots = sortSectionTree(roots);
const sortedFallback = sortSectionTree(fallbackNodes);
return [...sortedRoots, ...sortedFallback];
};
const deepClone = (value: Record<string, unknown>) =>
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
const toRawRequirement = (item: RequirementItem): Record<string, unknown> => {
const reqType = asRequirementType(item.requirementType);
const raw: Record<string, unknown> = {
需求类型: TYPE_LABEL[reqType],
需求编号: item.id,
需求描述: item.description,
优先级: item.priority || "中",
};
if (reqType === "interface") {
raw["接口名称"] = item.interfaceName || "";
raw["接口类型"] = item.interfaceType || "";
raw["数据来源"] = item.dataSource || "";
raw["数据目的地"] = item.dataDestination || "";
}
return raw;
};
const groupRequirementsBySection = (requirements: RequirementItem[]) => {
const grouped = new Map<string, RequirementItem[]>();
requirements.forEach((item) => {
const key = sectionKey(item.sectionNumber, item.sectionTitle);
const list = grouped.get(key) || [];
list.push(item);
grouped.set(key, list);
});
for (const value of grouped.values()) {
value.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
return grouped;
};
const rewriteContentRequirements = (
content: Record<string, unknown>,
grouped: Map<string, RequirementItem[]>
) => {
Object.values(content).forEach((section) => {
if (!section || typeof section !== "object") {
return;
}
const sectionNode = section as Record<string, unknown>;
const sectionInfo =
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
? (sectionNode["章节信息"] as Record<string, unknown>)
: {};
const key = sectionKey(
typeof sectionInfo["章节编号"] === "string" ? sectionInfo["章节编号"] : "",
typeof sectionInfo["章节标题"] === "string" ? sectionInfo["章节标题"] : ""
);
const sectionReqs = grouped.get(key);
if (sectionReqs) {
sectionNode["需求列表"] = sectionReqs.map((item) => toRawRequirement(item));
grouped.delete(key);
} else if (Array.isArray(sectionNode["需求列表"])) {
sectionNode["需求列表"] = [];
}
const children =
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
? (sectionNode["子章节"] as Record<string, unknown>)
: null;
if (children) {
rewriteContentRequirements(children, grouped);
}
});
};
const appendUnmatched = (
content: Record<string, unknown>,
grouped: Map<string, RequirementItem[]>
) => {
if (grouped.size === 0) {
return;
}
let orphan = content["未归类章节"];
if (!orphan || typeof orphan !== "object") {
orphan = {
: {
: "",
: "未归类章节",
章节级别: 1,
},
: [],
};
content["未归类章节"] = orphan;
}
const orphanNode = orphan as Record<string, unknown>;
const orphanReqs = Array.isArray(orphanNode["需求列表"])
? (orphanNode["需求列表"] as Array<Record<string, unknown>>)
: [];
grouped.forEach((items) => {
items.forEach((item) => orphanReqs.push(toRawRequirement(item)));
});
orphanNode["需求列表"] = orphanReqs;
};
const buildFlatRawOutput = (
requirements: RequirementItem[],
documentName: string
): Record<string, unknown> => {
const grouped = groupRequirementsBySection(requirements);
const content: Record<string, unknown> = {};
grouped.forEach((items, key) => {
const [number = "", title = "未归类章节"] = key.split("__");
const display = `${number} ${title}`.trim() || "未归类章节";
content[display] = {
: {
章节编号: number,
章节标题: title || "未归类章节",
章节级别: number ? number.split(".").length : 1,
},
需求列表: items.map((item) => toRawRequirement(item)),
};
});
return {
: {
标题: documentName,
生成时间: new Date().toISOString(),
总需求数: requirements.length,
: {},
},
需求内容: content,
};
};
const refreshMetadata = (
rawOutput: Record<string, unknown>,
requirements: RequirementItem[],
documentName: string
) => {
const metadata =
rawOutput["文档元数据"] && typeof rawOutput["文档元数据"] === "object"
? (rawOutput["文档元数据"] as Record<string, unknown>)
: {};
const typeStats: Record<string, number> = {};
requirements.forEach((item) => {
const reqType = asRequirementType(item.requirementType);
const label = TYPE_LABEL[reqType] || "功能需求";
typeStats[label] = (typeStats[label] || 0) + 1;
});
metadata["标题"] = documentName;
metadata["生成时间"] = new Date().toISOString();
metadata["总需求数"] = requirements.length;
metadata["需求类型统计"] = typeStats;
rawOutput["文档元数据"] = metadata;
};
const toFallbackRequirement = (
req: Record<string, unknown>,
sectionNumber: string,
sectionTitle: string,
order: number
): RequirementItem => {
const requirementType = asRequirementType(req["需求类型"]);
const description = typeof req["需求描述"] === "string" ? req["需求描述"] : "";
return {
id:
typeof req["需求编号"] === "string" && req["需求编号"].trim().length > 0
? req["需求编号"]
: `REQ-${String(order + 1).padStart(3, "0")}`,
description,
priority:
req["优先级"] === "高" || req["优先级"] === "中" || req["优先级"] === "低"
? req["优先级"]
: "中",
acceptanceCriteria: description ? [description] : ["待补充验收标准"],
sourceField: `${sectionNumber} ${sectionTitle}`.trim() || "文档解析",
sectionNumber,
sectionTitle,
requirementType,
interfaceName: typeof req["接口名称"] === "string" ? req["接口名称"] : "",
interfaceType: typeof req["接口类型"] === "string" ? req["接口类型"] : "",
dataSource: typeof req["数据来源"] === "string" ? req["数据来源"] : "",
dataDestination: typeof req["数据目的地"] === "string" ? req["数据目的地"] : "",
sortOrder: order,
};
};
export const rebuildRawOutput = (
rawOutput: Record<string, unknown> | undefined,
requirements: RequirementItem[],
documentName: string
): Record<string, unknown> => {
if (!rawOutput || typeof rawOutput !== "object") {
const fallback = buildFlatRawOutput(requirements, documentName);
refreshMetadata(fallback, requirements, documentName);
return fallback;
}
const cloned = deepClone(rawOutput);
const content =
cloned["需求内容"] && typeof cloned["需求内容"] === "object"
? (cloned["需求内容"] as Record<string, unknown>)
: null;
if (!content) {
const fallback = buildFlatRawOutput(requirements, documentName);
refreshMetadata(fallback, requirements, documentName);
return fallback;
}
const grouped = groupRequirementsBySection(requirements);
rewriteContentRequirements(content, grouped);
appendUnmatched(content, grouped);
refreshMetadata(cloned, requirements, documentName);
return cloned;
};
export const buildSectionTree = (
rawOutput: Record<string, unknown> | undefined,
requirements: RequirementItem[]
): SectionTreeNode[] => {
const byId = new Map(requirements.map((item) => [item.id, item]));
const content =
rawOutput && rawOutput["需求内容"] && typeof rawOutput["需求内容"] === "object"
? (rawOutput["需求内容"] as Record<string, unknown>)
: null;
if (!content) {
const grouped = groupRequirementsBySection(requirements);
const nodes = Array.from(grouped.entries()).map(([key, items], index) => {
const [sectionNumber = "", sectionTitle = "未归类章节"] = key.split("__");
return {
key: `${sectionNumber || "root"}-${index}`,
sectionNumber,
sectionTitle,
level: sectionNumber ? sectionNumber.split(".").length : 1,
requirements: items,
children: [],
};
});
return buildSectionTreeWithParents(nodes);
}
let fallbackOrder = 0;
const walk = (node: unknown, path: string): SectionTreeNode | null => {
if (!node || typeof node !== "object") {
return null;
}
const sectionNode = node as Record<string, unknown>;
const sectionInfo =
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
? (sectionNode["章节信息"] as Record<string, unknown>)
: {};
const sectionNumber =
typeof sectionInfo["章节编号"] === "string"
? normalizeSectionNumber(sectionInfo["章节编号"])
: "";
const sectionTitle =
typeof sectionInfo["章节标题"] === "string"
? sectionInfo["章节标题"]
: "未归类章节";
const level =
typeof sectionInfo["章节级别"] === "number"
? sectionInfo["章节级别"]
: sectionNumber
? sectionNumber.split(".").length
: 1;
const reqList = Array.isArray(sectionNode["需求列表"])
? (sectionNode["需求列表"] as Array<Record<string, unknown>>)
: [];
const mappedRequirements = reqList.map((req) => {
const reqId = typeof req["需求编号"] === "string" ? req["需求编号"] : "";
const current = byId.get(reqId);
if (current) {
return current;
}
const fallback = toFallbackRequirement(req, sectionNumber, sectionTitle, fallbackOrder);
fallbackOrder += 1;
return fallback;
});
const childrenRoot =
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
? (sectionNode["子章节"] as Record<string, unknown>)
: null;
const children = childrenRoot
? Object.entries(childrenRoot)
.map(([name, child]) => walk(child, `${path}/${name}`))
.filter((child): child is SectionTreeNode => Boolean(child))
: [];
if (mappedRequirements.length === 0 && children.length === 0) {
return null;
}
return {
key: `${path}/${sectionNumber}-${sectionTitle}`,
sectionNumber,
sectionTitle,
level,
requirements: mappedRequirements,
children,
};
};
const rawNodes = Object.entries(content)
.map(([name, node]) => walk(node, name))
.filter((node): node is SectionTreeNode => Boolean(node));
return buildSectionTreeWithParents(rawNodes);
};

View File

@@ -19,14 +19,125 @@ export interface SrsResultResponse {
documentName: string;
generatedAt: string;
statistics: Record<string, unknown>;
requirements: Array<
RequirementItem & {
sectionNumber?: string | null;
sectionTitle?: string | null;
requirementType?: string | null;
sortOrder: number;
}
>;
requirements: RequirementItem[];
rawOutput: Record<string, unknown>;
}
export interface SrsHistoryItem {
jobId: number;
documentName: string;
generatedAt: string;
totalRequirements: number;
status: string;
createdAt: string;
updatedAt: string;
}
export interface KnowledgeBaseSummary {
id: number;
name: string;
description?: string | null;
}
export interface TestingPipelineItem {
id: string;
content: string;
}
export interface TestingPipelineCase {
id: string;
item_id: string;
operation_steps: string[];
test_content: string;
expected_result_placeholder: string;
}
export interface TestingPipelineExpectedResult {
id: string;
case_id: string;
result: string;
}
export interface TestingPipelineResponse {
trace_id: string;
requirement_type: string;
reason: string;
candidates: string[];
test_items: Record<string, TestingPipelineItem[]>;
test_cases: Record<string, TestingPipelineCase[]>;
expected_results: Record<string, TestingPipelineExpectedResult[]>;
formatted_output: string;
pipeline_summary: string;
knowledge_used: boolean;
}
export interface TestingGenerationSaveRequest {
source_job_id?: number;
source_document_name: string;
knowledge_base_id?: number;
generated_file: Record<string, unknown>;
}
export interface TestingGenerationJobCreateRequest {
source_job_id?: number;
source_document_name: string;
knowledge_base_id?: number;
requirements: Array<{
id: string;
description: string;
priority: string;
acceptanceCriteria: string[];
sourceField: string;
sectionUid?: string;
sectionNumber?: string;
sectionTitle?: string;
requirementType?: string;
interfaceName?: string;
interfaceType?: string;
dataSource?: string;
dataDestination?: string;
sortOrder: number;
}>;
}
export interface TestingGenerationJobCreateResponse {
job_id: number;
status: string;
}
export interface TestingGenerationJobStatusResponse {
job_id: number;
tool_name: string;
status: "pending" | "processing" | "completed" | "failed";
error_message?: string | null;
started_at?: string | null;
completed_at?: string | null;
source_document_name?: string | null;
current_step?: number | null;
total_steps?: number | null;
current_requirement_id?: string | null;
}
export interface TestingGenerationResult {
jobId: number;
sourceJobId?: number | null;
sourceDocumentName: string;
generatedAt: string;
totalRequirements: number;
knowledgeBaseId?: number | null;
generatedFile: Record<string, unknown>;
}
export interface TestingGenerationHistoryItem {
jobId: number;
sourceJobId?: number | null;
sourceDocumentName: string;
generatedAt: string;
totalRequirements: number;
knowledgeBaseId?: number | null;
status: string;
createdAt: string;
updatedAt: string;
}
export const createSrsJob = async (file: File): Promise<SrsJobCreateResponse> => {
@@ -51,11 +162,18 @@ export const saveSrsRequirements = async (
): Promise<SrsResultResponse> => {
const requirements = extraction.requirements.map((item, index) => ({
id: item.id,
title: item.title,
description: item.description,
priority: item.priority,
acceptanceCriteria: item.acceptanceCriteria,
sourceField: item.sourceField,
sectionUid: item.sectionUid,
sectionNumber: item.sectionNumber,
sectionTitle: item.sectionTitle,
requirementType: item.requirementType,
interfaceName: item.interfaceName,
interfaceType: item.interfaceType,
dataSource: item.dataSource,
dataDestination: item.dataDestination,
sortOrder: index,
}));
@@ -71,5 +189,61 @@ export const toExtractionResult = (
documentName: result.documentName,
generatedAt: result.generatedAt,
requirements: result.requirements,
rawOutput: result.rawOutput,
};
};
export const listSrsHistory = async (): Promise<SrsHistoryItem[]> => {
return api.get("/api/tools/srs/history") as Promise<SrsHistoryItem[]>;
};
export const deleteSrsJob = async (jobId: number): Promise<void> => {
await api.delete(`/api/tools/srs/jobs/${jobId}`);
};
export const listKnowledgeBases = async (): Promise<KnowledgeBaseSummary[]> => {
return api.get("/api/knowledge-base") as Promise<KnowledgeBaseSummary[]>;
};
export const generateTestingContent = async (
requirementText: string,
knowledgeBaseId?: number
): Promise<TestingPipelineResponse> => {
return api.post("/api/testing/generate", {
requirement_text: requirementText,
knowledge_base_ids: knowledgeBaseId ? [knowledgeBaseId] : [],
use_model_generation: true,
}) as Promise<TestingPipelineResponse>;
};
export const createTestingGenerationJob = async (
payload: TestingGenerationJobCreateRequest
): Promise<TestingGenerationJobCreateResponse> => {
return api.post("/api/tools/testing/jobs", payload) as Promise<TestingGenerationJobCreateResponse>;
};
export const getTestingGenerationJobStatus = async (
jobId: number
): Promise<TestingGenerationJobStatusResponse> => {
return api.get(`/api/tools/testing/jobs/${jobId}`) as Promise<TestingGenerationJobStatusResponse>;
};
export const saveTestingGeneration = async (
payload: TestingGenerationSaveRequest
): Promise<TestingGenerationResult> => {
return api.post("/api/tools/testing/generations", payload) as Promise<TestingGenerationResult>;
};
export const listTestingHistory = async (): Promise<TestingGenerationHistoryItem[]> => {
return api.get("/api/tools/testing/history") as Promise<TestingGenerationHistoryItem[]>;
};
export const getTestingGenerationResult = async (
jobId: number
): Promise<TestingGenerationResult> => {
return api.get(`/api/tools/testing/jobs/${jobId}/result`) as Promise<TestingGenerationResult>;
};
export const deleteTestingGeneration = async (jobId: number): Promise<void> => {
await api.delete(`/api/tools/testing/jobs/${jobId}`);
};