Features:
- Backend: SystemKbService with full CRUD (knowledge bases + documents)
- Backend: 8 RESTful API endpoints (list/detail/create/update/delete/upload/download)
- Backend: OSS storage integration (system/knowledge-bases/{kbId}/{docId})
- Backend: RAG engine integration (document parsing, chunking, vectorization)
- Frontend: SystemKbListPage with card-based layout
- Frontend: SystemKbDetailPage with document management table
- Frontend: Master-Detail UX pattern for better user experience
- Document upload (single/batch), download (preserving original filename), delete
Technical:
- Database migration for system_knowledge_bases and system_kb_documents tables
- OSSAdapter.getSignedUrl with Content-Disposition for original filename
- Reuse RAG engine from common/rag for document processing
Tested: Local environment verified, all features working
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
/**
|
||
* 系统知识库详情页
|
||
*
|
||
* 管理知识库中的文档
|
||
*/
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import {
|
||
Card,
|
||
Button,
|
||
Table,
|
||
Space,
|
||
Upload,
|
||
message,
|
||
Popconfirm,
|
||
Tag,
|
||
Typography,
|
||
Empty,
|
||
Spin,
|
||
Progress,
|
||
Breadcrumb,
|
||
Descriptions,
|
||
Tooltip,
|
||
} from 'antd';
|
||
import {
|
||
ArrowLeftOutlined,
|
||
UploadOutlined,
|
||
DeleteOutlined,
|
||
DownloadOutlined,
|
||
FileTextOutlined,
|
||
FilePdfOutlined,
|
||
FileWordOutlined,
|
||
FileUnknownOutlined,
|
||
ClockCircleOutlined,
|
||
DatabaseOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import * as systemKbApi from '../api/systemKbApi';
|
||
import type { SystemKb, SystemKbDocument } from '../types/systemKb';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
/** 文件图标映射 */
|
||
const getFileIcon = (fileType: string | null) => {
|
||
switch (fileType) {
|
||
case 'pdf':
|
||
return <FilePdfOutlined className="text-red-500" />;
|
||
case 'doc':
|
||
case 'docx':
|
||
return <FileWordOutlined className="text-blue-500" />;
|
||
case 'txt':
|
||
case 'md':
|
||
return <FileTextOutlined className="text-gray-500" />;
|
||
default:
|
||
return <FileUnknownOutlined className="text-gray-400" />;
|
||
}
|
||
};
|
||
|
||
/** 格式化文件大小 */
|
||
const formatFileSize = (bytes: number | null) => {
|
||
if (!bytes) return '-';
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
};
|
||
|
||
/** 格式化日期 */
|
||
const formatDate = (dateStr: string) => {
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
const SystemKbDetailPage: React.FC = () => {
|
||
const { id } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
|
||
const [kb, setKb] = useState<SystemKb | null>(null);
|
||
const [documents, setDocuments] = useState<SystemKbDocument[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [docsLoading, setDocsLoading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||
|
||
// 加载知识库详情
|
||
const loadKnowledgeBase = async () => {
|
||
if (!id) return;
|
||
setLoading(true);
|
||
try {
|
||
const data = await systemKbApi.getKnowledgeBase(id);
|
||
setKb(data);
|
||
} catch (error) {
|
||
message.error('加载知识库失败');
|
||
navigate('/admin/system-kb');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 加载文档列表
|
||
const loadDocuments = async () => {
|
||
if (!id) return;
|
||
setDocsLoading(true);
|
||
try {
|
||
const data = await systemKbApi.listDocuments(id);
|
||
setDocuments(data);
|
||
} catch (error) {
|
||
message.error('加载文档列表失败');
|
||
} finally {
|
||
setDocsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadKnowledgeBase();
|
||
loadDocuments();
|
||
}, [id]);
|
||
|
||
// 上传文档
|
||
const handleUpload = async (file: File) => {
|
||
if (!id) return;
|
||
|
||
setUploadProgress(0);
|
||
try {
|
||
const result = await systemKbApi.uploadDocument(
|
||
id,
|
||
file,
|
||
(percent) => setUploadProgress(percent)
|
||
);
|
||
message.success(`上传成功:${result.chunkCount} 个分块,${result.tokenCount.toLocaleString()} tokens`);
|
||
await loadDocuments();
|
||
await loadKnowledgeBase(); // 刷新统计
|
||
} catch (error: any) {
|
||
message.error(error.response?.data?.error || '上传失败');
|
||
} finally {
|
||
setUploadProgress(null);
|
||
}
|
||
};
|
||
|
||
// 删除单个文档
|
||
const handleDeleteDoc = async (doc: SystemKbDocument) => {
|
||
if (!id) return;
|
||
|
||
try {
|
||
await systemKbApi.deleteDocument(id, doc.id);
|
||
message.success('删除成功');
|
||
await loadDocuments();
|
||
await loadKnowledgeBase();
|
||
} catch (error) {
|
||
message.error('删除失败');
|
||
}
|
||
};
|
||
|
||
// 批量删除
|
||
const handleBatchDelete = async () => {
|
||
if (!id || selectedRowKeys.length === 0) return;
|
||
|
||
try {
|
||
for (const docId of selectedRowKeys) {
|
||
await systemKbApi.deleteDocument(id, docId as string);
|
||
}
|
||
message.success(`成功删除 ${selectedRowKeys.length} 个文档`);
|
||
setSelectedRowKeys([]);
|
||
await loadDocuments();
|
||
await loadKnowledgeBase();
|
||
} catch (error) {
|
||
message.error('删除失败');
|
||
}
|
||
};
|
||
|
||
// 下载文档
|
||
const handleDownload = async (doc: SystemKbDocument) => {
|
||
if (!id) return;
|
||
|
||
try {
|
||
const result = await systemKbApi.getDocumentDownloadUrl(id, doc.id);
|
||
// 创建临时链接并触发下载
|
||
const link = document.createElement('a');
|
||
link.href = result.url;
|
||
link.download = result.filename;
|
||
link.target = '_blank';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
} catch (error) {
|
||
message.error('获取下载链接失败');
|
||
}
|
||
};
|
||
|
||
// 表格列定义
|
||
const columns: ColumnsType<SystemKbDocument> = [
|
||
{
|
||
title: '文件名',
|
||
dataIndex: 'filename',
|
||
key: 'filename',
|
||
render: (text, record) => (
|
||
<Space>
|
||
{getFileIcon(record.fileType)}
|
||
<span className="font-medium">{text}</span>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '大小',
|
||
dataIndex: 'fileSize',
|
||
key: 'fileSize',
|
||
width: 100,
|
||
render: (val) => formatFileSize(val),
|
||
},
|
||
{
|
||
title: 'Tokens',
|
||
dataIndex: 'tokenCount',
|
||
key: 'tokenCount',
|
||
width: 100,
|
||
align: 'right',
|
||
render: (val) => val?.toLocaleString() || '-',
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
width: 100,
|
||
render: (status, record) => {
|
||
const statusConfig: Record<string, { color: string; text: string }> = {
|
||
ready: { color: 'success', text: '就绪' },
|
||
processing: { color: 'processing', text: '处理中' },
|
||
failed: { color: 'error', text: '失败' },
|
||
pending: { color: 'warning', text: '等待' },
|
||
};
|
||
const config = statusConfig[status] || { color: 'default', text: status };
|
||
return (
|
||
<Tooltip title={record.errorMessage}>
|
||
<Tag color={config.color}>{config.text}</Tag>
|
||
</Tooltip>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '上传时间',
|
||
dataIndex: 'createdAt',
|
||
key: 'createdAt',
|
||
width: 160,
|
||
render: (val) => formatDate(val),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 120,
|
||
render: (_, record) => (
|
||
<Space size="small">
|
||
<Tooltip title="下载">
|
||
<Button
|
||
type="text"
|
||
icon={<DownloadOutlined />}
|
||
onClick={() => handleDownload(record)}
|
||
disabled={record.status !== 'ready'}
|
||
/>
|
||
</Tooltip>
|
||
<Popconfirm
|
||
title="确定删除此文档?"
|
||
onConfirm={() => handleDeleteDoc(record)}
|
||
>
|
||
<Tooltip title="删除">
|
||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||
</Tooltip>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="h-96 flex items-center justify-center">
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!kb) {
|
||
return (
|
||
<div className="p-6">
|
||
<Empty description="知识库不存在" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="p-6">
|
||
{/* 面包屑 */}
|
||
<Breadcrumb
|
||
className="mb-4"
|
||
items={[
|
||
{ title: <a onClick={() => navigate('/admin/system-kb')}>系统知识库</a> },
|
||
{ title: kb.name },
|
||
]}
|
||
/>
|
||
|
||
{/* 返回按钮和标题 */}
|
||
<div className="flex items-center gap-4 mb-6">
|
||
<Button
|
||
icon={<ArrowLeftOutlined />}
|
||
onClick={() => navigate('/admin/system-kb')}
|
||
>
|
||
返回列表
|
||
</Button>
|
||
<div>
|
||
<Title level={4} className="mb-0">{kb.name}</Title>
|
||
<Text code>{kb.code}</Text>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 知识库信息卡片 */}
|
||
<Card className="mb-6">
|
||
<Descriptions
|
||
column={{ xs: 1, sm: 2, md: 4 }}
|
||
items={[
|
||
{
|
||
key: 'documents',
|
||
label: (
|
||
<Space>
|
||
<FileTextOutlined />
|
||
<span>文档数</span>
|
||
</Space>
|
||
),
|
||
children: <span className="text-xl font-semibold">{kb.documentCount}</span>,
|
||
},
|
||
{
|
||
key: 'tokens',
|
||
label: (
|
||
<Space>
|
||
<DatabaseOutlined />
|
||
<span>总 Tokens</span>
|
||
</Space>
|
||
),
|
||
children: <span className="text-xl font-semibold">{kb.totalTokens.toLocaleString()}</span>,
|
||
},
|
||
{
|
||
key: 'created',
|
||
label: (
|
||
<Space>
|
||
<ClockCircleOutlined />
|
||
<span>创建时间</span>
|
||
</Space>
|
||
),
|
||
children: formatDate(kb.createdAt),
|
||
},
|
||
{
|
||
key: 'description',
|
||
label: '描述',
|
||
children: kb.description || <Text type="secondary">无</Text>,
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 文档管理卡片 */}
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<FileTextOutlined />
|
||
<span>文档列表</span>
|
||
<Tag>{documents.length} 个</Tag>
|
||
</Space>
|
||
}
|
||
extra={
|
||
<Space>
|
||
{selectedRowKeys.length > 0 && (
|
||
<Popconfirm
|
||
title={`确定删除选中的 ${selectedRowKeys.length} 个文档?`}
|
||
onConfirm={handleBatchDelete}
|
||
>
|
||
<Button danger icon={<DeleteOutlined />}>
|
||
批量删除
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
<Upload
|
||
accept=".pdf,.doc,.docx,.txt,.md"
|
||
showUploadList={false}
|
||
multiple
|
||
beforeUpload={(file) => {
|
||
handleUpload(file);
|
||
return false;
|
||
}}
|
||
>
|
||
<Button type="primary" icon={<UploadOutlined />}>
|
||
上传文档
|
||
</Button>
|
||
</Upload>
|
||
</Space>
|
||
}
|
||
>
|
||
{/* 上传进度 */}
|
||
{uploadProgress !== null && (
|
||
<div className="mb-4 p-4 bg-blue-50 rounded-lg">
|
||
<div className="flex items-center gap-4">
|
||
<Spin size="small" />
|
||
<div className="flex-1">
|
||
<Progress percent={uploadProgress} status="active" />
|
||
<Text type="secondary">正在上传并处理文档(向量化)...</Text>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 文档表格 */}
|
||
<Spin spinning={docsLoading}>
|
||
{documents.length > 0 ? (
|
||
<Table
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: setSelectedRowKeys,
|
||
}}
|
||
columns={columns}
|
||
dataSource={documents}
|
||
rowKey="id"
|
||
pagination={documents.length > 10 ? { pageSize: 10 } : false}
|
||
/>
|
||
) : (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="暂无文档"
|
||
>
|
||
<Upload
|
||
accept=".pdf,.doc,.docx,.txt,.md"
|
||
showUploadList={false}
|
||
beforeUpload={(file) => {
|
||
handleUpload(file);
|
||
return false;
|
||
}}
|
||
>
|
||
<Button type="primary" icon={<UploadOutlined />}>
|
||
上传第一个文档
|
||
</Button>
|
||
</Upload>
|
||
</Empty>
|
||
)}
|
||
</Spin>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SystemKbDetailPage;
|