Files
GovAI/apps/web/src/app/(portal)/knowledge/page.tsx
T
2026-06-15 23:48:37 +08:00

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"> PDFDOCXTXTMDCSVXLSX </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>
);
}