| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239 |
- "use client";
- import { useCallback, useEffect, useState } from "react";
- import { useRouter } from "next/navigation";
- import { useAuth } from "@/features/agent/hooks/useAuth";
- import { ResponsiveLayout } from "@/components/layout";
- import {
- fetchKnowledgeBases,
- createKnowledgeBase,
- updateKnowledgeBase,
- updateKnowledgeBaseRAGEnabled,
- deleteKnowledgeBase,
- type KnowledgeBase,
- type CreateKnowledgeBaseRequest,
- type UpdateKnowledgeBaseRequest,
- } from "@/features/agent/services/knowledgeBaseApi";
- import {
- fetchDocuments,
- createDocument,
- updateDocument,
- deleteDocument,
- publishDocument,
- unpublishDocument,
- type Document,
- type CreateDocumentRequest,
- type UpdateDocumentRequest,
- type DocumentListResult,
- } from "@/features/agent/services/documentApi";
- import {
- importDocuments,
- importFromUrls,
- type ImportResult,
- } from "@/features/agent/services/importApi";
- import { Button } from "@/components/ui/button";
- import { Input } from "@/components/ui/input";
- import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- } from "@/components/ui/dialog";
- import { Card } from "@/components/ui/card";
- import { Label } from "@/components/ui/label";
- import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
- import { Switch } from "@/components/ui/switch";
- import {
- Plus,
- Edit,
- Trash2,
- Search,
- FileText,
- Upload,
- Link as LinkIcon,
- BookOpen,
- CheckCircle2,
- XCircle,
- Loader2,
- ChevronLeft,
- ChevronRight,
- } from "lucide-react";
- import { Textarea } from "@/components/ui/textarea";
- import { toast } from "@/hooks/useToast";
- export default function KnowledgePage(props: any = {}) {
- const { embedded = false } = props;
- const router = useRouter();
- const { agent } = useAuth();
- // 知识库状态
- const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
- const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null);
- const [loadingKBs, setLoadingKBs] = useState(true);
- // 文档状态
- const [documents, setDocuments] = useState<Document[]>([]);
- const [documentResult, setDocumentResult] = useState<DocumentListResult | null>(null);
- const [loadingDocs, setLoadingDocs] = useState(false);
- const [searchKeyword, setSearchKeyword] = useState("");
- const [statusFilter, setStatusFilter] = useState<string>("all");
- const [currentPage, setCurrentPage] = useState(1);
- const pageSize = 20;
- // 对话框状态
- const [createKBDialogOpen, setCreateKBDialogOpen] = useState(false);
- const [editKBDialogOpen, setEditKBDialogOpen] = useState(false);
- const [deleteKBDialogOpen, setDeleteKBDialogOpen] = useState(false);
- const [createDocDialogOpen, setCreateDocDialogOpen] = useState(false);
- const [editDocDialogOpen, setEditDocDialogOpen] = useState(false);
- const [deleteDocDialogOpen, setDeleteDocDialogOpen] = useState(false);
- const [importDialogOpen, setImportDialogOpen] = useState(false);
- const [importTab, setImportTab] = useState<"file" | "url">("file");
- const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
- // 表单状态
- const [submitting, setSubmitting] = useState(false);
- const [createKBForm, setCreateKBForm] = useState<CreateKnowledgeBaseRequest>({
- name: "",
- description: "",
- });
- const [editKBForm, setEditKBForm] = useState<UpdateKnowledgeBaseRequest>({});
- const [createDocForm, setCreateDocForm] = useState<CreateDocumentRequest>({
- knowledge_base_id: 0,
- title: "",
- content: "",
- summary: "",
- type: "document",
- status: "draft",
- });
- const [editDocForm, setEditDocForm] = useState<UpdateDocumentRequest>({});
- const [importUrls, setImportUrls] = useState<string>("");
- const [importFiles, setImportFiles] = useState<File[]>([]);
- // 加载知识库列表(不依赖 selectedKnowledgeBase,避免选中后反复触发 effect 导致疯狂刷新)
- const loadKnowledgeBases = useCallback(async () => {
- setLoadingKBs(true);
- try {
- const data = await fetchKnowledgeBases();
- setKnowledgeBases(data);
- } catch (error) {
- console.error("加载知识库列表失败:", error);
- toast.error((error as Error).message || "加载知识库列表失败");
- } finally {
- setLoadingKBs(false);
- }
- }, []);
- // 加载文档列表(silent:后台轮询向量化状态时不全屏“加载中”、不弹 Toast,避免刷屏)
- const loadDocuments = useCallback(
- async (opts?: { silent?: boolean }) => {
- if (!selectedKnowledgeBase) {
- setDocuments([]);
- setDocumentResult(null);
- return;
- }
- const silent = opts?.silent === true;
- if (!silent) {
- setLoadingDocs(true);
- }
- try {
- const status = statusFilter === "all" ? undefined : statusFilter;
- const result = await fetchDocuments(
- selectedKnowledgeBase.id,
- currentPage,
- pageSize,
- searchKeyword || undefined,
- status
- );
- setDocumentResult(result);
- setDocuments(result.documents ?? []);
- } catch (error) {
- console.error("加载文档列表失败:", error);
- if (!silent) {
- toast.error((error as Error).message || "加载文档列表失败");
- setDocuments([]);
- setDocumentResult(null);
- }
- } finally {
- if (!silent) {
- setLoadingDocs(false);
- }
- }
- },
- [selectedKnowledgeBase, currentPage, searchKeyword, statusFilter]
- );
- // 初始加载
- useEffect(() => {
- loadKnowledgeBases();
- }, [loadKnowledgeBases]);
- // 当选择知识库或搜索条件变化时,重新加载文档
- useEffect(() => {
- setCurrentPage(1); // 切换知识库或搜索时重置页码
- void loadDocuments();
- }, [loadDocuments]);
- // 向量化进行中时自动刷新列表(pending → processing → completed/failed),无需手动整页刷新
- useEffect(() => {
- if (!selectedKnowledgeBase) return;
- const hasEmbeddingInFlight = documents.some(
- (d) => d.embedding_status === "pending" || d.embedding_status === "processing"
- );
- if (!hasEmbeddingInFlight) return;
- const intervalMs = 2500;
- const id = window.setInterval(() => {
- void loadDocuments({ silent: true });
- }, intervalMs);
- return () => window.clearInterval(id);
- }, [selectedKnowledgeBase, documents, loadDocuments]);
- // 选择知识库
- const handleSelectKnowledgeBase = (kb: KnowledgeBase) => {
- setSelectedKnowledgeBase(kb);
- setSearchKeyword("");
- setStatusFilter("all");
- setCurrentPage(1);
- };
- // 创建知识库
- const handleCreateKB = async () => {
- if (!createKBForm.name.trim()) {
- toast.error("知识库名称不能为空");
- return;
- }
- setSubmitting(true);
- try {
- await createKnowledgeBase(createKBForm);
- setCreateKBDialogOpen(false);
- setCreateKBForm({ name: "", description: "" });
- await loadKnowledgeBases();
- toast.success("创建成功");
- } catch (error) {
- toast.error((error as Error).message || "创建知识库失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 打开编辑知识库对话框
- const handleOpenEditKB = (kb: KnowledgeBase) => {
- setEditKBForm({
- name: kb.name,
- description: kb.description,
- });
- setSelectedKnowledgeBase(kb);
- setEditKBDialogOpen(true);
- };
- // 更新知识库
- const handleUpdateKB = async () => {
- if (!selectedKnowledgeBase) return;
- setSubmitting(true);
- try {
- await updateKnowledgeBase(selectedKnowledgeBase.id, editKBForm);
- setEditKBDialogOpen(false);
- await loadKnowledgeBases();
- toast.success("更新成功");
- } catch (error) {
- toast.error((error as Error).message || "更新知识库失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 打开删除知识库对话框
- const handleOpenDeleteKB = (kb: KnowledgeBase) => {
- setSelectedKnowledgeBase(kb);
- setDeleteKBDialogOpen(true);
- };
- // 删除知识库
- const handleDeleteKB = async () => {
- if (!selectedKnowledgeBase) return;
- setSubmitting(true);
- try {
- await deleteKnowledgeBase(selectedKnowledgeBase.id);
- setDeleteKBDialogOpen(false);
- setSelectedKnowledgeBase(null);
- await loadKnowledgeBases();
- toast.success("删除成功");
- } catch (error) {
- toast.error((error as Error).message || "删除知识库失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 打开创建文档对话框
- const handleOpenCreateDoc = () => {
- if (!selectedKnowledgeBase) {
- toast.error("请先选择知识库");
- return;
- }
- setCreateDocForm({
- knowledge_base_id: selectedKnowledgeBase.id,
- title: "",
- content: "",
- summary: "",
- type: "document",
- status: "draft",
- });
- setCreateDocDialogOpen(true);
- };
- // 创建文档
- const handleCreateDoc = async () => {
- if (!createDocForm.title.trim() || !createDocForm.content.trim()) {
- toast.error("标题和内容不能为空");
- return;
- }
- setSubmitting(true);
- try {
- await createDocument(createDocForm);
- setCreateDocDialogOpen(false);
- setCreateDocForm({
- knowledge_base_id: selectedKnowledgeBase?.id || 0,
- title: "",
- content: "",
- summary: "",
- type: "document",
- status: "draft",
- });
- await loadDocuments();
- toast.success("创建成功");
- } catch (error) {
- toast.error((error as Error).message || "创建文档失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 打开编辑文档对话框
- const handleOpenEditDoc = (doc: Document) => {
- setSelectedDocument(doc);
- setEditDocForm({
- title: doc.title,
- content: doc.content,
- summary: doc.summary,
- type: doc.type,
- status: doc.status,
- });
- setEditDocDialogOpen(true);
- };
- // 更新文档
- const handleUpdateDoc = async (docId: number) => {
- setSubmitting(true);
- try {
- await updateDocument(docId, editDocForm);
- setEditDocDialogOpen(false);
- await loadDocuments();
- toast.success("更新成功");
- } catch (error) {
- toast.error((error as Error).message || "更新文档失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 打开删除文档对话框
- const handleOpenDeleteDoc = (doc: Document) => {
- setSelectedDocument(doc);
- setDeleteDocDialogOpen(true);
- };
- // 删除文档
- const handleDeleteDoc = async (docId: number) => {
- setSubmitting(true);
- try {
- await deleteDocument(docId);
- setDeleteDocDialogOpen(false);
- await loadDocuments();
- toast.success("删除成功");
- } catch (error) {
- toast.error((error as Error).message || "删除文档失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 发布文档
- const handlePublishDoc = async (docId: number) => {
- try {
- await publishDocument(docId);
- await loadDocuments();
- toast.success("发布成功");
- } catch (error) {
- toast.error((error as Error).message || "发布文档失败");
- }
- };
- // 取消发布文档
- const handleUnpublishDoc = async (docId: number) => {
- try {
- await unpublishDocument(docId);
- await loadDocuments();
- toast.success("取消发布成功");
- } catch (error) {
- toast.error((error as Error).message || "取消发布文档失败");
- }
- };
- // 导入文件
- const handleImportFiles = async () => {
- if (!selectedKnowledgeBase) {
- toast.error("请先选择知识库");
- return;
- }
- if (importFiles.length === 0) {
- toast.error("请选择要导入的文件");
- return;
- }
- setSubmitting(true);
- try {
- const result: ImportResult = await importDocuments(selectedKnowledgeBase.id, importFiles);
- const errMsg = result.errors?.length ? result.errors[0] : "";
- if (result.failed_count > 0 && result.success_count === 0) {
- toast.error(errMsg || `导入失败:${result.failed_count} 个文件未成功`);
- } else if (result.failed_count > 0) {
- toast.success(`导入完成:成功 ${result.success_count},失败 ${result.failed_count}${errMsg ? `(${errMsg})` : ""}`);
- } else {
- toast.success(`导入完成:成功 ${result.success_count} 个文件`);
- }
- setImportDialogOpen(false);
- setImportFiles([]);
- try {
- await loadDocuments();
- await loadKnowledgeBases();
- } catch {
- toast.error("导入成功,但刷新列表失败,请手动刷新页面");
- }
- } catch (error) {
- toast.error((error as Error).message || "导入文档失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 导入 URL
- const handleImportUrls = async () => {
- if (!selectedKnowledgeBase) {
- toast.error("请先选择知识库");
- return;
- }
- const urls = importUrls
- .split("\n")
- .map((url) => url.trim())
- .filter((url) => url.length > 0);
- if (urls.length === 0) {
- toast.error("请输入至少一个 URL");
- return;
- }
- setSubmitting(true);
- try {
- const result: ImportResult = await importFromUrls({
- knowledge_base_id: selectedKnowledgeBase.id,
- urls,
- });
- const errMsg = result.errors?.length ? result.errors[0] : "";
- if (result.failed_count > 0 && result.success_count === 0) {
- toast.error(errMsg || `导入失败:${result.failed_count} 个 URL 未成功`);
- } else if (result.failed_count > 0) {
- toast.success(`导入完成:成功 ${result.success_count},失败 ${result.failed_count}${errMsg ? `(${errMsg})` : ""}`);
- } else {
- toast.success(`导入完成:成功 ${result.success_count} 个 URL`);
- }
- setImportDialogOpen(false);
- setImportUrls("");
- try {
- await loadDocuments();
- await loadKnowledgeBases();
- } catch {
- toast.error("导入成功,但刷新列表失败,请手动刷新页面");
- }
- } catch (error) {
- toast.error((error as Error).message || "导入 URL 失败");
- } finally {
- setSubmitting(false);
- }
- };
- // 格式化时间
- const formatTime = (dateStr: string) => {
- const date = new Date(dateStr);
- return date.toLocaleString("zh-CN", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- });
- };
- // 获取状态标签
- const getStatusBadge = (status: string) => {
- switch (status) {
- case "published":
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">
- <CheckCircle2 className="w-3 h-3 mr-1" />
- 已发布
- </span>
- );
- case "draft":
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800">
- 草稿
- </span>
- );
- default:
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800">
- {status}
- </span>
- );
- }
- };
- // 获取向量化状态标签
- const getEmbeddingStatusBadge = (status: string) => {
- switch (status) {
- case "completed":
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
- <CheckCircle2 className="w-3 h-3 mr-1" />
- 已完成
- </span>
- );
- case "processing":
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">
- <Loader2 className="w-3 h-3 mr-1 animate-spin" />
- 处理中
- </span>
- );
- case "failed":
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">
- <XCircle className="w-3 h-3 mr-1" />
- 失败
- </span>
- );
- case "pending":
- default:
- return (
- <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800">
- 待处理
- </span>
- );
- }
- };
- // 构建头部内容
- const headerContent = (
- <div className="bg-card border-b p-4 shadow-sm">
- <div className="flex items-center justify-between mb-4">
- <h1 className="text-xl font-bold text-foreground">知识库管理</h1>
- {!embedded && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => router.push("/agent/dashboard")}
- >
- 返回
- </Button>
- )}
- </div>
- </div>
- );
- // 构建主内容区
- const mainContent = (
- <div className="flex-1 flex overflow-hidden">
- {/* 左侧:知识库列表 */}
- <div className="w-64 border-r bg-gray-50 flex flex-col">
- <div className="p-4 border-b">
- <Button
- onClick={() => setCreateKBDialogOpen(true)}
- className="w-full"
- size="sm"
- >
- <Plus className="w-4 h-4 mr-2" />
- 新建知识库
- </Button>
- </div>
- <div className="flex-1 overflow-y-auto p-2">
- {loadingKBs ? (
- <div className="flex items-center justify-center h-full">
- <span className="text-muted-foreground">加载中...</span>
- </div>
- ) : knowledgeBases.length === 0 ? (
- <div className="flex items-center justify-center h-full">
- <span className="text-muted-foreground">暂无知识库</span>
- </div>
- ) : (
- <div className="space-y-2">
- {knowledgeBases.map((kb) => (
- <Card
- key={kb.id}
- className={`p-3 cursor-pointer transition-colors ${
- selectedKnowledgeBase?.id === kb.id
- ? "bg-green-50 border-green-300"
- : "hover:bg-gray-100"
- }`}
- onClick={() => handleSelectKnowledgeBase(kb)}
- >
- <div className="flex items-start justify-between">
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-1">
- <BookOpen className="w-4 h-4 text-blue-600 flex-shrink-0" />
- <h3 className="font-medium text-sm truncate">{kb.name}</h3>
- </div>
- {kb.description && (
- <p className="text-xs text-muted-foreground line-clamp-2 mb-1">
- {kb.description}
- </p>
- )}
- <div className="flex items-center gap-2 mt-1">
- <span className="text-xs text-muted-foreground">
- {kb.document_count} 篇文档
- </span>
- </div>
- </div>
- <div className="flex flex-col gap-1 ml-2">
- <Button
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0"
- onClick={(e) => {
- e.stopPropagation();
- handleOpenEditKB(kb);
- }}
- >
- <Edit className="w-3 h-3" />
- </Button>
- <Button
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
- onClick={(e) => {
- e.stopPropagation();
- handleOpenDeleteKB(kb);
- }}
- >
- <Trash2 className="w-3 h-3" />
- </Button>
- </div>
- </div>
- </Card>
- ))}
- </div>
- )}
- </div>
- </div>
- {/* 右侧:文档列表 */}
- <div className="flex-1 flex flex-col overflow-hidden">
- {selectedKnowledgeBase ? (
- <>
- {/* 文档列表头部 */}
- <div className="p-4 border-b bg-white">
- <div className="flex items-center justify-between mb-4">
- <h2 className="text-lg font-semibold">{selectedKnowledgeBase.name}</h2>
- <div className="flex gap-2 items-center">
- <div className="flex items-center gap-2 mr-2">
- <Label htmlFor="rag-enabled" className="text-sm text-muted-foreground whitespace-nowrap">参与 RAG</Label>
- <Switch
- id="rag-enabled"
- checked={selectedKnowledgeBase.rag_enabled !== false}
- onCheckedChange={async (checked) => {
- try {
- const updated = await updateKnowledgeBaseRAGEnabled(selectedKnowledgeBase.id, checked);
- setSelectedKnowledgeBase((prev) => (prev?.id === updated.id ? { ...prev, rag_enabled: updated.rag_enabled } : prev));
- setKnowledgeBases((prev) => prev.map((kb) => (kb.id === updated.id ? { ...kb, rag_enabled: updated.rag_enabled } : kb)));
- } catch (e) {
- toast.error((e as Error).message || "更新失败");
- }
- }}
- />
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- setImportTab("url");
- setImportDialogOpen(true);
- }}
- >
- <LinkIcon className="w-4 h-4 mr-2" />
- 导入 URL
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- setImportTab("file");
- setImportDialogOpen(true);
- }}
- >
- <Upload className="w-4 h-4 mr-2" />
- 导入文件
- </Button>
- <Button size="sm" onClick={handleOpenCreateDoc}>
- <Plus className="w-4 h-4 mr-2" />
- 新建文档
- </Button>
- </div>
- </div>
- {/* 搜索和筛选 */}
- <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
- <div className="flex-1 relative">
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
- <Input
- type="text"
- placeholder="搜索文档..."
- value={searchKeyword}
- onChange={(e) => setSearchKeyword(e.target.value)}
- className="pl-10"
- />
- </div>
- <select
- value={statusFilter}
- onChange={(e) => setStatusFilter(e.target.value)}
- className="px-3 py-2 border rounded-md text-sm"
- >
- <option value="all">全部状态</option>
- <option value="draft">草稿</option>
- <option value="published">已发布</option>
- </select>
- </div>
- </div>
- {/* 文档列表 */}
- <div className="flex-1 overflow-y-auto p-4">
- {loadingDocs ? (
- <div className="flex items-center justify-center h-full">
- <span className="text-muted-foreground">加载中...</span>
- </div>
- ) : (documents?.length ?? 0) === 0 ? (
- <div className="flex items-center justify-center h-full">
- <span className="text-muted-foreground">
- {searchKeyword || statusFilter !== "all"
- ? "没有找到匹配的文档"
- : "暂无文档"}
- </span>
- </div>
- ) : (
- <div className="space-y-4">
- {(documents ?? []).map((doc) => (
- <Card key={doc.id} className="p-4">
- <div className="flex items-start justify-between">
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-2">
- <FileText className="w-5 h-5 text-blue-600 flex-shrink-0" />
- <h3 className="font-medium text-foreground truncate">
- {doc.title}
- </h3>
- {getStatusBadge(doc.status)}
- {getEmbeddingStatusBadge(doc.embedding_status)}
- </div>
- {doc.summary && (
- <p className="text-sm text-muted-foreground mb-2 line-clamp-2">
- {doc.summary}
- </p>
- )}
- <div className="flex items-center gap-4 text-xs text-muted-foreground">
- <span>类型: {doc.type}</span>
- <span>创建时间: {formatTime(doc.created_at)}</span>
- </div>
- </div>
- <div className="flex flex-col gap-2 ml-4">
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleOpenEditDoc(doc)}
- >
- <Edit className="w-4 h-4 mr-1" />
- 编辑
- </Button>
- <Button
- variant="destructive"
- size="sm"
- onClick={() => handleOpenDeleteDoc(doc)}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- </div>
- <div className="flex gap-2">
- {doc.status === "published" ? (
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleUnpublishDoc(doc.id)}
- >
- 取消发布
- </Button>
- ) : (
- <Button
- variant="outline"
- size="sm"
- onClick={() => handlePublishDoc(doc.id)}
- >
- 发布
- </Button>
- )}
- </div>
- </div>
- </div>
- </Card>
- ))}
- </div>
- )}
- {/* 分页 */}
- {documentResult && documentResult.total_page > 1 && (
- <div className="flex items-center justify-center gap-2 mt-4">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setCurrentPage(currentPage - 1)}
- disabled={currentPage === 1}
- >
- <ChevronLeft className="w-4 h-4" />
- </Button>
- <span className="text-sm text-muted-foreground">
- 第 {currentPage} / {documentResult.total_page} 页,共 {documentResult.total} 条
- </span>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setCurrentPage(currentPage + 1)}
- disabled={currentPage >= documentResult.total_page}
- >
- <ChevronRight className="w-4 h-4" />
- </Button>
- </div>
- )}
- </div>
- </>
- ) : (
- <div className="flex items-center justify-center h-full">
- <span className="text-muted-foreground">请选择一个知识库</span>
- </div>
- )}
- </div>
- </div>
- );
- // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
- if (embedded) {
- return (
- <>
- <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
- {headerContent}
- {mainContent}
- </div>
- {/* 对话框 */}
- {/* 创建知识库对话框 */}
- <Dialog open={createKBDialogOpen} onOpenChange={setCreateKBDialogOpen}>
- <DialogContent className="max-w-2xl">
- <DialogHeader>
- <DialogTitle>创建知识库</DialogTitle>
- <DialogDescription>填写知识库名称和描述</DialogDescription>
- </DialogHeader>
- <div className="space-y-4">
- <div>
- <Label htmlFor="create-kb-name">名称 *</Label>
- <Input
- id="create-kb-name"
- value={createKBForm.name}
- onChange={(e) =>
- setCreateKBForm({ ...createKBForm, name: e.target.value })
- }
- placeholder="请输入知识库名称"
- />
- </div>
- <div>
- <Label htmlFor="create-kb-desc">描述(可选)</Label>
- <Textarea
- id="create-kb-desc"
- value={createKBForm.description || ""}
- onChange={(e) =>
- setCreateKBForm({ ...createKBForm, description: e.target.value })
- }
- placeholder="请输入知识库描述"
- rows={3}
- />
- </div>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setCreateKBDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button onClick={handleCreateKB} disabled={submitting}>
- {submitting ? "创建中..." : "创建"}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- {/* 编辑知识库对话框 */}
- <Dialog open={editKBDialogOpen} onOpenChange={setEditKBDialogOpen}>
- <DialogContent className="max-w-2xl">
- <DialogHeader>
- <DialogTitle>编辑知识库</DialogTitle>
- <DialogDescription>修改知识库名称和描述</DialogDescription>
- </DialogHeader>
- {selectedKnowledgeBase && (
- <div className="space-y-4">
- <div>
- <Label htmlFor="edit-kb-name">名称 *</Label>
- <Input
- id="edit-kb-name"
- value={editKBForm.name || selectedKnowledgeBase.name}
- onChange={(e) =>
- setEditKBForm({ ...editKBForm, name: e.target.value })
- }
- placeholder="请输入知识库名称"
- />
- </div>
- <div>
- <Label htmlFor="edit-kb-desc">描述(可选)</Label>
- <Textarea
- id="edit-kb-desc"
- value={editKBForm.description ?? selectedKnowledgeBase.description ?? ""}
- onChange={(e) =>
- setEditKBForm({ ...editKBForm, description: e.target.value })
- }
- placeholder="请输入知识库描述"
- rows={3}
- />
- </div>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setEditKBDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button onClick={handleUpdateKB} disabled={submitting}>
- {submitting ? "更新中..." : "更新"}
- </Button>
- </div>
- </div>
- )}
- </DialogContent>
- </Dialog>
- {/* 删除知识库对话框 */}
- <Dialog open={deleteKBDialogOpen} onOpenChange={setDeleteKBDialogOpen}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>删除知识库</DialogTitle>
- </DialogHeader>
- {selectedKnowledgeBase && (
- <div className="space-y-4">
- <p className="text-foreground">
- 确定要删除知识库 <strong>"{selectedKnowledgeBase.name}"</strong> 吗?
- </p>
- <p className="text-sm text-muted-foreground">
- 此操作将同时删除该知识库下的所有文档,此操作不可恢复,请谨慎操作。
- </p>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setDeleteKBDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button
- variant="destructive"
- onClick={handleDeleteKB}
- disabled={submitting}
- >
- {submitting ? "删除中..." : "删除"}
- </Button>
- </div>
- </div>
- )}
- </DialogContent>
- </Dialog>
- {/* 创建文档对话框 */}
- <Dialog open={createDocDialogOpen} onOpenChange={setCreateDocDialogOpen}>
- <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle>创建文档</DialogTitle>
- <DialogDescription>填写文档标题和内容</DialogDescription>
- </DialogHeader>
- <div className="space-y-4">
- <div>
- <Label htmlFor="create-doc-title">标题 *</Label>
- <Input
- id="create-doc-title"
- value={createDocForm.title}
- onChange={(e) =>
- setCreateDocForm({ ...createDocForm, title: e.target.value })
- }
- placeholder="请输入文档标题"
- />
- </div>
- <div>
- <Label htmlFor="create-doc-summary">摘要(可选)</Label>
- <Textarea
- id="create-doc-summary"
- value={createDocForm.summary || ""}
- onChange={(e) =>
- setCreateDocForm({ ...createDocForm, summary: e.target.value })
- }
- placeholder="请输入文档摘要"
- rows={2}
- />
- </div>
- <div>
- <Label htmlFor="create-doc-content">内容 *</Label>
- <Textarea
- id="create-doc-content"
- value={createDocForm.content}
- onChange={(e) =>
- setCreateDocForm({ ...createDocForm, content: e.target.value })
- }
- placeholder="请输入文档内容"
- rows={10}
- className="resize-none"
- />
- </div>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setCreateDocDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button onClick={handleCreateDoc} disabled={submitting}>
- {submitting ? "创建中..." : "创建"}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- {/* 编辑文档对话框 */}
- <Dialog open={editDocDialogOpen} onOpenChange={setEditDocDialogOpen}>
- <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle>编辑文档</DialogTitle>
- <DialogDescription>修改文档标题和内容</DialogDescription>
- </DialogHeader>
- <div className="space-y-4">
- <div>
- <Label htmlFor="edit-doc-title">标题 *</Label>
- <Input
- id="edit-doc-title"
- value={editDocForm.title || ""}
- onChange={(e) =>
- setEditDocForm({ ...editDocForm, title: e.target.value })
- }
- placeholder="请输入文档标题"
- />
- </div>
- <div>
- <Label htmlFor="edit-doc-summary">摘要(可选)</Label>
- <Textarea
- id="edit-doc-summary"
- value={editDocForm.summary || ""}
- onChange={(e) =>
- setEditDocForm({ ...editDocForm, summary: e.target.value })
- }
- placeholder="请输入文档摘要"
- rows={2}
- />
- </div>
- <div>
- <Label htmlFor="edit-doc-content">内容 *</Label>
- <Textarea
- id="edit-doc-content"
- value={editDocForm.content || ""}
- onChange={(e) =>
- setEditDocForm({ ...editDocForm, content: e.target.value })
- }
- placeholder="请输入文档内容"
- rows={10}
- className="resize-none"
- />
- </div>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setEditDocDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button
- onClick={() => {
- if (selectedDocument) {
- handleUpdateDoc(selectedDocument.id);
- }
- }}
- disabled={submitting}
- >
- {submitting ? "更新中..." : "更新"}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- {/* 删除文档对话框 */}
- <Dialog open={deleteDocDialogOpen} onOpenChange={setDeleteDocDialogOpen}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>删除文档</DialogTitle>
- </DialogHeader>
- {selectedDocument && (
- <div className="space-y-4">
- <p className="text-foreground">
- 确定要删除文档 <strong>"{selectedDocument.title}"</strong> 吗?
- </p>
- <p className="text-sm text-muted-foreground">
- 此操作不可恢复,请谨慎操作。
- </p>
- </div>
- )}
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setDeleteDocDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button
- variant="destructive"
- onClick={() => {
- if (selectedDocument) {
- handleDeleteDoc(selectedDocument.id);
- }
- }}
- disabled={submitting}
- >
- {submitting ? "删除中..." : "删除"}
- </Button>
- </div>
- </DialogContent>
- </Dialog>
- {/* 导入文档对话框(文件上传 + URL 导入) */}
- <Dialog
- open={importDialogOpen}
- onOpenChange={(open) => {
- setImportDialogOpen(open);
- if (!open) {
- setImportFiles([]);
- setImportUrls("");
- setImportTab("file");
- }
- }}
- >
- <DialogContent className="max-w-2xl">
- <DialogHeader>
- <DialogTitle>导入文档</DialogTitle>
- <DialogDescription>
- 选择文件上传或输入 URL 批量导入。当前支持的文件格式:<strong>Markdown(.md、.markdown)</strong>;PDF、Word 解析功能开发中。
- </DialogDescription>
- </DialogHeader>
- <Tabs
- value={importTab}
- onValueChange={(v) => setImportTab(v as "file" | "url")}
- defaultValue="file"
- >
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="file">文件上传</TabsTrigger>
- <TabsTrigger value="url">URL 导入</TabsTrigger>
- </TabsList>
- <TabsContent value="file" className="space-y-4 mt-4">
- <div>
- <Label htmlFor="import-files">选择文件</Label>
- <Input
- id="import-files"
- type="file"
- multiple
- onChange={(e) => {
- const files = Array.from(e.target.files || []);
- setImportFiles(files);
- }}
- />
- {importFiles.length > 0 && (
- <p className="text-sm text-muted-foreground mt-2">
- 已选择 {importFiles.length} 个文件
- </p>
- )}
- </div>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setImportDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button onClick={handleImportFiles} disabled={submitting}>
- {submitting ? "导入中..." : "导入"}
- </Button>
- </div>
- </TabsContent>
- <TabsContent value="url" className="space-y-4 mt-4">
- <div>
- <Label htmlFor="import-urls">URL 列表(每行一个)</Label>
- <Textarea
- id="import-urls"
- value={importUrls}
- onChange={(e) => setImportUrls(e.target.value)}
- placeholder="https://example.com/page1 https://example.com/page2"
- rows={8}
- className="resize-none"
- />
- </div>
- <div className="flex justify-end gap-2">
- <Button
- variant="outline"
- onClick={() => setImportDialogOpen(false)}
- disabled={submitting}
- >
- 取消
- </Button>
- <Button onClick={handleImportUrls} disabled={submitting}>
- {submitting ? "导入中..." : "导入"}
- </Button>
- </div>
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
- </>
- );
- }
- return (
- <ResponsiveLayout
- main={mainContent}
- header={headerContent}
- />
- );
- }
|