完善skills;测试用例生成页面功能初步实现
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
向知识库上传 PDF、DOCX、MD 或 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"
|
||||
>
|
||||
去上传
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
543
rag-web-ui/frontend/src/lib/srs-json.ts
Normal file
543
rag-web-ui/frontend/src/lib/srs-json.ts
Normal 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);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user