feat(frontend): Day 21 knowledge base management frontend completed
This commit is contained in:
185
frontend/src/api/knowledgeBaseApi.ts
Normal file
185
frontend/src/api/knowledgeBaseApi.ts
Normal 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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
115
frontend/src/components/knowledge/CreateKBDialog.tsx
Normal file
115
frontend/src/components/knowledge/CreateKBDialog.tsx
Normal 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>支持的文件格式:PDF、DOC、DOCX、TXT、MD</li>
|
||||||
|
<li>单个文件最大10MB</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateKBDialog;
|
||||||
|
|
||||||
211
frontend/src/components/knowledge/DocumentList.tsx
Normal file
211
frontend/src/components/knowledge/DocumentList.tsx
Normal 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;
|
||||||
|
|
||||||
144
frontend/src/components/knowledge/DocumentUpload.tsx
Normal file
144
frontend/src/components/knowledge/DocumentUpload.tsx
Normal 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">
|
||||||
|
支持格式:PDF、DOC、DOCX、TXT、MD | 单个文件最大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;
|
||||||
|
|
||||||
111
frontend/src/components/knowledge/EditKBDialog.tsx
Normal file
111
frontend/src/components/knowledge/EditKBDialog.tsx
Normal 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;
|
||||||
|
|
||||||
203
frontend/src/components/knowledge/KnowledgeBaseList.tsx
Normal file
203
frontend/src/components/knowledge/KnowledgeBaseList.tsx
Normal 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;
|
||||||
|
|
||||||
@@ -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('创建对话失败')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
217
frontend/src/stores/useKnowledgeBaseStore.ts
Normal file
217
frontend/src/stores/useKnowledgeBaseStore.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
Reference in New Issue
Block a user