299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
|
||
import React, { useState, useRef } from 'react';
|
||
import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react';
|
||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
|
||
import { mockKnowledgeBases } from '../services/mockData';
|
||
import { KnowledgeBase } from '../types';
|
||
|
||
export const KnowledgeBasePage: React.FC = () => {
|
||
const [view, setView] = useState<'list' | 'detail'>('list');
|
||
const [selectedKb, setSelectedKb] = useState<KnowledgeBase | null>(null);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [kbs, setKbs] = useState(mockKnowledgeBases);
|
||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false);
|
||
const [newKbName, setNewKbName] = useState('');
|
||
|
||
const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||
|
||
const handleSelect = (kb: KnowledgeBase) => {
|
||
setSelectedKb(kb);
|
||
setView('detail');
|
||
};
|
||
|
||
const handleImportClick = () => {
|
||
setIsUploadOpen(true);
|
||
};
|
||
|
||
const handleCreateKb = () => {
|
||
if (!newKbName.trim()) return;
|
||
|
||
const newKb: KnowledgeBase = {
|
||
id: `kb_${Date.now()}`,
|
||
name: newKbName.trim(),
|
||
creator: 'Admin User',
|
||
createdAt: new Date().toISOString().split('T')[0],
|
||
documents: []
|
||
};
|
||
|
||
setKbs([newKb, ...kbs]);
|
||
setIsCreateKbOpen(false);
|
||
setNewKbName('');
|
||
};
|
||
|
||
if (view === 'detail' && selectedKb) {
|
||
return (
|
||
<div className="py-4 pb-10">
|
||
<KnowledgeBaseDetail
|
||
kb={selectedKb}
|
||
onBack={() => setView('list')}
|
||
onImport={handleImportClick}
|
||
/>
|
||
<UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold tracking-tight text-white">知识库</h1>
|
||
</div>
|
||
|
||
{/* Search Bar - Layout aligned with History Page and width filled */}
|
||
<div className="bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||
<div className="relative w-full">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索知识库名称..."
|
||
className="pl-9 border-0 bg-white/5 w-full"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{filteredKbs.map(kb => (
|
||
<Card
|
||
key={kb.id}
|
||
className="p-6 hover:border-primary/50 transition-colors cursor-pointer group"
|
||
>
|
||
<div onClick={() => handleSelect(kb)}>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||
<FileText className="h-6 w-6" />
|
||
</div>
|
||
</div>
|
||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors text-white">{kb.name}</h3>
|
||
<div className="mt-4 space-y-1 text-sm text-muted-foreground">
|
||
<p>文档数量: {kb.documents.length}</p>
|
||
<p>创建人: {kb.creator}</p>
|
||
<p>创建时间: {kb.createdAt}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
|
||
{/* Add New Placeholder */}
|
||
<div
|
||
onClick={() => setIsCreateKbOpen(true)}
|
||
className="border border-dashed border-white/10 rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-white/5 hover:border-primary/30 transition-all cursor-pointer min-h-[200px]"
|
||
>
|
||
<Plus className="h-8 w-8 mb-2 opacity-50" />
|
||
<span>新建知识库</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* New Knowledge Base Dialog */}
|
||
<Dialog
|
||
isOpen={isCreateKbOpen}
|
||
onClose={() => setIsCreateKbOpen(false)}
|
||
title="新建知识库"
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={() => setIsCreateKbOpen(false)}>取消</Button>
|
||
<Button onClick={handleCreateKb} disabled={!newKbName.trim()}>确认创建</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">知识库名称</label>
|
||
<Input
|
||
value={newKbName}
|
||
onChange={(e) => setNewKbName(e.target.value)}
|
||
placeholder="请输入知识库名称..."
|
||
autoFocus
|
||
onKeyDown={(e) => e.key === 'Enter' && handleCreateKb()}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
知识库用于存储私域文档,AI 小助手在回答问题时会优先检索绑定的知识库内容。
|
||
</p>
|
||
</div>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const KnowledgeBaseDetail: React.FC<{
|
||
kb: KnowledgeBase;
|
||
onBack: () => void;
|
||
onImport: () => void;
|
||
}> = ({ kb, onBack, onImport }) => {
|
||
const [docSearch, setDocSearch] = useState('');
|
||
const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase()));
|
||
|
||
return (
|
||
<div className="space-y-6 animate-in slide-in-from-right-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-4">
|
||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">{kb.name}</h1>
|
||
<p className="text-sm text-muted-foreground">创建于 {kb.createdAt} · by {kb.creator}</p>
|
||
</div>
|
||
</div>
|
||
<Button onClick={onImport}>
|
||
<Upload className="mr-2 h-4 w-4" /> 新增知识 (导入)
|
||
</Button>
|
||
</div>
|
||
|
||
<Card className="overflow-hidden border-white/5">
|
||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
|
||
<h3 className="font-medium text-white">文档列表</h3>
|
||
<div className="w-64">
|
||
<Input
|
||
placeholder="搜索文档..."
|
||
value={docSearch}
|
||
onChange={(e) => setDocSearch(e.target.value)}
|
||
className="bg-black/20 border-transparent focus:bg-black/40"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>文档名称</TableHead>
|
||
<TableHead>大小</TableHead>
|
||
<TableHead>上传时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<tbody>
|
||
{filteredDocs.length > 0 ? filteredDocs.map(doc => (
|
||
<TableRow key={doc.id}>
|
||
<TableCell className="font-medium flex items-center text-white">
|
||
<FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name}
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">{doc.size}</TableCell>
|
||
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80">删除</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
)) : (
|
||
<TableRow>
|
||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">暂无文档</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const [files, setFiles] = useState<File[]>([]);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleDrag = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.type === "dragenter" || e.type === "dragover") {
|
||
setDragActive(true);
|
||
} else if (e.type === "dragleave") {
|
||
setDragActive(false);
|
||
}
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setDragActive(false);
|
||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]);
|
||
}
|
||
};
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
e.preventDefault();
|
||
if (e.target.files && e.target.files[0]) {
|
||
setFiles(prev => [...prev, ...Array.from(e.target.files || [])]);
|
||
}
|
||
};
|
||
|
||
const removeFile = (idx: number) => {
|
||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||
};
|
||
|
||
return (
|
||
<Dialog
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
title="上传知识文档"
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||
<Button onClick={() => { alert('Upload Started!'); onClose(); setFiles([]); }}>确认上传</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div
|
||
className={`relative flex flex-col items-center justify-center w-full h-48 rounded-lg border-2 border-dashed transition-colors ${dragActive ? "border-primary bg-primary/10" : "border-white/10 bg-white/5 hover:bg-white/10"}`}
|
||
onDragEnter={handleDrag}
|
||
onDragLeave={handleDrag}
|
||
onDragOver={handleDrag}
|
||
onDrop={handleDrop}
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleChange}
|
||
accept=".pdf,.doc,.docx,.txt,.md"
|
||
/>
|
||
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
|
||
<p className="text-sm text-muted-foreground text-center">
|
||
<span className="font-semibold text-primary">点击上传</span> 或将文件拖拽到此处
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-1 text-white/50">支持 PDF, DOCX, TXT (Max 10MB)</p>
|
||
</div>
|
||
|
||
{files.length > 0 && (
|
||
<div className="mt-4 space-y-2 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
|
||
{files.map((file, idx) => (
|
||
<div key={idx} className="flex items-center justify-between p-2 rounded-md bg-white/5 border border-white/5">
|
||
<div className="flex items-center space-x-2 overflow-hidden">
|
||
<FileIcon className="h-4 w-4 text-primary shrink-0" />
|
||
<span className="text-sm truncate max-w-[200px] text-white">{file.name}</span>
|
||
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
||
</div>
|
||
<button onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive transition-colors">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Dialog>
|
||
);
|
||
};
|