Files
AIclinicalresearch/frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx
HaHafeng 0d9e6b9922 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
2026-01-28 21:57:44 +08:00

452 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 系统知识库详情页
*
* 管理知识库中的文档
*/
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;