420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import api from "@/lib/api";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { toast } from "sonner";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import {
|
|
BookOpen,
|
|
Upload,
|
|
FileText,
|
|
Trash2,
|
|
Plus,
|
|
Database,
|
|
Search,
|
|
} from "lucide-react";
|
|
|
|
interface KnowledgeBase {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
visibility: string;
|
|
document_count: number;
|
|
total_chars: number;
|
|
status: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface KBDocument {
|
|
id: string;
|
|
filename: string;
|
|
file_size: number;
|
|
file_type: string;
|
|
status: string;
|
|
created_at: string;
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return bytes + " B";
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
}
|
|
|
|
function getStatusBadge(status: string) {
|
|
switch (status) {
|
|
case "completed":
|
|
return <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">已完成</Badge>;
|
|
case "indexing":
|
|
return <Badge className="bg-amber-50 text-amber-700 border-amber-200">索引中</Badge>;
|
|
case "failed":
|
|
return <Badge variant="destructive">失败</Badge>;
|
|
default:
|
|
return <Badge variant="secondary">{status}</Badge>;
|
|
}
|
|
}
|
|
|
|
function getVisibilityLabel(v: string) {
|
|
switch (v) {
|
|
case "public": return "全单位";
|
|
case "department": return "本科室";
|
|
default: return "私有";
|
|
}
|
|
}
|
|
|
|
export default function KnowledgePage() {
|
|
const queryClient = useQueryClient();
|
|
const user = useAuthStore((s) => s.user);
|
|
const orgId = user?.org_id;
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null);
|
|
const [form, setForm] = useState({ name: "", description: "", visibility: "private" });
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const { data: knowledgeBases, isLoading: kbLoading } = useQuery({
|
|
queryKey: ["knowledgeBases", orgId],
|
|
queryFn: () => api.get<KnowledgeBase[]>(`/api/v1/knowledge/${orgId ? `?org_id=${orgId}` : ""}`),
|
|
});
|
|
|
|
const { data: documents } = useQuery({
|
|
queryKey: ["kbDocuments", selectedKB?.id],
|
|
queryFn: () => api.get<KBDocument[]>(`/api/v1/knowledge/${selectedKB!.id}/documents`),
|
|
enabled: !!selectedKB,
|
|
});
|
|
|
|
const createKB = useMutation({
|
|
mutationFn: () => api.post("/api/v1/knowledge/", form),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
|
setShowCreate(false);
|
|
setForm({ name: "", description: "", visibility: "private" });
|
|
toast.success("知识库创建成功");
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const deleteKB = useMutation({
|
|
mutationFn: (id: string) => api.delete(`/api/v1/knowledge/${id}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
|
if (selectedKB) setSelectedKB(null);
|
|
toast.success("知识库已删除");
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const uploadDoc = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const res = await fetch(`/api/v1/knowledge/${selectedKB!.id}/documents`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
|
body: formData,
|
|
});
|
|
if (!res.ok) throw new Error("上传失败");
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["kbDocuments"] });
|
|
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
|
toast.success("文档上传成功");
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const deleteDoc = useMutation({
|
|
mutationFn: (docId: string) =>
|
|
api.delete(`/api/v1/knowledge/${selectedKB!.id}/documents/${docId}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["kbDocuments"] });
|
|
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
|
toast.success("文档已删除");
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const handleFileUpload = useCallback(() => {
|
|
fileInputRef.current?.click();
|
|
}, []);
|
|
|
|
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
uploadDoc.mutate(file);
|
|
e.target.value = "";
|
|
}
|
|
}, [uploadDoc]);
|
|
|
|
const filteredKBs = knowledgeBases?.filter(
|
|
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm)
|
|
);
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-700">
|
|
<Database className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold">知识库管理</h1>
|
|
<p className="text-sm text-muted-foreground">管理政策法规、制度文件等知识资源</p>
|
|
</div>
|
|
</div>
|
|
<Button onClick={() => setShowCreate(true)} className="gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
新建知识库
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-1 space-y-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="搜索知识库..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{kbLoading && (
|
|
<div className="space-y-3">
|
|
{[1, 2].map((i) => (
|
|
<Card key={i} className="animate-pulse">
|
|
<CardContent className="py-6">
|
|
<div className="h-4 bg-muted rounded w-3/4 mb-2" />
|
|
<div className="h-3 bg-muted rounded w-1/2" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!kbLoading && filteredKBs?.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-10 text-center">
|
|
<BookOpen className="h-10 w-10 mx-auto text-muted-foreground/40 mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{searchTerm ? "未找到匹配的知识库" : "暂无知识库,点击上方按钮创建"}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{filteredKBs?.map((kb) => (
|
|
<Card
|
|
key={kb.id}
|
|
className={`cursor-pointer transition-all ${
|
|
selectedKB?.id === kb.id
|
|
? "border-primary ring-1 ring-primary/20"
|
|
: "hover:border-muted-foreground/30"
|
|
}`}
|
|
onClick={() => setSelectedKB(kb)}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<BookOpen className="h-4 w-4 text-blue-600" />
|
|
{kb.name}
|
|
</CardTitle>
|
|
<Badge variant="secondary" className="text-xs">{kb.document_count} 文档</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
|
{kb.description || "暂无描述"}
|
|
</p>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-xs">{getVisibilityLabel(kb.visibility)}</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive h-6 px-2"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (confirm("确定要删除该知识库吗?所有文档将一并删除。")) {
|
|
deleteKB.mutate(kb.id);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="lg:col-span-2">
|
|
{selectedKB ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BookOpen className="h-5 w-5 text-blue-600" />
|
|
{selectedKB.name}
|
|
</CardTitle>
|
|
<p className="text-sm text-muted-foreground mt-1">{selectedKB.description}</p>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Badge variant="outline">{getVisibilityLabel(selectedKB.visibility)}</Badge>
|
|
<span className="text-xs text-muted-foreground">{selectedKB.document_count} 个文档</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
|
|
onChange={onFileChange}
|
|
/>
|
|
<Button onClick={handleFileUpload} disabled={uploadDoc.isPending} className="gap-2">
|
|
<Upload className="h-4 w-4" />
|
|
{uploadDoc.isPending ? "上传中..." : "上传文档"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{documents?.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<FileText className="h-10 w-10 mx-auto text-muted-foreground/40 mb-3" />
|
|
<p className="text-sm">暂无文档</p>
|
|
<p className="text-xs mt-1">支持上传 PDF、DOCX、TXT、MD、CSV、XLSX 格式</p>
|
|
</div>
|
|
) : (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="text-left p-3 font-medium">文件名</th>
|
|
<th className="text-left p-3 font-medium">类型</th>
|
|
<th className="text-left p-3 font-medium">大小</th>
|
|
<th className="text-left p-3 font-medium">状态</th>
|
|
<th className="text-left p-3 font-medium">上传时间</th>
|
|
<th className="text-left p-3 font-medium">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{documents?.map((doc) => (
|
|
<tr key={doc.id} className="border-t hover:bg-muted/30 transition-colors">
|
|
<td className="p-3 font-medium flex items-center gap-2">
|
|
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
|
<span className="truncate max-w-[200px]">{doc.filename}</span>
|
|
</td>
|
|
<td className="p-3 text-muted-foreground uppercase text-xs">{doc.file_type || "-"}</td>
|
|
<td className="p-3 text-muted-foreground">{formatFileSize(doc.file_size)}</td>
|
|
<td className="p-3">{getStatusBadge(doc.status)}</td>
|
|
<td className="p-3 text-muted-foreground text-xs">{new Date(doc.created_at).toLocaleString("zh-CN")}</td>
|
|
<td className="p-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive h-7 px-2"
|
|
onClick={() => deleteDoc.mutate(doc.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-20 text-center text-muted-foreground">
|
|
<Database className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
|
<p className="text-sm">请从左侧选择一个知识库查看文档</p>
|
|
<p className="text-xs mt-1">或点击"新建知识库"创建政策法规资源库</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<BookOpen className="h-5 w-5 text-blue-600" />
|
|
新建知识库
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>名称 *</Label>
|
|
<Input
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
placeholder="例如:科技局政策法规库"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>描述</Label>
|
|
<Textarea
|
|
value={form.description}
|
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
placeholder="简要描述知识库的用途和包含的文档类型"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>可见范围</Label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{[
|
|
{ value: "private", label: "私有" },
|
|
{ value: "department", label: "本科室" },
|
|
{ value: "public", label: "全单位" },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setForm({ ...form, visibility: opt.value })}
|
|
className={`p-2.5 rounded-lg border text-sm text-center transition-colors ${
|
|
form.visibility === opt.value
|
|
? "border-primary bg-primary/5 text-primary font-medium"
|
|
: "border-border hover:border-primary/30"
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" onClick={() => setShowCreate(false)}>取消</Button>
|
|
<Button
|
|
onClick={() => createKB.mutate()}
|
|
disabled={!form.name.trim() || createKB.isPending}
|
|
>
|
|
创建
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|