+
+
+ {[
+ { step: 1, icon: Upload, label: "上传" },
+ { step: 2, icon: FileText, label: "预览" },
+ { step: 3, icon: Settings, label: "处理" },
+ ].map(({ step, icon: Icon, label }, index, array) => (
+
+
step
+ ? "bg-primary/20 border-primary/20"
+ : "bg-background border-input"
+ )}
+ >
+
+
+
+ {step}. {label}
+
+ {index < array.length - 1 && (
+
step ? "bg-primary/20" : "bg-input"
+ )}
+ />
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ 将文件拖放到这里,或点击选择
+
+
+ 支持 PDF、DOCX、TXT、MD 文件
+
+
+ {files.length > 0 && (
+
+ {files.map((fileStatus) => (
+
+
+
+
+
+
+
+ {fileStatus.file.name}
+
+
+ {(fileStatus.file.size / 1024 / 1024).toFixed(2)} MB
+
+
+
+
+ {fileStatus.status === "uploaded" && (
+
+ 已上传
+
+ )}
+ {fileStatus.status === "error" && (
+
+ {fileStatus.error}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ 选择要预览的文档
+
+
+
+
+
+
+
+ 高级设置
+
+
+
+
+
+
+
+
+
+
+
+ {selectedDocumentId && uploadedDocuments[selectedDocumentId] && (
+
+
+
+
+ {
+ files.find((f) => f.uploadId === selectedDocumentId)
+ ?.file.name
+ }
+
+
+ {uploadedDocuments[selectedDocumentId].chunks.length}{" "}
+ 个分块
+
+
+
+ {uploadedDocuments[selectedDocumentId].chunks.map(
+ (chunk: PreviewChunk, index: number) => (
+
+
+ 分块 {index + 1}
+
+
+ {chunk.content}
+
+
+ )
+ )}
+
+
+
+ )}
+
+
+
+
+
+
+
+ {files
+ .filter((f) => f.status === "uploaded")
+ .map((file) => {
+ const task = Object.values(taskStatuses).find(
+ (t) => t.document_id === file.documentId
+ );
+ return (
+
+
+
+
+
+
+
+
+ {file.file.name}
+
+
+ {(file.file.size / 1024 / 1024).toFixed(2)} MB
+
+ {task && (
+
+ 状态:{STATUS_TEXT[task.status || "pending"] || task.status}
+
+ )}
+
+
+ {task?.status === "failed" && (
+
+ {task.error_message}
+
+ )}
+
+ {task &&
+ (task.status === "pending" ||
+ task.status === "processing") && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/rag-web-ui/frontend/src/components/layout/dashboard-layout.tsx b/rag-web-ui/frontend/src/components/layout/dashboard-layout.tsx
new file mode 100644
index 0000000..c7379db
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/layout/dashboard-layout.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import Link from "next/link";
+import {
+ Book,
+ ChevronRight,
+ FileText,
+ MessageSquare,
+ LogOut,
+ Menu,
+ Search,
+ User,
+} from "lucide-react";
+import Breadcrumb from "@/components/ui/breadcrumb";
+import { cn } from "@/lib/utils";
+
+type NavigationItem = {
+ name: string;
+ icon: React.ComponentType<{ className?: string }>;
+ href?: string;
+ children?: Array<{
+ name: string;
+ href: string;
+ }>;
+};
+
+export default function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const [isDocProcessingOpen, setIsDocProcessingOpen] = useState(
+ pathname.startsWith("/dashboard/doc-processing")
+ );
+
+ useEffect(() => {
+ const token = localStorage.getItem("token");
+ if (!token) {
+ router.push("/login");
+ }
+ }, [router]);
+
+ const handleLogout = () => {
+ localStorage.removeItem("token");
+ router.push("/login");
+ };
+
+ useEffect(() => {
+ if (pathname.startsWith("/dashboard/doc-processing")) {
+ setIsDocProcessingOpen(true);
+ }
+ }, [pathname]);
+
+ const navigation: NavigationItem[] = [
+ { name: "知识库", href: "/dashboard/knowledge", icon: Book },
+ { name: "对话", href: "/dashboard/chat", icon: MessageSquare },
+ {
+ name: "文档处理",
+ icon: FileText,
+ children: [
+ { name: "需求提取", href: "/dashboard/doc-processing/extract" },
+ {
+ name: "测试用例生成",
+ href: "/dashboard/doc-processing/test-case-gen",
+ },
+ ],
+ },
+ {
+ name: "一致性分析",
+ href: "/dashboard/consistency-analysis",
+ icon: Search,
+ },
+ { name: "API 密钥", href: "/dashboard/api-keys", icon: User },
+ ];
+
+ return (
+
+ {/* Mobile menu button */}
+
+
+
+
+ {/* Sidebar */}
+
+
+ {/* Sidebar header */}
+
+
+

+ RAG 知识助手
+
+
+
+ {/* Navigation */}
+
+ {/* User profile and logout */}
+
+
+
+
+
+
+ {/* Main content */}
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export const dashboardConfig = {
+ mainNav: [],
+ sidebarNav: [
+ {
+ title: "知识库",
+ href: "/dashboard/knowledge",
+ icon: "database",
+ },
+ {
+ title: "对话",
+ href: "/dashboard/chat",
+ icon: "messageSquare",
+ },
+ {
+ title: "文档处理",
+ href: "/dashboard/doc-processing/extract",
+ icon: "fileText",
+ },
+ {
+ title: "一致性分析",
+ href: "/dashboard/consistency-analysis",
+ icon: "search",
+ },
+ {
+ title: "API 密钥",
+ href: "/dashboard/api-keys",
+ icon: "key",
+ },
+ ],
+};
diff --git a/rag-web-ui/frontend/src/components/theme-provider.tsx b/rag-web-ui/frontend/src/components/theme-provider.tsx
new file mode 100644
index 0000000..b0ff266
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/theme-provider.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import * as React from "react";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import { type ThemeProviderProps } from "next-themes/dist/types";
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return
{children};
+}
diff --git a/rag-web-ui/frontend/src/components/ui/accordion.tsx b/rag-web-ui/frontend/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..24c788c
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/accordion.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/rag-web-ui/frontend/src/components/ui/badge.tsx b/rag-web-ui/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/rag-web-ui/frontend/src/components/ui/breadcrumb.tsx b/rag-web-ui/frontend/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..421f0fe
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { ChevronRight, Home } from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const Breadcrumb = () => {
+ const pathname = usePathname();
+ const labelMap: Record = {
+ dashboard: "主界面",
+ knowledge: "知识库",
+ chat: "对话",
+ "doc-processing": "文档处理",
+ extract: "需求提取",
+ "test-case-gen": "测试用例生成",
+ "consistency-analysis": "一致性分析",
+ new: "新建",
+ "api-keys": "API 密钥",
+ "test-retrieval": "检索测试",
+ upload: "上传",
+ login: "登录",
+ register: "注册",
+ };
+
+ const generateBreadcrumbs = () => {
+ const paths = pathname.split("/").filter(Boolean);
+ const breadcrumbs = 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 {
+ href,
+ label: displayLabel,
+ isLast,
+ };
+ });
+
+ return breadcrumbs;
+ };
+
+ const breadcrumbs = generateBreadcrumbs();
+
+ if (pathname === "/") return null;
+
+ return (
+
+ );
+};
+
+export default Breadcrumb;
diff --git a/rag-web-ui/frontend/src/components/ui/button.tsx b/rag-web-ui/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..0ba4277
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap 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",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/rag-web-ui/frontend/src/components/ui/card.tsx b/rag-web-ui/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..afa13ec
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/rag-web-ui/frontend/src/components/ui/dialog.tsx b/rag-web-ui/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..99dacf1
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ 关闭
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/rag-web-ui/frontend/src/components/ui/divider.tsx b/rag-web-ui/frontend/src/components/ui/divider.tsx
new file mode 100644
index 0000000..1200432
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/divider.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { cn } from "@/lib/utils";
+
+interface DividerProps {
+ className?: string;
+ orientation?: "horizontal" | "vertical";
+}
+
+export const Divider: React.FC = ({
+ className,
+ orientation = "horizontal",
+}) => {
+ return (
+
+ );
+};
diff --git a/rag-web-ui/frontend/src/components/ui/input.tsx b/rag-web-ui/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..677d05f
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/rag-web-ui/frontend/src/components/ui/label.tsx b/rag-web-ui/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/rag-web-ui/frontend/src/components/ui/popover.tsx b/rag-web-ui/frontend/src/components/ui/popover.tsx
new file mode 100644
index 0000000..a0ec48b
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/rag-web-ui/frontend/src/components/ui/progress.tsx b/rag-web-ui/frontend/src/components/ui/progress.tsx
new file mode 100644
index 0000000..5c87ea4
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/progress.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }
diff --git a/rag-web-ui/frontend/src/components/ui/select.tsx b/rag-web-ui/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..a85ac64
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/select.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+};
diff --git a/rag-web-ui/frontend/src/components/ui/skeleton.tsx b/rag-web-ui/frontend/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..01b8b6d
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/rag-web-ui/frontend/src/components/ui/switch.tsx b/rag-web-ui/frontend/src/components/ui/switch.tsx
new file mode 100644
index 0000000..bc69cf2
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/rag-web-ui/frontend/src/components/ui/table.tsx b/rag-web-ui/frontend/src/components/ui/table.tsx
new file mode 100644
index 0000000..7f3502f
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/rag-web-ui/frontend/src/components/ui/tabs.tsx b/rag-web-ui/frontend/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..26eb109
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/rag-web-ui/frontend/src/components/ui/toast.tsx b/rag-web-ui/frontend/src/components/ui/toast.tsx
new file mode 100644
index 0000000..521b94b
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/toast.tsx
@@ -0,0 +1,129 @@
+"use client"
+
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/rag-web-ui/frontend/src/components/ui/toaster.tsx b/rag-web-ui/frontend/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..e223385
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast"
+import { useToast } from "@/components/ui/use-toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/rag-web-ui/frontend/src/components/ui/use-toast.ts b/rag-web-ui/frontend/src/components/ui/use-toast.ts
new file mode 100644
index 0000000..02e111d
--- /dev/null
+++ b/rag-web-ui/frontend/src/components/ui/use-toast.ts
@@ -0,0 +1,194 @@
+"use client"
+
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast"
+
+const TOAST_LIMIT = 1
+const TOAST_REMOVE_DELAY = 1000000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
+ return count.toString()
+}
+
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ }
+}
+
+export { useToast, toast }
diff --git a/rag-web-ui/frontend/src/lib/api.ts b/rag-web-ui/frontend/src/lib/api.ts
new file mode 100644
index 0000000..d4ae3a9
--- /dev/null
+++ b/rag-web-ui/frontend/src/lib/api.ts
@@ -0,0 +1,94 @@
+interface FetchOptions extends Omit {
+ data?: any;
+ headers?: Record;
+}
+
+export class ApiError extends Error {
+ constructor(public status: number, message: string) {
+ super(message);
+ this.name = 'ApiError';
+ }
+}
+
+export async function fetchApi(fullUrl: string, options: FetchOptions = {}) {
+ const { data, headers: customHeaders = {}, ...restOptions } = options;
+
+ // Get token from localStorage
+ let token = '';
+ if (typeof window !== 'undefined') {
+ token = localStorage.getItem('token') || '';
+ }
+
+ const headers: Record = {
+ ...(token && { Authorization: `Bearer ${token}` }),
+ ...customHeaders,
+ };
+
+ // If no content type is specified and we have data, default to JSON
+ if (!headers['Content-Type'] && data && !(data instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ const config: RequestInit = {
+ ...restOptions,
+ headers,
+ };
+
+ // Handle body based on Content-Type
+ if (data) {
+ if (data instanceof FormData) {
+ config.body = data;
+ } else if (headers['Content-Type'] === 'application/json') {
+ config.body = JSON.stringify(data);
+ } else if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
+ config.body = typeof data === 'string' ? data : new URLSearchParams(data).toString();
+ } else {
+ config.body = data;
+ }
+ }
+
+ try {
+ const response = await fetch(fullUrl, config);
+
+ if (response.status === 401) {
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ }
+ throw new ApiError(401, '未授权,请重新登录');
+ }
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new ApiError(
+ response.status,
+ errorData.message || errorData.detail || '请求发生错误'
+ );
+ }
+
+ return await response.json();
+ } catch (error) {
+ if (error instanceof ApiError) {
+ throw error;
+ }
+ throw new ApiError(500, '网络错误或服务器不可达');
+ }
+}
+
+// Helper methods for common HTTP methods
+export const api = {
+ get: (url: string, options?: Omit) =>
+ fetchApi(url, { ...options, method: 'GET' }),
+
+ post: (url: string, data?: any, options?: Omit) =>
+ fetchApi(url, { ...options, method: 'POST', data }),
+
+ put: (url: string, data?: any, options?: Omit) =>
+ fetchApi(url, { ...options, method: 'PUT', data }),
+
+ delete: (url: string, options?: Omit) =>
+ fetchApi(url, { ...options, method: 'DELETE' }),
+
+ patch: (url: string, data?: any, options?: Omit) =>
+ fetchApi(url, { ...options, method: 'PATCH', data }),
+};
diff --git a/rag-web-ui/frontend/src/lib/document-mock.ts b/rag-web-ui/frontend/src/lib/document-mock.ts
new file mode 100644
index 0000000..87ba343
--- /dev/null
+++ b/rag-web-ui/frontend/src/lib/document-mock.ts
@@ -0,0 +1,443 @@
+export type PriorityLevel = "高" | "中" | "低";
+export type SeverityLevel = "高" | "中" | "低";
+
+export interface RequirementItem {
+ id: string;
+ title: string;
+ description: string;
+ priority: PriorityLevel;
+ acceptanceCriteria: string[];
+ sourceField: string;
+}
+
+export interface RequirementExtractionResult {
+ documentName: string;
+ generatedAt: string;
+ requirements: RequirementItem[];
+}
+
+export interface TestCaseItem {
+ id: string;
+ requirementId: string;
+ requirementTitle: string;
+ title: string;
+ preconditions: string[];
+ steps: string[];
+ expectedResults: string[];
+ priority: PriorityLevel;
+ tags: string[];
+}
+
+export interface TestCaseGenerationResult {
+ sourceDocument: string;
+ generatedAt: string;
+ testCases: TestCaseItem[];
+}
+
+export interface ConsistencyIssue {
+ id: string;
+ type: string;
+ severity: SeverityLevel;
+ requirementRef: string;
+ codeRef: string;
+ summary: string;
+ suggestion: string;
+}
+
+export interface ConsistencyReport {
+ requirementFileName: string;
+ codeFileName: string;
+ generatedAt: string;
+ consistencyScore: number;
+ coverage: number;
+ issues: ConsistencyIssue[];
+}
+
+const STORAGE_KEYS = {
+ extractionDraft: "doc_processing_extraction_draft",
+ testCaseDraft: "doc_processing_test_case_draft",
+ consistencyDraft: "consistency_analysis_draft",
+};
+
+const WAIT_TIME = {
+ extract: 950,
+ generateCases: 900,
+ analyze: 1100,
+};
+
+const asPriority = (value: unknown): PriorityLevel => {
+ if (value === "高" || value === "中" || value === "低") {
+ return value;
+ }
+ return "中";
+};
+
+const asSeverity = (value: unknown): SeverityLevel => {
+ if (value === "高" || value === "中" || value === "低") {
+ return value;
+ }
+ return "中";
+};
+
+const wait = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+const hashText = (value: string) => {
+ let hash = 0;
+ for (let i = 0; i < value.length; i += 1) {
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+};
+
+const seededPick = (items: T[], seed: number, offset = 0): T => {
+ return items[(seed + offset) % items.length];
+};
+
+const toIso = () => new Date().toISOString();
+
+const safeStorage = {
+ set(key: string, value: T) {
+ if (typeof window === "undefined") {
+ return;
+ }
+ window.localStorage.setItem(key, JSON.stringify(value));
+ },
+ get(key: string): T | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ const raw = window.localStorage.getItem(key);
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ },
+};
+
+const normalizeRequirementItem = (
+ item: Partial,
+ fallbackIndex: number
+): RequirementItem => {
+ const acceptanceCriteria = Array.isArray(item.acceptanceCriteria)
+ ? item.acceptanceCriteria.map((entry) => String(entry))
+ : [];
+
+ return {
+ id:
+ typeof item.id === "string" && item.id.trim().length > 0
+ ? item.id
+ : `REQ-${String(fallbackIndex + 1).padStart(3, "0")}`,
+ title:
+ typeof item.title === "string" && item.title.trim().length > 0
+ ? item.title
+ : `未命名需求 ${fallbackIndex + 1}`,
+ description:
+ typeof item.description === "string" ? item.description : "",
+ priority: asPriority(item.priority),
+ acceptanceCriteria:
+ acceptanceCriteria.length > 0 ? acceptanceCriteria : ["待补充验收标准"],
+ sourceField:
+ typeof item.sourceField === "string" && item.sourceField.trim().length > 0
+ ? item.sourceField
+ : `章节 ${fallbackIndex + 1}`,
+ };
+};
+
+export const parseRequirementJson = (
+ content: string
+): RequirementExtractionResult => {
+ const parsed = JSON.parse(content) as unknown;
+
+ if (!parsed || typeof parsed !== "object") {
+ throw new Error("JSON 内容无效,请检查文件格式");
+ }
+
+ const value = parsed as {
+ documentName?: unknown;
+ generatedAt?: unknown;
+ requirements?: unknown;
+ };
+
+ if (!Array.isArray(value.requirements)) {
+ throw new Error("JSON 中缺少 requirements 数组字段");
+ }
+
+ const requirements = value.requirements.map((entry, index) =>
+ normalizeRequirementItem((entry as Partial) ?? {}, index)
+ );
+
+ return {
+ documentName:
+ typeof value.documentName === "string" && value.documentName.trim().length > 0
+ ? value.documentName
+ : "导入需求文件",
+ generatedAt:
+ typeof value.generatedAt === "string" && value.generatedAt.trim().length > 0
+ ? value.generatedAt
+ : toIso(),
+ requirements,
+ };
+};
+
+export const mockExtractRequirements = async (
+ file: File
+): Promise => {
+ await wait(WAIT_TIME.extract);
+
+ const seed = hashText(file.name);
+ const moduleCandidates = ["认证", "检索", "会话", "权限", "日志", "导出"];
+ const actionCandidates = [
+ "支持配置化执行",
+ "提供异常处理提示",
+ "可追踪关键链路",
+ "支持批量操作",
+ "支持结果回溯",
+ "具备可编辑能力",
+ ];
+ const priorities: PriorityLevel[] = ["高", "中", "低"];
+
+ const requirements: RequirementItem[] = Array.from({ length: 5 }).map(
+ (_, index) => {
+ const moduleName = seededPick(moduleCandidates, seed, index);
+ const action = seededPick(actionCandidates, seed, index + 3);
+ const requirementNumber = String(index + 1).padStart(3, "0");
+
+ return {
+ id: `REQ-${requirementNumber}`,
+ title: `${moduleName}能力需求 ${index + 1}`,
+ description: `系统在${moduleName}场景下应${action},并确保操作过程可观测。`,
+ priority: seededPick(priorities, seed, index),
+ acceptanceCriteria: [
+ `当用户完成输入后,${moduleName}流程可在 2 秒内给出反馈。`,
+ `在错误条件下,页面需提供明确提示并保留用户已输入信息。`,
+ `处理结果必须可以导出并支持后续追踪。`,
+ ],
+ sourceField: `文档片段 ${index + 1}`,
+ };
+ }
+ );
+
+ return {
+ documentName: file.name,
+ generatedAt: toIso(),
+ requirements,
+ };
+};
+
+export const mockGenerateTestCases = async (
+ extraction: RequirementExtractionResult
+): Promise => {
+ 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],
+ }));
+
+ return {
+ sourceDocument: extraction.documentName,
+ generatedAt: toIso(),
+ testCases: cases,
+ };
+};
+
+export const mockAnalyzeConsistency = async (
+ requirementFile: File,
+ codeFile: File
+): Promise => {
+ await wait(WAIT_TIME.analyze);
+
+ const seed = hashText(`${requirementFile.name}-${codeFile.name}`);
+ const issueTemplates = [
+ {
+ type: "需求未覆盖",
+ summary: "需求中存在关键行为,但代码中缺少对应分支。",
+ suggestion: "补充对应功能分支并增加单元测试。",
+ },
+ {
+ type: "实现偏差",
+ summary: "代码行为与需求描述不一致,存在参数约束差异。",
+ suggestion: "统一需求与代码中的参数规则,并补充说明。",
+ },
+ {
+ type: "验收标准缺失",
+ summary: "需求定义了验收口径,但代码未体现可验证输出。",
+ suggestion: "增加可验证输出字段,并补充验证逻辑。",
+ },
+ {
+ type: "异常处理不足",
+ summary: "需求要求异常可恢复,代码当前只记录日志未提示用户。",
+ suggestion: "增加用户可见提示,并保留现场数据。",
+ },
+ ];
+
+ const severityLevels: SeverityLevel[] = ["高", "中", "低"];
+ const issueCount = 3 + (seed % 3);
+
+ const issues: ConsistencyIssue[] = Array.from({ length: issueCount }).map(
+ (_, index) => {
+ const template = seededPick(issueTemplates, seed, index);
+ return {
+ id: `ISSUE-${String(index + 1).padStart(3, "0")}`,
+ type: template.type,
+ severity: seededPick(severityLevels, seed, index + 2),
+ requirementRef: `REQ-${String(index + 1).padStart(3, "0")}`,
+ codeRef: `${codeFile.name}:L${20 + index * 17}`,
+ summary: template.summary,
+ suggestion: template.suggestion,
+ };
+ }
+ );
+
+ const consistencyScore = Math.min(95, 70 + (seed % 20));
+ const coverage = Math.min(96, 68 + ((seed >> 3) % 24));
+
+ return {
+ requirementFileName: requirementFile.name,
+ codeFileName: codeFile.name,
+ generatedAt: toIso(),
+ consistencyScore,
+ coverage,
+ issues,
+ };
+};
+
+export const toWordContent = (result: TestCaseGenerationResult) => {
+ const lines: string[] = [];
+
+ lines.push(`测试用例生成结果`);
+ lines.push(`来源需求文件: ${result.sourceDocument}`);
+ lines.push(`生成时间: ${new Date(result.generatedAt).toLocaleString("zh-CN")}`);
+ lines.push("");
+
+ result.testCases.forEach((testCase, index) => {
+ lines.push(`${index + 1}. ${testCase.id} ${testCase.title}`);
+ lines.push(`需求映射: ${testCase.requirementId} ${testCase.requirementTitle}`);
+ lines.push(`优先级: ${testCase.priority}`);
+ lines.push(`标签: ${testCase.tags.join("、")}`);
+ lines.push("前置条件:");
+ testCase.preconditions.forEach((line, i) => {
+ lines.push(` ${i + 1}) ${line}`);
+ });
+ lines.push("步骤:");
+ testCase.steps.forEach((line, i) => {
+ lines.push(` ${i + 1}) ${line}`);
+ });
+ lines.push("预期结果:");
+ testCase.expectedResults.forEach((line, i) => {
+ lines.push(` ${i + 1}) ${line}`);
+ });
+ lines.push("");
+ });
+
+ return lines.join("\n");
+};
+
+const downloadBlob = (fileName: string, blob: Blob) => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+};
+
+export const downloadJson = (fileName: string, data: unknown) => {
+ const json = JSON.stringify(data, null, 2);
+ const blob = new Blob([json], { type: "application/json;charset=utf-8" });
+ downloadBlob(fileName, blob);
+};
+
+export const downloadWord = (fileName: string, content: string) => {
+ const blob = new Blob([content], { type: "application/msword;charset=utf-8" });
+ downloadBlob(fileName, blob);
+};
+
+export const saveExtractionDraft = (data: RequirementExtractionResult) => {
+ safeStorage.set(STORAGE_KEYS.extractionDraft, data);
+};
+
+export const loadExtractionDraft = () => {
+ const value = safeStorage.get(
+ STORAGE_KEYS.extractionDraft
+ );
+ if (!value || !Array.isArray(value.requirements)) {
+ return null;
+ }
+
+ return {
+ ...value,
+ requirements: value.requirements.map((item, index) =>
+ normalizeRequirementItem(item, index)
+ ),
+ };
+};
+
+export const saveTestCaseDraft = (data: TestCaseGenerationResult) => {
+ safeStorage.set(STORAGE_KEYS.testCaseDraft, data);
+};
+
+export const loadTestCaseDraft = () => {
+ const value = safeStorage.get(
+ STORAGE_KEYS.testCaseDraft
+ );
+ if (!value || !Array.isArray(value.testCases)) {
+ return null;
+ }
+ return value;
+};
+
+export const saveConsistencyDraft = (data: ConsistencyReport) => {
+ safeStorage.set(STORAGE_KEYS.consistencyDraft, data);
+};
+
+export const loadConsistencyDraft = () => {
+ const value = safeStorage.get(STORAGE_KEYS.consistencyDraft);
+ if (!value || !Array.isArray(value.issues)) {
+ return null;
+ }
+
+ return {
+ ...value,
+ issues: value.issues.map((issue) => ({
+ ...issue,
+ severity: asSeverity(issue.severity),
+ })),
+ };
+};
diff --git a/rag-web-ui/frontend/src/lib/srs-tools-api.ts b/rag-web-ui/frontend/src/lib/srs-tools-api.ts
new file mode 100644
index 0000000..b1ea36b
--- /dev/null
+++ b/rag-web-ui/frontend/src/lib/srs-tools-api.ts
@@ -0,0 +1,75 @@
+import { api } from "@/lib/api";
+import { RequirementItem, RequirementExtractionResult } from "@/lib/document-mock";
+
+export interface SrsJobCreateResponse {
+ job_id: number;
+ status: string;
+}
+
+export interface SrsJobStatusResponse {
+ job_id: number;
+ tool_name: string;
+ status: "pending" | "processing" | "completed" | "failed";
+ error_message?: string | null;
+ extraction_id?: number | null;
+}
+
+export interface SrsResultResponse {
+ jobId: number;
+ documentName: string;
+ generatedAt: string;
+ statistics: Record;
+ requirements: Array<
+ RequirementItem & {
+ sectionNumber?: string | null;
+ sectionTitle?: string | null;
+ requirementType?: string | null;
+ sortOrder: number;
+ }
+ >;
+}
+
+export const createSrsJob = async (file: File): Promise => {
+ const formData = new FormData();
+ formData.append("file", file);
+ return api.post("/api/tools/srs/jobs", formData) as Promise;
+};
+
+export const getSrsJobStatus = async (
+ jobId: number
+): Promise => {
+ return api.get(`/api/tools/srs/jobs/${jobId}`) as Promise;
+};
+
+export const getSrsJobResult = async (jobId: number): Promise => {
+ return api.get(`/api/tools/srs/jobs/${jobId}/result`) as Promise;
+};
+
+export const saveSrsRequirements = async (
+ jobId: number,
+ extraction: RequirementExtractionResult
+): Promise => {
+ 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,
+ sortOrder: index,
+ }));
+
+ return api.put(`/api/tools/srs/jobs/${jobId}/requirements`, {
+ requirements,
+ }) as Promise;
+};
+
+export const toExtractionResult = (
+ result: SrsResultResponse
+): RequirementExtractionResult => {
+ return {
+ documentName: result.documentName,
+ generatedAt: result.generatedAt,
+ requirements: result.requirements,
+ };
+};
diff --git a/rag-web-ui/frontend/src/lib/utils.ts b/rag-web-ui/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..1a860ee
--- /dev/null
+++ b/rag-web-ui/frontend/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
\ No newline at end of file
diff --git a/rag-web-ui/frontend/src/styles/globals.css b/rag-web-ui/frontend/src/styles/globals.css
new file mode 100644
index 0000000..8abdb15
--- /dev/null
+++ b/rag-web-ui/frontend/src/styles/globals.css
@@ -0,0 +1,76 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/rag-web-ui/frontend/tailwind.config.ts b/rag-web-ui/frontend/tailwind.config.ts
new file mode 100644
index 0000000..e53e85f
--- /dev/null
+++ b/rag-web-ui/frontend/tailwind.config.ts
@@ -0,0 +1,83 @@
+import type { Config } from "tailwindcss"
+
+const config = {
+ darkMode: ["class"],
+ content: [
+ './pages/**/*.{ts,tsx}',
+ './components/**/*.{ts,tsx}',
+ './app/**/*.{ts,tsx}',
+ './src/**/*.{ts,tsx}',
+ ],
+ prefix: "",
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [
+ require("tailwindcss-animate"),
+ require("@tailwindcss/line-clamp"),
+ ],
+} satisfies Config
+
+export default config
\ No newline at end of file
diff --git a/rag-web-ui/frontend/tsconfig.json b/rag-web-ui/frontend/tsconfig.json
new file mode 100644
index 0000000..bff9b53
--- /dev/null
+++ b/rag-web-ui/frontend/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.js"],
+ "exclude": ["node_modules"]
+}
diff --git a/rag-web-ui/nginx.conf b/rag-web-ui/nginx.conf
new file mode 100644
index 0000000..31cc1e8
--- /dev/null
+++ b/rag-web-ui/nginx.conf
@@ -0,0 +1,84 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ client_max_body_size 100M;
+
+ upstream frontend {
+ server frontend:3000;
+ }
+
+ upstream backend {
+ server backend:8000;
+ }
+
+ upstream minio {
+ server minio:9000;
+ }
+
+ # Default server block for both IP and nip.io access
+ server {
+ # change the port you want to use, but not 3000
+ listen 80 default_server;
+ # support any nip.io domain and direct IP access
+ # if you want to deploy on a cloud server,
+ # you can change the server_name to your server's domain name
+ server_name _ *.nip.io;
+
+ # Backend API
+ location /api {
+ proxy_pass http://backend/api;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_buffering off;
+ }
+
+ # Frontend
+ location / {
+ proxy_pass http://frontend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ proxy_buffering off;
+ proxy_http_version 1.1;
+ proxy_read_timeout 60s;
+ proxy_cache_bypass $http_upgrade;
+ }
+
+ # Next.js 静态文件和 API 路由
+ location /_next/ {
+ proxy_pass http://frontend;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+
+ # API Documentation (Redoc)
+ location /redoc {
+ proxy_pass http://backend/redoc;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ # API Documentation (OpenAPI JSON)
+ location /openapi.json {
+ proxy_pass http://backend/openapi.json;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+ }
+}
\ No newline at end of file
diff --git a/rag-web-ui/nginx.dev.conf b/rag-web-ui/nginx.dev.conf
new file mode 100644
index 0000000..08a465e
--- /dev/null
+++ b/rag-web-ui/nginx.dev.conf
@@ -0,0 +1,113 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ client_max_body_size 100M;
+
+ upstream frontend {
+ server frontend:3000;
+ }
+
+ upstream backend {
+ server backend:8000;
+ }
+
+ upstream minio {
+ server minio:9000;
+ }
+
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ listen 80 default_server;
+ server_name _ *.nip.io; # 支持任何 nip.io 域名和直接 IP 访问
+
+ # 开发环境的 CORS 设置
+ add_header 'Access-Control-Allow-Origin' '*' always;
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
+ add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
+
+ # Next.js 开发服务器
+ location / {
+ proxy_pass http://frontend;
+ proxy_http_version 1.1;
+
+ # Next.js HMR 配置
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # 开发环境特定配置
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 1800s;
+ proxy_connect_timeout 1800s;
+
+ # 开发环境下的 OPTIONS 请求处理
+ if ($request_method = 'OPTIONS') {
+ add_header 'Access-Control-Allow-Origin' '*';
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
+ add_header 'Access-Control-Max-Age' 1728000;
+ add_header 'Content-Type' 'text/plain; charset=utf-8';
+ add_header 'Content-Length' 0;
+ return 204;
+ }
+ }
+
+ # Next.js HMR 专用配置
+ location /_next/webpack-hmr {
+ proxy_pass http://frontend/_next/webpack-hmr;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_set_header Host $host;
+ }
+
+ # 后端 API
+ location /api {
+ proxy_pass http://backend/api;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+
+ # 开发环境下的错误调试设置
+ proxy_intercept_errors off;
+ proxy_read_timeout 300s;
+ }
+
+ # API Documentation
+ location /redoc {
+ proxy_pass http://backend/redoc;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ location /openapi.json {
+ proxy_pass http://backend/openapi.json;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ # Minio API
+ location /minio/ {
+ proxy_pass http://minio/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ }
+}
\ No newline at end of file