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,185 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001';
const api = axios.create({
baseURL: `${API_BASE_URL}/api/v1`,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
/**
* 知识库类型定义
*/
export interface KnowledgeBase {
id: string;
userId: string;
name: string;
description?: string;
difyDatasetId: string;
fileCount: number;
totalSizeBytes: number;
createdAt: string;
updatedAt: string;
_count?: {
documents: number;
};
}
export interface Document {
id: string;
kbId: string;
userId: string;
filename: string;
fileType: string;
fileSizeBytes: number;
fileUrl: string;
difyDocumentId: string;
status: 'uploading' | 'parsing' | 'indexing' | 'completed' | 'error';
progress: number;
errorMessage?: string;
segmentsCount?: number;
tokensCount?: number;
uploadedAt: string;
processedAt?: string;
}
export interface CreateKnowledgeBaseRequest {
name: string;
description?: string;
}
export interface UpdateKnowledgeBaseRequest {
name?: string;
description?: string;
}
export interface KnowledgeBaseStats {
totalDocuments: number;
completedDocuments: number;
processingDocuments: number;
errorDocuments: number;
totalSizeBytes: number;
totalTokens: number;
}
/**
* 知识库管理API
*/
export const knowledgeBaseApi = {
/**
* 获取知识库列表
*/
async getList(): Promise<KnowledgeBase[]> {
const response = await api.get('/knowledge-bases');
return response.data.data;
},
/**
* 获取知识库详情
*/
async getById(id: string): Promise<KnowledgeBase> {
const response = await api.get(`/knowledge-bases/${id}`);
return response.data.data;
},
/**
* 创建知识库
*/
async create(data: CreateKnowledgeBaseRequest): Promise<KnowledgeBase> {
const response = await api.post('/knowledge-bases', data);
return response.data.data;
},
/**
* 更新知识库
*/
async update(id: string, data: UpdateKnowledgeBaseRequest): Promise<KnowledgeBase> {
const response = await api.put(`/knowledge-bases/${id}`, data);
return response.data.data;
},
/**
* 删除知识库
*/
async delete(id: string): Promise<void> {
await api.delete(`/knowledge-bases/${id}`);
},
/**
* 获取知识库统计信息
*/
async getStats(id: string): Promise<KnowledgeBaseStats> {
const response = await api.get(`/knowledge-bases/${id}/stats`);
return response.data.data;
},
/**
* 检索知识库
*/
async search(id: string, query: string, topK: number = 3): Promise<any> {
const response = await api.get(`/knowledge-bases/${id}/search`, {
params: { query, top_k: topK },
});
return response.data.data;
},
};
/**
* 文档管理API
*/
export const documentApi = {
/**
* 获取文档列表
*/
async getList(kbId: string): Promise<Document[]> {
const response = await api.get(`/knowledge-bases/${kbId}/documents`);
return response.data.data;
},
/**
* 获取文档详情
*/
async getById(id: string): Promise<Document> {
const response = await api.get(`/documents/${id}`);
return response.data.data;
},
/**
* 上传文档
*/
async upload(kbId: string, file: File, onProgress?: (progress: number) => void): Promise<Document> {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(`/knowledge-bases/${kbId}/documents`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percentCompleted);
}
},
});
return response.data.data;
},
/**
* 删除文档
*/
async delete(id: string): Promise<void> {
await api.delete(`/documents/${id}`);
},
/**
* 重新处理文档
*/
async reprocess(id: string): Promise<void> {
await api.post(`/documents/${id}/reprocess`);
},
};

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;

View File

@@ -1,6 +1,6 @@
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Typography, Space, Alert, Spin, message } from 'antd' import { Space, Alert, Spin, message } from 'antd'
import { RobotOutlined } from '@ant-design/icons' import { RobotOutlined } from '@ant-design/icons'
import { agentApi, type AgentConfig } from '../api/agentApi' import { agentApi, type AgentConfig } from '../api/agentApi'
import conversationApi, { type Conversation, type Message } from '../api/conversationApi' import conversationApi, { type Conversation, type Message } from '../api/conversationApi'
@@ -9,8 +9,6 @@ import MessageInput from '../components/chat/MessageInput'
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector' import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
import { useProjectStore } from '../stores/useProjectStore' import { useProjectStore } from '../stores/useProjectStore'
const { Title, Paragraph } = Typography
const AgentChatPage = () => { const AgentChatPage = () => {
const { agentId } = useParams() const { agentId } = useParams()
const { currentProject } = useProjectStore() const { currentProject } = useProjectStore()
@@ -71,10 +69,8 @@ const AgentChatPage = () => {
title: `${agent.name}的对话`, title: `${agent.name}的对话`,
}) })
if (response.success && response.data) { setConversation(response.data.data || null)
setConversation(response.data)
setMessages([]) setMessages([])
}
} catch (err) { } catch (err) {
console.error('Failed to create conversation:', err) console.error('Failed to create conversation:', err)
message.error('创建对话失败') message.error('创建对话失败')

View File

@@ -1,225 +1,281 @@
import { import React, { useEffect, useState } from 'react';
Card, import { Card, Tabs, Button, message } from 'antd';
Button, import { ArrowLeftOutlined } from '@ant-design/icons';
Space, import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore';
Table, import KnowledgeBaseList from '../components/knowledge/KnowledgeBaseList';
Tag, import CreateKBDialog from '../components/knowledge/CreateKBDialog';
Progress, import EditKBDialog from '../components/knowledge/EditKBDialog';
Alert, import DocumentUpload from '../components/knowledge/DocumentUpload';
} from 'antd' import DocumentList from '../components/knowledge/DocumentList';
import { import type { KnowledgeBase, Document } from '../api/knowledgeBaseApi';
PlusOutlined,
FolderOutlined,
FileTextOutlined,
UploadOutlined,
DeleteOutlined,
SyncOutlined,
} from '@ant-design/icons'
const KnowledgePage = () => { const { TabPane } = Tabs;
// 模拟知识库数据
const mockKnowledgeBases = [
{
id: '1',
name: '心血管疾病研究文献库',
fileCount: 15,
totalSize: '45MB',
createdAt: '2025-10-05',
status: 'ready',
},
]
// 模拟文档数据 const KnowledgePage: React.FC = () => {
const mockDocuments = [ const {
{ knowledgeBases,
id: '1', currentKb,
name: '高血压治疗指南2024.pdf', documents,
size: '3.2MB', loading,
uploadedAt: '2025-10-05 14:30', error,
status: 'processed', fetchKnowledgeBases,
}, fetchKnowledgeBaseById,
{ createKnowledgeBase,
id: '2', updateKnowledgeBase,
name: '心血管疾病流行病学研究.docx', deleteKnowledgeBase,
size: '1.8MB', fetchDocuments,
uploadedAt: '2025-10-05 15:20', deleteDocument,
status: 'processing', reprocessDocument,
}, setCurrentKb,
] clearError,
} = useKnowledgeBaseStore();
const kbColumns = [ const [createDialogOpen, setCreateDialogOpen] = useState(false);
{ const [editDialogOpen, setEditDialogOpen] = useState(false);
title: '知识库名称', const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<Space>
<FolderOutlined style={{ color: '#1890ff' }} />
<span>{text}</span>
</Space>
),
},
{
title: '文档数量',
dataIndex: 'fileCount',
key: 'fileCount',
render: (count: number) => `${count} / 50`,
},
{
title: '总大小',
dataIndex: 'totalSize',
key: 'totalSize',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === 'ready' ? 'green' : 'orange'}>
{status === 'ready' ? '就绪' : '处理中'}
</Tag>
),
},
{
title: '操作',
key: 'actions',
render: () => (
<Space>
<Button size="small" icon={<UploadOutlined />} disabled>
</Button>
<Button size="small" danger icon={<DeleteOutlined />} disabled>
</Button>
</Space>
),
},
]
const docColumns = [ // 初始加载知识库列表
{ useEffect(() => {
title: '文件名', fetchKnowledgeBases();
dataIndex: 'name', }, []);
key: 'name',
render: (text: string) => ( // 显示错误提示
<Space> useEffect(() => {
<FileTextOutlined /> if (error) {
<span>{text}</span> message.error(error);
</Space> clearError();
),
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
},
{
title: '上传时间',
dataIndex: 'uploadedAt',
key: 'uploadedAt',
},
{
title: '处理状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
if (status === 'processed') {
return <Tag color="green"></Tag>
} }
return ( }, [error]);
<Space>
<Tag color="processing" icon={<SyncOutlined spin />}> // 创建知识库
const handleCreate = async (name: string, description?: string) => {
</Tag> await createKnowledgeBase(name, description);
<Progress percent={65} size="small" style={{ width: 100 }} /> };
</Space>
) // 编辑知识库
}, const handleEdit = (kb: KnowledgeBase) => {
}, setEditingKb(kb);
{ setEditDialogOpen(true);
title: '操作', };
key: 'actions',
render: () => ( // 更新知识库
<Button size="small" danger icon={<DeleteOutlined />} disabled> const handleUpdate = async (id: string, name: string, description?: string) => {
await updateKnowledgeBase(id, name, description);
</Button> };
),
}, // 删除知识库
] const handleDelete = async (kb: KnowledgeBase) => {
try {
await deleteKnowledgeBase(kb.id);
message.success('知识库删除成功');
// 如果删除的是当前打开的知识库,返回列表
if (currentKb?.id === kb.id) {
setCurrentKb(null);
}
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
// 选择知识库,查看详情
const handleSelectKb = async (kb: KnowledgeBase) => {
setCurrentKb(kb);
await fetchKnowledgeBaseById(kb.id);
};
// 返回知识库列表
const handleBackToList = () => {
setCurrentKb(null);
};
// 上传成功后刷新文档列表
const handleUploadSuccess = async () => {
if (currentKb) {
await fetchDocuments(currentKb.id);
}
};
// 删除文档
const handleDeleteDocument = async (doc: Document) => {
try {
await deleteDocument(doc.id);
message.success('文档删除成功');
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
// 重新处理文档
const handleReprocessDocument = async (doc: Document) => {
try {
await reprocessDocument(doc.id);
message.success('已开始重新处理');
} catch (error: any) {
message.error(error.message || '操作失败');
}
};
// 轮询文档状态每5秒
useEffect(() => {
if (!currentKb) return;
const hasProcessing = documents.some(doc =>
['uploading', 'parsing', 'indexing'].includes(doc.status)
);
if (!hasProcessing) return;
const interval = setInterval(() => {
fetchDocuments(currentKb.id);
}, 5000);
return () => clearInterval(interval);
}, [currentKb, documents]);
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: 24, height: '100%', overflow: 'auto' }}>
{/* 提示信息 */} {!currentKb ? (
<Alert // 知识库列表视图
message="知识库功能说明" <>
description="每个用户最多可创建3个知识库每个知识库最多上传50个文件支持PDF、DOCX格式。在对话时可以@知识库让AI基于您的文献进行回答。" <KnowledgeBaseList
type="info" knowledgeBases={knowledgeBases}
showIcon loading={loading}
closable onCreateClick={() => setCreateDialogOpen(true)}
onEditClick={handleEdit}
onDeleteClick={handleDelete}
onSelectClick={handleSelectKb}
/>
<CreateKBDialog
open={createDialogOpen}
onCancel={() => setCreateDialogOpen(false)}
onCreate={handleCreate}
/>
<EditKBDialog
open={editDialogOpen}
knowledgeBase={editingKb}
onCancel={() => {
setEditDialogOpen(false);
setEditingKb(null);
}}
onUpdate={handleUpdate}
/>
</>
) : (
// 知识库详情视图
<div>
{/* 返回按钮和标题 */}
<div style={{ marginBottom: 24 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleBackToList}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/>
<Alert
message="功能开发中"
description="知识库管理功能正在开发中,敬请期待..."
type="warning"
showIcon
icon={<SyncOutlined spin />}
style={{ marginBottom: 24 }}
/>
{/* 知识库列表 */}
<Card
title={
<Space>
<FolderOutlined />
<span> (1/3)</span>
</Space>
}
extra={
<Button type="primary" icon={<PlusOutlined />} disabled>
</Button>
}
style={{ marginBottom: 24 }}
> >
<Table
columns={kbColumns}
dataSource={mockKnowledgeBases}
rowKey="id"
pagination={false}
/>
</Card>
{/* 文档列表 */}
<Card
title={
<Space>
<FileTextOutlined />
<span></span>
</Space>
}
extra={
<Button icon={<UploadOutlined />} disabled>
</Button> </Button>
}
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0, marginBottom: 8 }}>{currentKb.name}</h2>
<p style={{ margin: 0, color: '#8c8c8c' }}>
{currentKb.description || '暂无描述'}
</p>
</div>
<Button
onClick={() => handleEdit(currentKb)}
> >
<Table
columns={docColumns} </Button>
dataSource={mockDocuments} </div>
rowKey="id"
pagination={false}
/>
</Card> </Card>
</div> </div>
)
}
export default KnowledgePage {/* 文档管理标签页 */}
<Tabs defaultActiveKey="documents">
<TabPane tab={`文档管理 (${documents.length})`} key="documents">
<div style={{ marginBottom: 24 }}>
<DocumentUpload
kbId={currentKb.id}
onUploadSuccess={handleUploadSuccess}
disabled={loading}
maxDocuments={50}
currentDocumentCount={documents.length}
/>
</div>
<Card title="文档列表">
<DocumentList
documents={documents}
loading={loading}
onDelete={handleDeleteDocument}
onReprocess={handleReprocessDocument}
/>
</Card>
</TabPane>
<TabPane tab="统计信息" key="stats">
<Card>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<div style={{ textAlign: 'center', padding: 16, background: '#f0f5ff', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#1890ff' }}>
{documents.length}
</div>
<div style={{ marginTop: 8, color: '#595959' }}></div>
</div>
<div style={{ textAlign: 'center', padding: 16, background: '#f6ffed', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#52c41a' }}>
{documents.filter(d => d.status === 'completed').length}
</div>
<div style={{ marginTop: 8, color: '#595959' }}></div>
</div>
<div style={{ textAlign: 'center', padding: 16, background: '#fffbe6', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#faad14' }}>
{documents.filter(d => ['uploading', 'parsing', 'indexing'].includes(d.status)).length}
</div>
<div style={{ marginTop: 8, color: '#595959' }}></div>
</div>
<div style={{ textAlign: 'center', padding: 16, background: '#fff1f0', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#ff4d4f' }}>
{documents.filter(d => d.status === 'error').length}
</div>
<div style={{ marginTop: 8, color: '#595959' }}></div>
</div>
<div style={{ textAlign: 'center', padding: 16, background: '#f0f0f0', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#262626' }}>
{documents.reduce((sum, d) => sum + (d.tokensCount || 0), 0).toLocaleString()}
</div>
<div style={{ marginTop: 8, color: '#595959' }}>Token数</div>
</div>
<div style={{ textAlign: 'center', padding: 16, background: '#f0f0f0', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#262626' }}>
{documents.reduce((sum, d) => sum + (d.segmentsCount || 0), 0).toLocaleString()}
</div>
<div style={{ marginTop: 8, color: '#595959' }}></div>
</div>
</div>
</Card>
</TabPane>
</Tabs>
<EditKBDialog
open={editDialogOpen}
knowledgeBase={currentKb}
onCancel={() => setEditDialogOpen(false)}
onUpdate={handleUpdate}
/>
</div>
)}
</div>
);
};
export default KnowledgePage;

View File

@@ -0,0 +1,217 @@
import { create } from 'zustand';
import type { KnowledgeBase, Document } from '../api/knowledgeBaseApi';
import { knowledgeBaseApi, documentApi } from '../api/knowledgeBaseApi';
interface KnowledgeBaseState {
// 知识库列表
knowledgeBases: KnowledgeBase[];
currentKb: KnowledgeBase | null;
// 文档列表
documents: Document[];
// 加载状态
loading: boolean;
error: string | null;
// 操作方法
fetchKnowledgeBases: () => Promise<void>;
fetchKnowledgeBaseById: (id: string) => Promise<void>;
createKnowledgeBase: (name: string, description?: string) => Promise<KnowledgeBase>;
updateKnowledgeBase: (id: string, name?: string, description?: string) => Promise<void>;
deleteKnowledgeBase: (id: string) => Promise<void>;
// 文档操作
fetchDocuments: (kbId: string) => Promise<void>;
uploadDocument: (kbId: string, file: File, onProgress?: (progress: number) => void) => Promise<Document>;
deleteDocument: (id: string) => Promise<void>;
reprocessDocument: (id: string) => Promise<void>;
// 辅助方法
setCurrentKb: (kb: KnowledgeBase | null) => void;
clearError: () => void;
}
export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({
knowledgeBases: [],
currentKb: null,
documents: [],
loading: false,
error: null,
// 获取知识库列表
fetchKnowledgeBases: async () => {
set({ loading: true, error: null });
try {
const knowledgeBases = await knowledgeBaseApi.getList();
set({ knowledgeBases, loading: false });
} catch (error: any) {
set({
error: error.response?.data?.message || '获取知识库列表失败',
loading: false
});
}
},
// 获取知识库详情
fetchKnowledgeBaseById: async (id: string) => {
set({ loading: true, error: null });
try {
const kb = await knowledgeBaseApi.getById(id);
set({ currentKb: kb, loading: false });
// 同时获取文档列表
if (kb.id) {
await get().fetchDocuments(kb.id);
}
} catch (error: any) {
set({
error: error.response?.data?.message || '获取知识库详情失败',
loading: false
});
}
},
// 创建知识库
createKnowledgeBase: async (name: string, description?: string) => {
set({ loading: true, error: null });
try {
const kb = await knowledgeBaseApi.create({ name, description });
// 更新列表
const knowledgeBases = [...get().knowledgeBases, kb];
set({ knowledgeBases, loading: false });
return kb;
} catch (error: any) {
const errorMsg = error.response?.data?.message || '创建知识库失败';
set({ error: errorMsg, loading: false });
throw new Error(errorMsg);
}
},
// 更新知识库
updateKnowledgeBase: async (id: string, name?: string, description?: string) => {
set({ loading: true, error: null });
try {
const updatedKb = await knowledgeBaseApi.update(id, { name, description });
// 更新列表
const knowledgeBases = get().knowledgeBases.map(kb =>
kb.id === id ? updatedKb : kb
);
// 更新当前知识库
const currentKb = get().currentKb?.id === id ? updatedKb : get().currentKb;
set({ knowledgeBases, currentKb, loading: false });
} catch (error: any) {
set({
error: error.response?.data?.message || '更新知识库失败',
loading: false
});
throw error;
}
},
// 删除知识库
deleteKnowledgeBase: async (id: string) => {
set({ loading: true, error: null });
try {
await knowledgeBaseApi.delete(id);
// 更新列表
const knowledgeBases = get().knowledgeBases.filter(kb => kb.id !== id);
// 清除当前知识库(如果是被删除的)
const currentKb = get().currentKb?.id === id ? null : get().currentKb;
set({ knowledgeBases, currentKb, loading: false });
} catch (error: any) {
set({
error: error.response?.data?.message || '删除知识库失败',
loading: false
});
throw error;
}
},
// 获取文档列表
fetchDocuments: async (kbId: string) => {
try {
const documents = await documentApi.getList(kbId);
set({ documents });
} catch (error: any) {
set({
error: error.response?.data?.message || '获取文档列表失败'
});
}
},
// 上传文档
uploadDocument: async (kbId: string, file: File, onProgress?: (progress: number) => void) => {
set({ loading: true, error: null });
try {
const document = await documentApi.upload(kbId, file, onProgress);
// 更新文档列表
const documents = [document, ...get().documents];
set({ documents, loading: false });
return document;
} catch (error: any) {
const errorMsg = error.response?.data?.message || '上传文档失败';
set({ error: errorMsg, loading: false });
throw new Error(errorMsg);
}
},
// 删除文档
deleteDocument: async (id: string) => {
set({ loading: true, error: null });
try {
await documentApi.delete(id);
// 更新文档列表
const documents = get().documents.filter(doc => doc.id !== id);
set({ documents, loading: false });
} catch (error: any) {
set({
error: error.response?.data?.message || '删除文档失败',
loading: false
});
throw error;
}
},
// 重新处理文档
reprocessDocument: async (id: string) => {
set({ loading: true, error: null });
try {
await documentApi.reprocess(id);
// 更新文档状态
const documents = get().documents.map(doc =>
doc.id === id ? { ...doc, status: 'parsing' as const, progress: 0 } : doc
);
set({ documents, loading: false });
} catch (error: any) {
set({
error: error.response?.data?.message || '重新处理文档失败',
loading: false
});
throw error;
}
},
// 设置当前知识库
setCurrentKb: (kb: KnowledgeBase | null) => {
set({ currentKb: kb });
},
// 清除错误
clearError: () => {
set({ error: null });
},
}));