feat(frontend): Day 21 knowledge base management frontend completed

This commit is contained in:
AI Clinical Dev Team
2025-10-11 12:48:26 +08:00
parent 5bacdc1768
commit 186ae55302
9 changed files with 1459 additions and 221 deletions

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Modal, Form, Input, message } from 'antd';
const { TextArea } = Input;
interface CreateKBDialogProps {
open: boolean;
onCancel: () => void;
onCreate: (name: string, description?: string) => Promise<void>;
}
const CreateKBDialog: React.FC<CreateKBDialogProps> = ({
open,
onCancel,
onCreate,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleOk = async () => {
try {
const values = await form.validateFields();
setLoading(true);
await onCreate(values.name, values.description);
message.success('知识库创建成功!');
form.resetFields();
onCancel();
} catch (error: any) {
if (error.errorFields) {
// 表单验证错误
return;
}
message.error(error.message || '创建失败');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
return (
<Modal
title="创建知识库"
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="创建"
cancelText="取消"
confirmLoading={loading}
destroyOnClose
>
<Form
form={form}
layout="vertical"
autoComplete="off"
>
<Form.Item
name="name"
label="知识库名称"
rules={[
{ required: true, message: '请输入知识库名称' },
{ max: 50, message: '名称不能超过50个字符' },
]}
>
<Input
placeholder="例如:文献综述资料"
maxLength={50}
showCount
/>
</Form.Item>
<Form.Item
name="description"
label="描述(选填)"
rules={[
{ max: 200, message: '描述不能超过200个字符' },
]}
>
<TextArea
placeholder="简要描述该知识库的用途..."
rows={4}
maxLength={200}
showCount
/>
</Form.Item>
<div style={{
padding: 12,
background: '#f0f5ff',
borderRadius: 4,
fontSize: 13,
color: '#595959'
}}>
<p style={{ margin: 0, marginBottom: 8 }}>
📌 <strong></strong>
</p>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li> 3 </li>
<li> 50 </li>
<li>PDFDOCDOCXTXTMD</li>
<li>10MB</li>
</ul>
</div>
</Form>
</Modal>
);
};
export default CreateKBDialog;

View File

@@ -0,0 +1,211 @@
import React from 'react';
import { Table, Tag, Button, Popconfirm, Space, Typography, Progress, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
ReloadOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import type { Document } from '../../api/knowledgeBaseApi';
import type { ColumnsType } from 'antd/es/table';
const { Text } = Typography;
interface DocumentListProps {
documents: Document[];
loading: boolean;
onDelete: (doc: Document) => void;
onReprocess: (doc: Document) => void;
}
const DocumentList: React.FC<DocumentListProps> = ({
documents,
loading,
onDelete,
onReprocess,
}) => {
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getStatusTag = (status: Document['status']) => {
const statusConfig = {
uploading: {
color: 'blue',
icon: <LoadingOutlined />,
text: '上传中'
},
parsing: {
color: 'processing',
icon: <ClockCircleOutlined />,
text: '解析中'
},
indexing: {
color: 'processing',
icon: <ClockCircleOutlined />,
text: '索引中'
},
completed: {
color: 'success',
icon: <CheckCircleOutlined />,
text: '已就绪'
},
error: {
color: 'error',
icon: <CloseCircleOutlined />,
text: '失败'
},
};
const config = statusConfig[status] || statusConfig.uploading;
return (
<Tag icon={config.icon} color={config.color}>
{config.text}
</Tag>
);
};
const columns: ColumnsType<Document> = [
{
title: '文件名',
dataIndex: 'filename',
key: 'filename',
ellipsis: true,
render: (filename: string) => (
<Space>
<FileTextOutlined style={{ color: '#1890ff' }} />
<Text ellipsis>{filename}</Text>
</Space>
),
},
{
title: '文件大小',
dataIndex: 'fileSizeBytes',
key: 'fileSizeBytes',
width: 120,
render: (size: number) => formatFileSize(size),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: (status: Document['status'], record: Document) => {
if (status === 'error' && record.errorMessage) {
return (
<Tooltip title={record.errorMessage}>
{getStatusTag(status)}
</Tooltip>
);
}
return getStatusTag(status);
},
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 150,
render: (progress: number, record: Document) => {
if (record.status === 'completed') {
return (
<Space size={4}>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.segmentsCount || 0}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>|</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.tokensCount || 0} tokens
</Text>
</Space>
);
}
if (record.status === 'error') {
return <Text type="danger"></Text>;
}
return (
<Progress
percent={progress}
size="small"
status={progress === 100 ? 'success' : 'active'}
/>
);
},
},
{
title: '上传时间',
dataIndex: 'uploadedAt',
key: 'uploadedAt',
width: 180,
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: '操作',
key: 'action',
width: 150,
render: (_: any, record: Document) => (
<Space size="small">
{record.status === 'error' && (
<Tooltip title="重新处理">
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => onReprocess(record)}
>
</Button>
</Tooltip>
)}
<Popconfirm
title="确认删除?"
description={`删除文档 "${record.filename}"`}
onConfirm={() => onDelete(record)}
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={documents}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total} 个文档`,
}}
locale={{
emptyText: '暂无文档,请上传文件',
}}
/>
);
};
export default DocumentList;

View File

@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { Upload, message, Progress, Card } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { useKnowledgeBaseStore } from '../../stores/useKnowledgeBaseStore';
const { Dragger } = Upload;
interface DocumentUploadProps {
kbId: string;
onUploadSuccess: () => void;
disabled?: boolean;
maxDocuments?: number;
currentDocumentCount?: number;
}
const DocumentUpload: React.FC<DocumentUploadProps> = ({
kbId,
onUploadSuccess,
disabled = false,
maxDocuments = 50,
currentDocumentCount = 0,
}) => {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
'text/markdown',
];
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.md'];
const isAtLimit = currentDocumentCount >= maxDocuments;
const beforeUpload = (file: File) => {
// 检查文档数量限制
if (isAtLimit) {
message.error(`已达到文档数量上限(${maxDocuments}个)`);
return Upload.LIST_IGNORE;
}
// 检查文件类型
if (!allowedTypes.includes(file.type) && !allowedExtensions.some(ext => file.name.endsWith(ext))) {
message.error(`不支持的文件类型。支持PDF、DOC、DOCX、TXT、MD`);
return Upload.LIST_IGNORE;
}
// 检查文件大小 (10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
message.error('文件大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false; // 阻止自动上传,我们手动处理
};
const customRequest: UploadProps['customRequest'] = async (options) => {
const { file, onSuccess, onError } = options;
try {
setUploading(true);
setUploadProgress(0);
// 使用 knowledgeBaseStore 的 uploadDocument 方法
await useKnowledgeBaseStore.getState().uploadDocument(
kbId,
file as File,
(progress) => {
setUploadProgress(progress);
}
);
message.success(`${(file as File).name} 上传成功`);
if (onSuccess) {
onSuccess('ok');
}
// 通知父组件刷新文档列表
onUploadSuccess();
} catch (error: any) {
console.error('Upload error:', error);
message.error(error.message || '上传失败');
if (onError) {
onError(error);
}
} finally {
setUploading(false);
setUploadProgress(0);
}
};
return (
<Card>
<Dragger
name="file"
multiple={false}
beforeUpload={beforeUpload}
customRequest={customRequest}
disabled={disabled || isAtLimit || uploading}
showUploadList={false}
style={{ marginBottom: uploading ? 16 : 0 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">
{isAtLimit ? `已达到文档数量上限(${maxDocuments}个)` : '点击或拖拽文件到此区域上传'}
</p>
<p className="ant-upload-hint">
PDFDOCDOCXTXTMD | 10MB
</p>
<p className="ant-upload-hint" style={{ marginTop: 8, fontSize: 13, color: '#8c8c8c' }}>
: {currentDocumentCount}/{maxDocuments}
</p>
</Dragger>
{uploading && (
<div style={{ marginTop: 16 }}>
<Progress
percent={uploadProgress}
status={uploadProgress === 100 ? 'success' : 'active'}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
<p style={{ marginTop: 8, textAlign: 'center', color: '#595959', fontSize: 13 }}>
{uploadProgress < 100 ? '正在上传...' : '上传完成,正在处理...'}
</p>
</div>
)}
</Card>
);
};
export default DocumentUpload;

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, message } from 'antd';
import type { KnowledgeBase } from '../../api/knowledgeBaseApi';
const { TextArea } = Input;
interface EditKBDialogProps {
open: boolean;
knowledgeBase: KnowledgeBase | null;
onCancel: () => void;
onUpdate: (id: string, name: string, description?: string) => Promise<void>;
}
const EditKBDialog: React.FC<EditKBDialogProps> = ({
open,
knowledgeBase,
onCancel,
onUpdate,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// 当知识库数据变化时,更新表单
useEffect(() => {
if (open && knowledgeBase) {
form.setFieldsValue({
name: knowledgeBase.name,
description: knowledgeBase.description || '',
});
}
}, [open, knowledgeBase, form]);
const handleOk = async () => {
if (!knowledgeBase) return;
try {
const values = await form.validateFields();
setLoading(true);
await onUpdate(knowledgeBase.id, values.name, values.description);
message.success('知识库更新成功!');
onCancel();
} catch (error: any) {
if (error.errorFields) {
// 表单验证错误
return;
}
message.error(error.message || '更新失败');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
return (
<Modal
title="编辑知识库"
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="保存"
cancelText="取消"
confirmLoading={loading}
destroyOnClose
>
<Form
form={form}
layout="vertical"
autoComplete="off"
>
<Form.Item
name="name"
label="知识库名称"
rules={[
{ required: true, message: '请输入知识库名称' },
{ max: 50, message: '名称不能超过50个字符' },
]}
>
<Input
placeholder="例如:文献综述资料"
maxLength={50}
showCount
/>
</Form.Item>
<Form.Item
name="description"
label="描述(选填)"
rules={[
{ max: 200, message: '描述不能超过200个字符' },
]}
>
<TextArea
placeholder="简要描述该知识库的用途..."
rows={4}
maxLength={200}
showCount
/>
</Form.Item>
</Form>
</Modal>
);
};
export default EditKBDialog;

View File

@@ -0,0 +1,203 @@
import React from 'react';
import { Card, Button, Empty, Tag, Popconfirm, Space, Typography } from 'antd';
import {
PlusOutlined,
FolderOutlined,
FileTextOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons';
import type { KnowledgeBase } from '../../api/knowledgeBaseApi';
const { Title, Text, Paragraph } = Typography;
interface KnowledgeBaseListProps {
knowledgeBases: KnowledgeBase[];
loading: boolean;
onCreateClick: () => void;
onEditClick: (kb: KnowledgeBase) => void;
onDeleteClick: (kb: KnowledgeBase) => void;
onSelectClick: (kb: KnowledgeBase) => void;
}
const KnowledgeBaseList: React.FC<KnowledgeBaseListProps> = ({
knowledgeBases,
loading,
onCreateClick,
onEditClick,
onDeleteClick,
onSelectClick,
}) => {
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const canCreateMore = knowledgeBases.length < 3;
return (
<div>
{/* 标题和创建按钮 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24
}}>
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary">
使 {knowledgeBases.length}/3
</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onCreateClick}
disabled={!canCreateMore || loading}
>
</Button>
</div>
{/* 配额提示 */}
{!canCreateMore && (
<div style={{ marginBottom: 16 }}>
<Tag color="warning">
3
</Tag>
</div>
)}
{/* 知识库列表 */}
{knowledgeBases.length === 0 ? (
<Card>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有知识库,创建第一个吧!"
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onCreateClick}
>
</Button>
</Empty>
</Card>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: 16
}}>
{knowledgeBases.map((kb) => (
<Card
key={kb.id}
hoverable
onClick={() => onSelectClick(kb)}
style={{ cursor: 'pointer' }}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEditClick(kb);
}}
>
</Button>,
<Popconfirm
key="delete"
title="确认删除?"
description={`删除知识库 "${kb.name}" 将同时删除其中的所有文档,此操作不可恢复。`}
onConfirm={(e) => {
e?.stopPropagation();
onDeleteClick(kb);
}}
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
>
</Button>
</Popconfirm>,
]}
>
<Card.Meta
avatar={
<div style={{
width: 48,
height: 48,
background: '#1890ff20',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<FolderOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
}
title={
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Text strong ellipsis style={{ flex: 1 }}>
{kb.name}
</Text>
</div>
}
description={
<div>
<Paragraph
ellipsis={{ rows: 2 }}
type="secondary"
style={{ marginBottom: 12, minHeight: 44 }}
>
{kb.description || '暂无描述'}
</Paragraph>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<FileTextOutlined style={{ marginRight: 8, color: '#8c8c8c' }} />
<Text type="secondary" style={{ fontSize: 13 }}>
{kb._count?.documents || kb.fileCount || 0}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
: {formatFileSize(kb.totalSizeBytes)}
</Text>
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(kb.createdAt).toLocaleDateString()}
</Text>
</div>
</Space>
</div>
}
/>
</Card>
))}
</div>
)}
</div>
);
};
export default KnowledgeBaseList;