feat(admin): Implement System Knowledge Base management module

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
This commit is contained in:
2026-01-28 21:57:44 +08:00
parent 3a4aa9123c
commit 0d9e6b9922
28 changed files with 2827 additions and 247 deletions

View File

@@ -0,0 +1,451 @@
/**
* 系统知识库详情页
*
* 管理知识库中的文档
*/
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;