Initial commit: GovAI 政务AI平台
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user