feat(pkb): Complete PKB module frontend migration with V3 design
Summary: - Implement PKB Dashboard and Workspace pages based on V3 prototype - Add single-layer header with integrated Tab navigation - Implement 3 work modes: Full Text, Deep Read, Batch Processing - Integrate Ant Design X Chat component for AI conversations - Create BatchModeComplete with template selection and document processing - Add compact work mode selector with dropdown design Backend: - Migrate PKB controllers and services to /modules/pkb structure - Register v2 API routes at /api/v2/pkb/knowledge - Maintain dual API routes for backward compatibility Technical details: - Use Zustand for state management - Handle SSE streaming responses for AI chat - Support document selection for Deep Read mode - Implement batch processing with progress tracking Known issues: - Batch processing API integration pending - Knowledge assets page navigation needs optimization Status: Frontend functional, pending refinement
This commit is contained in:
@@ -80,4 +80,8 @@ vite.config.*.timestamp-*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,4 +47,8 @@ exec nginx -g 'daemon off;'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -203,4 +203,8 @@ http {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
frontend-v2/public/MP_verify_zdBL0coMfYXaanmi.txt
Normal file
1
frontend-v2/public/MP_verify_zdBL0coMfYXaanmi.txt
Normal file
@@ -0,0 +1 @@
|
||||
zdBL0coMfYXaanmi
|
||||
@@ -42,9 +42,9 @@ export const MODULES: ModuleDefinition[] = [
|
||||
path: '/knowledge-base',
|
||||
icon: FolderOpenOutlined,
|
||||
component: lazy(() => import('@/modules/pkb')),
|
||||
placeholder: true, // 后续重写
|
||||
placeholder: false, // V5.0设计已完成实现 ✅
|
||||
requiredVersion: 'basic',
|
||||
description: '个人知识库管理系统',
|
||||
description: '个人知识库管理系统(支持全文阅读、逐篇精读、批处理)',
|
||||
},
|
||||
{
|
||||
id: 'data-cleaning',
|
||||
|
||||
@@ -546,6 +546,10 @@ export default FulltextDetailDrawer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -139,6 +139,10 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -129,6 +129,10 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -328,6 +328,10 @@ export default DropnaDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -413,6 +413,10 @@ export default MetricTimePanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -299,6 +299,10 @@ export default PivotPanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -102,5 +102,9 @@ export function useSessionStatus({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ export interface DataStats {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
217
frontend-v2/src/modules/pkb/api/knowledgeBaseApi.ts
Normal file
217
frontend-v2/src/modules/pkb/api/knowledgeBaseApi.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* PKB个人知识库 API(v2版本)
|
||||
* 更新API路径为 /api/v2/pkb/knowledge
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v2/pkb/knowledge`,
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 检索知识库(RAG)
|
||||
*/
|
||||
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`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文档全文(用于逐篇精读模式)
|
||||
*/
|
||||
async getFullText(id: string): Promise<any> {
|
||||
const response = await api.get(`/documents/${id}/full-text`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 文档选择API(用于全文阅读模式)
|
||||
*/
|
||||
export const documentSelectionApi = {
|
||||
/**
|
||||
* 获取知识库的文档选择结果
|
||||
*/
|
||||
async getSelection(kbId: string, maxFiles?: number, maxTokens?: number): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (maxFiles) params.append('max_files', maxFiles.toString());
|
||||
if (maxTokens) params.append('max_tokens', maxTokens.toString());
|
||||
|
||||
const url = `/knowledge-bases/${kbId}/document-selection${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await api.get(url);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
116
frontend-v2/src/modules/pkb/components/CreateKBDialog.tsx
Normal file
116
frontend-v2/src/modules/pkb/components/CreateKBDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
|
||||
|
||||
212
frontend-v2/src/modules/pkb/components/DocumentList.tsx
Normal file
212
frontend-v2/src/modules/pkb/components/DocumentList.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
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-v2/src/modules/pkb/components/DocumentUpload.tsx
Normal file
144
frontend-v2/src/modules/pkb/components/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;
|
||||
}
|
||||
|
||||
// 不返回任何值,让 customRequest 处理上传
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
112
frontend-v2/src/modules/pkb/components/EditKBDialog.tsx
Normal file
112
frontend-v2/src/modules/pkb/components/EditKBDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
|
||||
|
||||
204
frontend-v2/src/modules/pkb/components/KnowledgeBaseList.tsx
Normal file
204
frontend-v2/src/modules/pkb/components/KnowledgeBaseList.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 批处理模式组件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { BatchModeComplete } from './BatchModeComplete';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
interface BatchModeProps {
|
||||
kbId: string;
|
||||
kbInfo?: KnowledgeBase;
|
||||
documents?: Document[];
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export const BatchMode: React.FC<BatchModeProps> = ({ kbId, kbInfo, documents, template }) => {
|
||||
// 直接渲染完整的批处理组件
|
||||
return (
|
||||
<BatchModeComplete
|
||||
kbId={kbId}
|
||||
kbInfo={kbInfo!}
|
||||
documents={documents || []}
|
||||
template={template || 'clinicalResearch'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* 完整的批处理模式组件
|
||||
* 精细化设计,支持模板选择、文档选择、执行和结果展示
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Table, Progress, Alert, message, Card, Steps, Checkbox, Radio } from 'antd';
|
||||
import { Play, Download, RotateCw, FileText, CheckCircle2, Zap } from 'lucide-react';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
interface BatchModeCompleteProps {
|
||||
kbId: string;
|
||||
kbInfo: KnowledgeBase;
|
||||
documents: Document[];
|
||||
template: string;
|
||||
}
|
||||
|
||||
interface BatchTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fields: { key: string; label: string }[];
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
documentId: string;
|
||||
documentName: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'error';
|
||||
progress: number;
|
||||
result?: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const TEMPLATES: BatchTemplate[] = [
|
||||
{
|
||||
id: 'clinicalResearch',
|
||||
name: '临床研究信息提取',
|
||||
description: '提取研究目的、方法、样本量、结论等核心信息',
|
||||
fields: [
|
||||
{ key: 'title', label: '研究标题' },
|
||||
{ key: 'purpose', label: '研究目的' },
|
||||
{ key: 'method', label: '研究方法' },
|
||||
{ key: 'sampleSize', label: '样本量' },
|
||||
{ key: 'intervention', label: '干预措施' },
|
||||
{ key: 'outcome', label: '主要结局' },
|
||||
{ key: 'conclusion', label: '研究结论' },
|
||||
{ key: 'limitation', label: '研究局限' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'drug_safety',
|
||||
name: '药物安全性分析',
|
||||
description: '提取药物不良反应、禁忌症、注意事项等',
|
||||
fields: [
|
||||
{ key: 'drugName', label: '药物名称' },
|
||||
{ key: 'adverseReactions', label: '不良反应' },
|
||||
{ key: 'contraindications', label: '禁忌症' },
|
||||
{ key: 'warnings', label: '警告事项' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'patient_baseline',
|
||||
name: '患者基线特征',
|
||||
description: '提取患者年龄、性别、诊断、既往史等基线信息',
|
||||
fields: [
|
||||
{ key: 'age', label: '年龄' },
|
||||
{ key: 'gender', label: '性别' },
|
||||
{ key: 'diagnosis', label: '主要诊断' },
|
||||
{ key: 'comorbidities', label: '合并症' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
kbId,
|
||||
documents,
|
||||
template: initialTemplate,
|
||||
}) => {
|
||||
const [step, setStep] = useState(0); // 0: 配置, 1: 执行中, 2: 结果
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<BatchTemplate | null>(TEMPLATES[0]);
|
||||
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
|
||||
const [results, setResults] = useState<BatchResult[]>([]);
|
||||
const [, setIsExecuting] = useState(false);
|
||||
|
||||
const completedDocs = documents.filter(doc => doc.status === 'completed');
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化模板(如果有传入则使用,否则默认第一个)
|
||||
if (initialTemplate) {
|
||||
const template = TEMPLATES.find(t => t.id === initialTemplate);
|
||||
if (template) setSelectedTemplate(template);
|
||||
}
|
||||
}, [initialTemplate]);
|
||||
|
||||
// 处理文档选择
|
||||
const handleDocSelect = (docId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (selectedDocs.length >= 50) {
|
||||
message.warning('最多选择50篇文档');
|
||||
return;
|
||||
}
|
||||
setSelectedDocs(prev => [...prev, docId]);
|
||||
} else {
|
||||
setSelectedDocs(prev => prev.filter(id => id !== docId));
|
||||
}
|
||||
};
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = completedDocs.slice(0, 50).map(d => d.id);
|
||||
setSelectedDocs(allIds);
|
||||
} else {
|
||||
setSelectedDocs([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行批处理
|
||||
const handleExecute = async () => {
|
||||
if (selectedDocs.length < 3) {
|
||||
message.warning('请至少选择3篇文档');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTemplate) {
|
||||
message.error('请选择批处理模板');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep(1);
|
||||
setIsExecuting(true);
|
||||
|
||||
// 初始化结果
|
||||
const initialResults: BatchResult[] = selectedDocs.map(docId => {
|
||||
const doc = documents.find(d => d.id === docId);
|
||||
return {
|
||||
documentId: docId,
|
||||
documentName: doc?.filename || '未知文档',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
setResults(initialResults);
|
||||
|
||||
try {
|
||||
// 调用批处理API
|
||||
const response = await fetch('/api/v2/pkb/batch-tasks/batch/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kb_id: kbId,
|
||||
template_id: selectedTemplate.id,
|
||||
document_ids: selectedDocs,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('批处理执行失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const taskId = data.task_id;
|
||||
|
||||
// 轮询任务状态
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`/api/v2/pkb/batch-tasks/${taskId}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
// 更新进度
|
||||
setResults(prev => prev.map((r, idx) => {
|
||||
const docResult = statusData.results?.[idx];
|
||||
if (docResult) {
|
||||
return {
|
||||
...r,
|
||||
status: docResult.status,
|
||||
progress: docResult.progress || 0,
|
||||
result: docResult.result,
|
||||
error: docResult.error,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
|
||||
// 检查是否全部完成
|
||||
if (statusData.status === 'completed' || statusData.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
setIsExecuting(false);
|
||||
setStep(2);
|
||||
|
||||
if (statusData.status === 'completed') {
|
||||
message.success('批处理完成!');
|
||||
} else {
|
||||
message.error('批处理失败');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '执行失败';
|
||||
message.error(errorMessage);
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出Excel
|
||||
const handleExport = () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
// 构建CSV数据
|
||||
const headers = ['文档名称', ...selectedTemplate.fields.map(f => f.label)];
|
||||
const rows = results
|
||||
.filter(r => r.status === 'completed' && r.result)
|
||||
.map(r => [
|
||||
r.documentName,
|
||||
...selectedTemplate.fields.map(f => r.result?.[f.key] || '-'),
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(',')),
|
||||
].join('\n');
|
||||
|
||||
// 下载
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `批处理结果_${selectedTemplate.name}_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
setStep(0);
|
||||
setSelectedDocs([]);
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-white">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="flex-shrink-0 px-8 py-5 border-b border-gray-100 bg-gray-50/50">
|
||||
<Steps
|
||||
current={step}
|
||||
size="small"
|
||||
items={[
|
||||
{ title: '配置任务', description: '选择模板和文档' },
|
||||
{ title: '执行中', description: '正在处理文档' },
|
||||
{ title: '查看结果', description: '导出数据' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 步骤1: 配置 */}
|
||||
{step === 0 && (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* 模板选择 */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<Zap className="w-4 h-4 mr-2 text-orange-500" />
|
||||
<span>选择批处理模板</span>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
className="shadow-sm"
|
||||
>
|
||||
<Radio.Group
|
||||
value={selectedTemplate?.id}
|
||||
onChange={(e) => {
|
||||
const template = TEMPLATES.find(t => t.id === e.target.value);
|
||||
setSelectedTemplate(template || null);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{TEMPLATES.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedTemplate?.id === t.id
|
||||
? 'border-blue-400 bg-blue-50/50'
|
||||
: 'border-gray-200 hover:border-blue-200 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedTemplate(t)}
|
||||
>
|
||||
<Radio value={t.id} className="w-full">
|
||||
<div className="ml-2">
|
||||
<div className="font-medium text-slate-800">{t.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{t.description}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{t.fields.slice(0, 5).map(f => (
|
||||
<span key={f.key} className="text-xs bg-gray-100 text-slate-600 px-2 py-0.5 rounded">
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
{t.fields.length > 5 && (
|
||||
<span className="text-xs text-slate-400">+{t.fields.length - 5}项</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
{/* 文档选择 */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-4 h-4 mr-2 text-blue-500" />
|
||||
<span>选择文档</span>
|
||||
<span className="ml-2 text-xs text-slate-400 font-normal">
|
||||
({completedDocs.length} 篇可用)
|
||||
</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={selectedDocs.length === completedDocs.length && completedDocs.length > 0}
|
||||
indeterminate={selectedDocs.length > 0 && selectedDocs.length < completedDocs.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
className="shadow-sm"
|
||||
>
|
||||
<div className="space-y-1 max-h-[280px] overflow-y-auto">
|
||||
{completedDocs.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无已完成解析的文档</p>
|
||||
</div>
|
||||
) : (
|
||||
completedDocs.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`flex items-center p-3 rounded-lg cursor-pointer transition-all ${
|
||||
selectedDocs.includes(doc.id)
|
||||
? 'bg-blue-50 border border-blue-200'
|
||||
: 'hover:bg-gray-50 border border-transparent'
|
||||
}`}
|
||||
onClick={() => handleDocSelect(doc.id, !selectedDocs.includes(doc.id))}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedDocs.includes(doc.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDocSelect(doc.id, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<div className="w-8 h-8 bg-red-50 text-red-500 rounded flex items-center justify-center ml-3 mr-3 flex-shrink-0">
|
||||
<FileText className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700 truncate">{doc.filename}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{doc.tokensCount ? `${(doc.tokensCount / 1000).toFixed(0)}k tokens` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDocs.includes(doc.id) && (
|
||||
<CheckCircle2 className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<Alert
|
||||
message={
|
||||
<span className="text-sm">
|
||||
已选择 <strong className="text-blue-600">{selectedDocs.length}</strong> 篇文档
|
||||
{selectedDocs.length < 3 && <span className="text-orange-600 ml-2">(至少需要3篇)</span>}
|
||||
</span>
|
||||
}
|
||||
type={selectedDocs.length >= 3 ? 'success' : 'warning'}
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 开始按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<Play className="w-4 h-4" />}
|
||||
onClick={handleExecute}
|
||||
disabled={selectedDocs.length < 3 || !selectedTemplate}
|
||||
className="w-full h-12 text-base font-medium shadow-lg"
|
||||
>
|
||||
开始批处理 ({selectedDocs.length} 篇文档)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤2: 执行中 */}
|
||||
{step === 1 && (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<Zap className="w-4 h-4 mr-2 text-orange-500 animate-pulse" />
|
||||
<span>批处理执行中...</span>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
className="shadow-sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{results.map((result) => (
|
||||
<div key={result.documentId} className="border-b border-gray-100 pb-4 last:border-b-0 last:pb-0">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center min-w-0 flex-1">
|
||||
<FileText className="w-4 h-4 text-red-400 mr-2 flex-shrink-0" />
|
||||
<span className="font-medium text-sm text-slate-700 truncate">
|
||||
{result.documentName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs ml-2 flex-shrink-0">
|
||||
{result.status === 'completed' && <span className="text-green-600">✅ 完成</span>}
|
||||
{result.status === 'processing' && <span className="text-blue-600">⏳ 处理中</span>}
|
||||
{result.status === 'error' && <span className="text-red-600">❌ 失败</span>}
|
||||
{result.status === 'pending' && <span className="text-slate-400">⏸️ 等待</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={result.progress}
|
||||
status={result.status === 'error' ? 'exception' : result.status === 'completed' ? 'success' : 'active'}
|
||||
size="small"
|
||||
strokeColor={result.status === 'completed' ? '#22c55e' : '#3b82f6'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤3: 结果 */}
|
||||
{step === 2 && selectedTemplate && (
|
||||
<div className="flex-1 overflow-hidden flex flex-col p-6">
|
||||
<div className="flex-shrink-0 mb-4 flex justify-between items-center">
|
||||
<div className="flex items-center text-sm text-slate-600">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 mr-2" />
|
||||
已完成 {results.filter(r => r.status === 'completed').length} / {results.length} 篇文档处理
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button icon={<RotateCw className="w-4 h-4" />} onClick={handleReset}>
|
||||
重新开始
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
onClick={handleExport}
|
||||
>
|
||||
导出Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<Table
|
||||
dataSource={results.filter(r => r.status === 'completed')}
|
||||
rowKey="documentId"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: '100%' }}
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: '文档名称',
|
||||
dataIndex: 'documentName',
|
||||
key: 'documentName',
|
||||
fixed: 'left',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-4 h-4 text-red-400 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{text}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...selectedTemplate.fields.map(field => ({
|
||||
title: field.label,
|
||||
dataIndex: ['result', field.key],
|
||||
key: field.key,
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (text: string) => text || '-',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 逐篇精读模式组件 - ChatGPT风格全屏聊天
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Empty } from 'antd';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { ChatContainer } from '@/shared/components/Chat';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
interface DeepReadModeProps {
|
||||
kbId: string;
|
||||
kbInfo: KnowledgeBase;
|
||||
selectedDocuments: Document[];
|
||||
}
|
||||
|
||||
export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
kbId,
|
||||
selectedDocuments
|
||||
}) => {
|
||||
if (!selectedDocuments || selectedDocuments.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-50">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-4">
|
||||
<FileText className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">选择文档开始精读</h3>
|
||||
<p className="text-sm text-slate-500 max-w-xs">
|
||||
请在上方"逐篇精读"下拉框中选择一篇文档进行深度分析
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedDoc = selectedDocuments[0];
|
||||
const selectedDocIds = selectedDocuments.map(d => d.id);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Chat组件 - 全屏展开 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
conversationType="pkb"
|
||||
conversationKey={`kb-deepread-${kbId}-${selectedDoc.id}`}
|
||||
defaultMessages={[{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `您好!我已准备好深度解读文档《${selectedDoc.filename}》。\n\n我可以帮您:\n- 📖 逐段解读文献内容\n- 🎯 提炼核心观点和结论\n- 💡 分析研究方法和局限性\n\n请告诉我您想深入了解哪方面?`,
|
||||
status: 'success',
|
||||
timestamp: Date.now(),
|
||||
}]}
|
||||
providerConfig={{
|
||||
apiEndpoint: '/api/v1/chat/stream',
|
||||
requestFn: async (message: string) => {
|
||||
const response = await fetch('/api/v1/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: message,
|
||||
modelType: 'qwen-long',
|
||||
knowledgeBaseIds: [kbId],
|
||||
documentIds: selectedDocIds, // 🌟 关键参数:限定文档范围
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.content) {
|
||||
fullContent += json.content;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent,
|
||||
messageId: Date.now().toString(),
|
||||
};
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 全文阅读模式组件 - ChatGPT风格全屏聊天
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ChatContainer } from '@/shared/components/Chat';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
interface FullTextModeProps {
|
||||
kbId: string;
|
||||
kbInfo: KnowledgeBase;
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
export const FullTextMode: React.FC<FullTextModeProps> = ({ kbId, documents }) => {
|
||||
// 准备全文文档ID
|
||||
const fullTextDocumentIds = documents
|
||||
.filter(doc => doc.status === 'completed')
|
||||
.map(doc => doc.id);
|
||||
|
||||
const totalTokens = documents.reduce((sum, d) => sum + (d.tokensCount || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Chat组件 - 全屏展开,ChatGPT风格 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
conversationType="pkb"
|
||||
conversationKey={`kb-fulltext-${kbId}`}
|
||||
defaultMessages={[{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `您好!我已加载全部 ${documents.length} 篇文档(共${totalTokens.toLocaleString()} tokens)。\n\n我可以帮您:\n- 📚 综合多篇文献进行对比分析\n- 🔍 查找特定主题的共识与争议\n- 📊 总结研究趋势和发展脉络\n\n请告诉我您想了解什么?`,
|
||||
status: 'success',
|
||||
timestamp: Date.now(),
|
||||
}]}
|
||||
customMessageRenderer={(msgInfo) => {
|
||||
const msg = msgInfo.message;
|
||||
if (msg.role === 'assistant' && msg.content) {
|
||||
// 处理参考文献格式
|
||||
const processedContent = msg.content.replace(
|
||||
/\*\*参考文献\*\*/g,
|
||||
'\n\n**📚 参考文献**\n'
|
||||
);
|
||||
return <div className="whitespace-pre-wrap">{processedContent}</div>;
|
||||
}
|
||||
return <div className="whitespace-pre-wrap">{msg.content}</div>;
|
||||
}}
|
||||
providerConfig={{
|
||||
apiEndpoint: '/api/v1/chat/stream',
|
||||
requestFn: async (message: string) => {
|
||||
const response = await fetch('/api/v1/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: message,
|
||||
modelType: 'qwen-long',
|
||||
knowledgeBaseIds: [kbId],
|
||||
fullTextDocumentIds, // 🌟 关键参数:全文阅读
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.content) {
|
||||
fullContent += json.content;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent,
|
||||
messageId: Date.now().toString(),
|
||||
};
|
||||
},
|
||||
}}
|
||||
onMessageReceived={(msg) => {
|
||||
console.log('[全文阅读模式] 收到AI回复:', msg);
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('[全文阅读模式] 错误:', error);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 工作模式选择器组件
|
||||
* 支持:全文阅读、逐篇精读、批处理
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Collapse, Radio, Progress, Select, Alert } from 'antd';
|
||||
import { Globe, Search, Package } from 'lucide-react';
|
||||
import type { WorkMode } from '../../types/workspace';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
interface WorkModeSelectorProps {
|
||||
currentMode: WorkMode;
|
||||
kbInfo: KnowledgeBase;
|
||||
documents: Document[];
|
||||
onChange: (mode: WorkMode) => void;
|
||||
onDocumentSelect?: (docs: string[]) => void;
|
||||
onTemplateSelect?: (template: string) => void;
|
||||
}
|
||||
|
||||
export const WorkModeSelector: React.FC<WorkModeSelectorProps> = ({
|
||||
currentMode,
|
||||
kbInfo,
|
||||
documents,
|
||||
onChange,
|
||||
onDocumentSelect,
|
||||
onTemplateSelect,
|
||||
}) => {
|
||||
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||
|
||||
// 计算Token使用率
|
||||
const calculateTokenUsage = () => {
|
||||
const totalTokens = documents.reduce((sum, doc) => sum + (doc.tokensCount || 0), 0);
|
||||
const maxTokens = 200000; // 假设最大200k tokens
|
||||
return Math.min(Math.round((totalTokens / maxTokens) * 100), 100);
|
||||
};
|
||||
|
||||
const handleDocumentChange = (values: string[]) => {
|
||||
setSelectedDocs(values);
|
||||
onDocumentSelect?.(values);
|
||||
};
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
setSelectedTemplate(value);
|
||||
onTemplateSelect?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
defaultActiveKey={['modes']}
|
||||
className="bg-white border border-gray-100 rounded-lg shadow-sm"
|
||||
bordered={false}
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className="font-semibold text-slate-800 text-sm flex items-center">
|
||||
<span className="text-base mr-2">📚</span>
|
||||
工作模式
|
||||
</div>
|
||||
}
|
||||
key="modes"
|
||||
>
|
||||
<Radio.Group
|
||||
value={currentMode}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
{/* 全文阅读模式 - 精细优化 */}
|
||||
<Radio value="full_text" className="w-full">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-1.5">
|
||||
<Globe className="w-4 h-4 mr-2 text-blue-600" />
|
||||
<span className="font-semibold text-slate-800 text-sm">全文阅读模式</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 ml-6 leading-relaxed">
|
||||
加载全部 {kbInfo.fileCount} 篇文档,AI具备全知视角,适合文献综述
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={calculateTokenUsage()}
|
||||
width={42}
|
||||
strokeColor="#3b82f6"
|
||||
trailColor="#e5e7eb"
|
||||
strokeWidth={8}
|
||||
/>
|
||||
</div>
|
||||
</Radio>
|
||||
|
||||
{/* 逐篇精读模式 - 精细优化 */}
|
||||
<Radio value="deep_read" className="w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-1.5">
|
||||
<Search className="w-4 h-4 mr-2 text-purple-600" />
|
||||
<span className="font-semibold text-slate-800 text-sm">逐篇精读模式</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 ml-6 leading-relaxed">
|
||||
选择1-5篇文档进行深度解读,适合单篇文献精读
|
||||
</div>
|
||||
{currentMode === 'deep_read' && (
|
||||
<div className="ml-6 mt-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择文档(最多5篇)"
|
||||
className="w-full"
|
||||
maxCount={5}
|
||||
value={selectedDocs}
|
||||
onChange={handleDocumentChange}
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
options={documents
|
||||
.filter(doc => doc.status === 'completed')
|
||||
.map(doc => ({
|
||||
label: doc.filename,
|
||||
value: doc.id,
|
||||
}))}
|
||||
/>
|
||||
{selectedDocs.length > 0 && (
|
||||
<Alert
|
||||
message={`已选择 ${selectedDocs.length} 篇文档`}
|
||||
type="info"
|
||||
showIcon
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Radio>
|
||||
|
||||
{/* 批处理模式 - 精细优化 */}
|
||||
<Radio value="batch" className="w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-1.5">
|
||||
<Package className="w-4 h-4 mr-2 text-orange-600" />
|
||||
<span className="font-semibold text-slate-800 text-sm">批处理模式</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 ml-6 leading-relaxed">
|
||||
批量提取信息,生成结构化表格,适合数据提取
|
||||
</div>
|
||||
{currentMode === 'batch' && (
|
||||
<div className="ml-6 mt-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
placeholder="请选择批处理模板"
|
||||
className="w-full"
|
||||
value={selectedTemplate}
|
||||
onChange={handleTemplateChange}
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
options={[
|
||||
{ label: '临床研究信息提取', value: 'clinicalResearch' },
|
||||
{ label: '药物安全性分析', value: 'drug_safety' },
|
||||
{ label: '患者基线特征', value: 'patient_baseline' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
41
frontend-v2/src/modules/pkb/hooks/useWorkMode.ts
Normal file
41
frontend-v2/src/modules/pkb/hooks/useWorkMode.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 工作模式状态管理 Hook
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { WorkMode } from '../types/workspace';
|
||||
import type { Document } from '../api/knowledgeBaseApi';
|
||||
|
||||
export const useWorkMode = (initialMode: WorkMode = 'full_text') => {
|
||||
const [workMode, setWorkMode] = useState<WorkMode>(initialMode);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||
|
||||
const handleModeChange = useCallback((mode: WorkMode) => {
|
||||
setWorkMode(mode);
|
||||
// 切换模式时清空选择
|
||||
if (mode !== 'deep_read') {
|
||||
setSelectedDocuments([]);
|
||||
}
|
||||
if (mode !== 'batch') {
|
||||
setSelectedTemplate('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDocumentSelect = useCallback((docs: Document[]) => {
|
||||
setSelectedDocuments(docs);
|
||||
}, []);
|
||||
|
||||
const handleTemplateSelect = useCallback((template: string) => {
|
||||
setSelectedTemplate(template);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
workMode,
|
||||
selectedDocuments,
|
||||
selectedTemplate,
|
||||
setWorkMode: handleModeChange,
|
||||
setSelectedDocuments: handleDocumentSelect,
|
||||
setSelectedTemplate: handleTemplateSelect,
|
||||
};
|
||||
};
|
||||
@@ -1,30 +1,26 @@
|
||||
import Placeholder from '@/shared/components/Placeholder'
|
||||
/**
|
||||
* PKB(个人知识库)模块入口
|
||||
*
|
||||
* 功能:
|
||||
* - 知识库仪表盘(V5设计)
|
||||
* - 沉浸式工作台(V3设计,全屏模式)
|
||||
* - 3种工作模式:全文阅读、逐篇精读、批处理
|
||||
*/
|
||||
|
||||
const PKBModule = () => {
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import WorkspacePage from './pages/WorkspacePage';
|
||||
|
||||
const PKBModule: React.FC = () => {
|
||||
return (
|
||||
<Placeholder
|
||||
title="个人知识库模块"
|
||||
description="后续基于新架构重写,提供更好的文档管理和智能检索"
|
||||
moduleName="PKB - Personal Knowledge Base"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PKBModule
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
{/* Workspace页面全屏独立,不使用外层Layout */}
|
||||
<Route path="workspace/:kbId" element={<WorkspacePage standalone />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default PKBModule;
|
||||
|
||||
449
frontend-v2/src/modules/pkb/pages/DashboardPage.tsx
Normal file
449
frontend-v2/src/modules/pkb/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* PKB知识库仪表盘页面(V5设计)
|
||||
* 严格遵循:知识库仪表盘V5.html
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore';
|
||||
import {
|
||||
Plus, BookOpen, Microscope, Stethoscope, Pill,
|
||||
GraduationCap, Wrench, MessageSquare, FileText,
|
||||
Loader2, MoreHorizontal, X, CheckCircle2,
|
||||
ChevronRight, Upload, Sparkles, Trash2, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { message } from 'antd';
|
||||
import type { KBType } from '../types/workspace';
|
||||
|
||||
// 6种知识库类型配置(严格遵循V5设计)
|
||||
const KB_TYPES = [
|
||||
{
|
||||
id: 'GUIDELINE' as KBType,
|
||||
name: '临床指南',
|
||||
icon: BookOpen,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-100',
|
||||
desc: '存储诊疗规范、专家共识。支持精确检索与引用跳转。',
|
||||
tags: ['RAG', '精准溯源']
|
||||
},
|
||||
{
|
||||
id: 'RESEARCH' as KBType,
|
||||
name: '科研文献',
|
||||
icon: Microscope,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-100',
|
||||
desc: '存储同主题文献。支持全文深度阅读、横向对比总结。',
|
||||
tags: ['Long Context', '深度分析']
|
||||
},
|
||||
{
|
||||
id: 'CASE_REPORT' as KBType,
|
||||
name: '典型病例',
|
||||
icon: Stethoscope,
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-100',
|
||||
desc: '存储疑难病历。支持相似病例检索、临床决策辅助。',
|
||||
tags: ['Multimodal', '时序分析']
|
||||
},
|
||||
{
|
||||
id: 'DRUG_SAFETY' as KBType,
|
||||
name: '药品安全',
|
||||
icon: Pill,
|
||||
color: 'text-rose-600',
|
||||
bg: 'bg-rose-100',
|
||||
desc: '存储药品说明书。支持配伍禁忌、不良反应查询。',
|
||||
tags: ['RAG', '结构化提取']
|
||||
},
|
||||
{
|
||||
id: 'EXAM' as KBType,
|
||||
name: '职称考试',
|
||||
icon: GraduationCap,
|
||||
color: 'text-orange-600',
|
||||
bg: 'bg-orange-100',
|
||||
desc: '存储题库与解析。支持考点生成、模拟练习。',
|
||||
tags: ['Hybrid', '题库模式']
|
||||
},
|
||||
{
|
||||
id: 'CUSTOM' as KBType,
|
||||
name: '自定义',
|
||||
icon: Wrench,
|
||||
color: 'text-slate-600',
|
||||
bg: 'bg-slate-200',
|
||||
desc: '自由配置 AI 引擎参数,混合管理多种文档。',
|
||||
tags: ['Advanced', '自由配置']
|
||||
},
|
||||
];
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { knowledgeBases, fetchKnowledgeBases, createKnowledgeBase } = useKnowledgeBaseStore();
|
||||
|
||||
// Modal状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [createStep, setCreateStep] = useState(1);
|
||||
const [selectedTypeId, setSelectedTypeId] = useState<KBType | null>(null);
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({ name: '', department: 'Cardiology' });
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
const getKbTypeConfig = (id: KBType) => KB_TYPES.find(t => t.id === id) || KB_TYPES[0];
|
||||
|
||||
const handleCreateOpen = () => {
|
||||
setCreateStep(1);
|
||||
setSelectedTypeId(null);
|
||||
setFormData({ name: '', department: 'Cardiology' });
|
||||
setFiles([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
try {
|
||||
const kb = await createKnowledgeBase(formData.name, formData.department);
|
||||
message.success('知识库创建成功!');
|
||||
setIsModalOpen(false);
|
||||
navigate(`/knowledge-base/workspace/${kb.id}`);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟文件上传
|
||||
const simulateUpload = () => {
|
||||
const newFile = {
|
||||
id: Math.random().toString(),
|
||||
name: `New_Clinical_Protocol_v${files.length + 1}.pdf`,
|
||||
size: "3.5 MB",
|
||||
status: 'uploading',
|
||||
progress: 0
|
||||
};
|
||||
setFiles(prev => [...prev, newFile]);
|
||||
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += 2;
|
||||
setFiles(prev => prev.map(f => {
|
||||
if (f.id !== newFile.id) return f;
|
||||
let status = 'uploading';
|
||||
if (progress > 15) status = 'analyzing_layout';
|
||||
if (progress > 50) status = 'extracting_table';
|
||||
if (progress > 85) status = 'indexing';
|
||||
if (progress >= 100) status = 'ready';
|
||||
return { ...f, progress: Math.min(progress, 100), status };
|
||||
}));
|
||||
if (progress >= 100) clearInterval(interval);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'uploading': return '上传中...';
|
||||
case 'analyzing_layout': return 'MinerU 版面分析...';
|
||||
case 'extracting_table': return '结构化表格提取...';
|
||||
case 'indexing': return '构建向量索引...';
|
||||
case 'ready': return '就绪';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-50">
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 overflow-y-auto p-6 md:p-10 w-full max-w-[1600px] mx-auto">
|
||||
{/* 1+3网格布局 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
{/* 创建知识库卡片 */}
|
||||
<button
|
||||
onClick={handleCreateOpen}
|
||||
className="group relative bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-dashed border-blue-300 rounded-xl p-4 flex flex-col items-center justify-center hover:shadow-lg hover:border-blue-400 hover:from-blue-100 hover:to-indigo-100 transition-all h-[240px] overflow-hidden"
|
||||
>
|
||||
{/* 主按钮 */}
|
||||
<div className="z-10 flex flex-col items-center mb-5 mt-2">
|
||||
<div className="w-14 h-14 bg-blue-600 rounded-full flex items-center justify-center mb-3 shadow-md group-hover:scale-110 transition-transform duration-300">
|
||||
<Plus className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-lg text-blue-800 group-hover:text-blue-900 tracking-tight">创建知识库</span>
|
||||
</div>
|
||||
|
||||
{/* 5种类型展示 */}
|
||||
<div className="z-10 w-full px-1">
|
||||
<div className="flex justify-between items-center px-1 mb-2 opacity-70">
|
||||
<span className="text-[10px] text-blue-800 font-bold uppercase tracking-wider mx-auto">支持 5 种专业类型</span>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
{KB_TYPES.slice(0, 5).map(type => {
|
||||
const TypeIcon = type.icon;
|
||||
return (
|
||||
<div key={type.id} className="flex flex-col items-center group/icon" title={type.name}>
|
||||
<div className={`w-8 h-8 rounded-lg bg-white flex items-center justify-center mb-1 shadow-sm border border-blue-100 group-hover/icon:border-blue-300 transition-all`}>
|
||||
<TypeIcon className={`w-4 h-4 ${type.color}`} />
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-medium scale-90 whitespace-nowrap group-hover/icon:text-blue-600 transition-colors">
|
||||
{type.name.substring(0, 4)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 现有知识库卡片 */}
|
||||
{knowledgeBases.map(kb => {
|
||||
const style = getKbTypeConfig('GUIDELINE' as KBType); // 默认类型
|
||||
const TypeIcon = style.icon;
|
||||
|
||||
return (
|
||||
<div key={kb.id} className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-lg hover:border-blue-200/50 transition-all flex flex-col h-[240px] group relative">
|
||||
{/* Card Header */}
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-2.5 rounded-lg ${style.bg} ${style.color}`}>
|
||||
<TypeIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<button className="text-gray-300 hover:text-gray-600 p-1 rounded hover:bg-gray-100">
|
||||
<MoreHorizontal className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-2 line-clamp-1 group-hover:text-blue-700 transition-colors">{kb.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded border ${style.bg} ${style.color}`}>
|
||||
{style.name}库
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 mb-3 line-clamp-2 h-8 leading-relaxed">
|
||||
{kb.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Footer */}
|
||||
<div className="pt-4 mt-2 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-3">
|
||||
<span className="flex items-center">
|
||||
<FileText className="w-3 h-3 mr-1"/> {kb.fileCount} 份文档
|
||||
</span>
|
||||
<span>{new Date(kb.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/knowledge-base/workspace/${kb.id}`)}
|
||||
className="w-full bg-slate-800 hover:bg-blue-600 text-white text-sm font-medium py-2.5 rounded-lg flex items-center justify-center transition-all shadow-sm transform active:scale-[0.98]"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
进入工作台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 创建向导Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl flex flex-col max-h-[90vh] overflow-hidden">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="px-8 py-6 border-b border-gray-100 flex justify-between items-center bg-white z-10">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 flex items-center">
|
||||
{createStep === 1 && <Sparkles className="w-6 h-6 mr-3 text-blue-600" />}
|
||||
{createStep === 1 ? '选择知识库类型' : createStep === 2 ? '基础信息配置' : '上传知识资产'}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mt-1.5 ml-0.5">
|
||||
{createStep === 1 ? '不同的业务场景将配置不同的 AI 策略,请根据您的实际需求选择。' :
|
||||
createStep === 2 ? '完善信息以便 AI 更好地扮演专家角色。' : '支持 PDF 批量上传,系统将自动进行 MinerU 深度解析。'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-slate-700 bg-gray-50 hover:bg-gray-100 p-2 rounded-full transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-8">
|
||||
{/* Step 1: 类型选择 */}
|
||||
{createStep === 1 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{KB_TYPES.map((type) => {
|
||||
const TypeIcon = type.icon;
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => {
|
||||
setSelectedTypeId(type.id);
|
||||
setCreateStep(2);
|
||||
}}
|
||||
className={`relative flex flex-col items-start p-6 rounded-xl border-2 transition-all duration-200 hover:shadow-xl bg-white text-left group min-h-[180px]
|
||||
${type.id === 'CUSTOM' ? 'border-dashed border-slate-300 hover:border-slate-500' : 'border-transparent hover:border-blue-500 ring-1 ring-slate-100'}
|
||||
`}
|
||||
>
|
||||
<div className="flex w-full justify-between items-start mb-4">
|
||||
<div className={`p-3.5 rounded-xl ${type.bg} ${type.color} group-hover:scale-110 transition-transform duration-300`}>
|
||||
<TypeIcon className="w-8 h-8" />
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-xl text-slate-800 mb-2 group-hover:text-blue-700">{type.name}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed mb-4">{type.desc}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-auto">
|
||||
{type.tags.map(tag => (
|
||||
<span key={tag} className="text-[10px] font-bold px-2 py-1 rounded bg-slate-100 text-slate-600 uppercase tracking-wide border border-slate-200">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 基础信息 */}
|
||||
{createStep === 2 && selectedTypeId && (
|
||||
<div className="max-w-xl mx-auto mt-6">
|
||||
<div className="bg-white p-8 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||
<div className={`inline-flex items-center space-x-2 px-4 py-1.5 rounded-full text-sm font-bold ${getKbTypeConfig(selectedTypeId).bg} ${getKbTypeConfig(selectedTypeId).color}`}>
|
||||
{React.createElement(getKbTypeConfig(selectedTypeId).icon, { className: "w-4 h-4" })}
|
||||
<span>正在创建:{getKbTypeConfig(selectedTypeId).name}知识库</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">知识库名称 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
placeholder="例如:2024年心衰诊疗指南合集"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">所属科室 (用于 AI 角色设定)</label>
|
||||
<select
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg bg-white outline-none focus:ring-2 focus:ring-blue-500 text-base"
|
||||
value={formData.department}
|
||||
onChange={(e) => setFormData({...formData, department: e.target.value})}
|
||||
>
|
||||
<option value="Cardiology">心内科</option>
|
||||
<option value="Neurology">神经内科</option>
|
||||
<option value="Oncology">肿瘤科</option>
|
||||
<option value="General">全科</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 上传 */}
|
||||
{createStep === 3 && (
|
||||
<div className="max-w-3xl mx-auto space-y-6 mt-4">
|
||||
<div
|
||||
onClick={simulateUpload}
|
||||
className="border-2 border-dashed border-gray-300 rounded-xl p-12 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50 hover:border-blue-500 transition-all group bg-white"
|
||||
>
|
||||
<div className="w-20 h-20 bg-blue-50 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
<Upload className="w-10 h-10 text-blue-600" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-slate-700">点击上传 PDF 文件</p>
|
||||
<p className="text-sm text-slate-400 mt-2">支持高清 PDF 及扫描件 (MinerU 深度解析引擎已就绪)</p>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">上传队列 ({files.length})</span>
|
||||
</div>
|
||||
<div className="max-h-[280px] overflow-y-auto p-2 space-y-2">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className="flex items-center p-3 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<div className="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center text-red-500 mr-4 flex-shrink-0">
|
||||
<FileText className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="font-medium text-slate-800 text-sm truncate">{f.name}</span>
|
||||
<span className="text-xs text-slate-500">{f.size}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${f.status === 'ready' ? 'bg-green-500' : 'bg-blue-600'}`}
|
||||
style={{ width: `${f.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className={`text-[10px] font-bold ${f.status === 'ready' ? 'text-green-600' : 'text-blue-600'} flex items-center`}>
|
||||
{f.status !== 'ready' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
||||
{getStatusText(f.status)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{f.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-gray-300 hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-8 py-5 border-t border-gray-100 bg-white flex justify-between items-center z-10">
|
||||
<button
|
||||
onClick={() => setCreateStep(Math.max(1, createStep - 1))}
|
||||
disabled={createStep === 1}
|
||||
className={`px-6 py-2.5 rounded-lg font-medium transition-colors ${createStep === 1 ? 'text-gray-300 cursor-not-allowed' : 'text-slate-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
|
||||
{createStep < 3 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (createStep === 1 && !selectedTypeId) return;
|
||||
if (createStep === 2 && !formData.name) return;
|
||||
setCreateStep(createStep + 1);
|
||||
}}
|
||||
disabled={(createStep === 1 && !selectedTypeId) || (createStep === 2 && !formData.name)}
|
||||
className={`px-10 py-3 rounded-lg text-white font-bold shadow-md transition-all flex items-center ${
|
||||
(createStep === 1 && !selectedTypeId) || (createStep === 2 && !formData.name)
|
||||
? 'bg-gray-300 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
下一步 <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCreateSubmit}
|
||||
className="px-10 py-3 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-bold shadow-lg hover:shadow-xl transition-all active:scale-95 flex items-center"
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5 mr-2" />
|
||||
完成并进入工作台
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
285
frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx
Normal file
285
frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* PKB知识库管理页面(v2版本)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Tabs, Button, message } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore';
|
||||
import KnowledgeBaseList from '../components/KnowledgeBaseList';
|
||||
import CreateKBDialog from '../components/CreateKBDialog';
|
||||
import EditKBDialog from '../components/EditKBDialog';
|
||||
import DocumentUpload from '../components/DocumentUpload';
|
||||
import DocumentList from '../components/DocumentList';
|
||||
import type { KnowledgeBase, Document } from '../api/knowledgeBaseApi';
|
||||
|
||||
const KnowledgePage: React.FC = () => {
|
||||
const {
|
||||
knowledgeBases,
|
||||
currentKb,
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
fetchKnowledgeBases,
|
||||
fetchKnowledgeBaseById,
|
||||
createKnowledgeBase,
|
||||
updateKnowledgeBase,
|
||||
deleteKnowledgeBase,
|
||||
fetchDocuments,
|
||||
deleteDocument,
|
||||
reprocessDocument,
|
||||
setCurrentKb,
|
||||
clearError,
|
||||
} = useKnowledgeBaseStore();
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
|
||||
|
||||
// 初始加载知识库列表
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
// 显示错误提示
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
message.error(error);
|
||||
clearError();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// 创建知识库
|
||||
const handleCreate = async (name: string, description?: string) => {
|
||||
await createKnowledgeBase(name, description);
|
||||
};
|
||||
|
||||
// 编辑知识库
|
||||
const handleEdit = (kb: KnowledgeBase) => {
|
||||
setEditingKb(kb);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
// 更新知识库
|
||||
const handleUpdate = async (id: string, name: string, description?: string) => {
|
||||
await updateKnowledgeBase(id, name, description);
|
||||
};
|
||||
|
||||
// 删除知识库
|
||||
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 (
|
||||
<div style={{ padding: 24, height: '100%', overflow: 'auto' }}>
|
||||
{!currentKb ? (
|
||||
// 知识库列表视图
|
||||
<>
|
||||
<KnowledgeBaseList
|
||||
knowledgeBases={knowledgeBases}
|
||||
loading={loading}
|
||||
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 }}
|
||||
>
|
||||
返回知识库列表
|
||||
</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)}
|
||||
>
|
||||
编辑知识库
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 文档管理标签页 */}
|
||||
<Tabs defaultActiveKey="documents">
|
||||
<Tabs.TabPane tab={`文档管理 (${documents.length})`} key="documents">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<DocumentUpload
|
||||
kbId={currentKb.id}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
disabled={false}
|
||||
maxDocuments={50}
|
||||
currentDocumentCount={documents.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card title="文档列表">
|
||||
<DocumentList
|
||||
documents={documents}
|
||||
loading={loading}
|
||||
onDelete={handleDeleteDocument}
|
||||
onReprocess={handleReprocessDocument}
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.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>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<EditKBDialog
|
||||
open={editDialogOpen}
|
||||
knowledgeBase={currentKb}
|
||||
onCancel={() => setEditDialogOpen(false)}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgePage;
|
||||
|
||||
|
||||
512
frontend-v2/src/modules/pkb/pages/WorkspacePage.tsx
Normal file
512
frontend-v2/src/modules/pkb/pages/WorkspacePage.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* PKB工作台页面
|
||||
* 设计要点:
|
||||
* 1. 单层Header - 包含返回、知识库名、Tab切换、设置
|
||||
* 2. 工作模式紧凑上移
|
||||
* 3. 对话框最大化空间
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Table, Button, message, Progress, Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
MessageSquare, FileText, Database,
|
||||
Settings, ChevronLeft, Trash2, CheckCircle2,
|
||||
Loader2, X, PanelRightOpen, Filter, Plus,
|
||||
BookOpen, FileSearch, Zap, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore';
|
||||
import { useWorkMode } from '../hooks/useWorkMode';
|
||||
import { FullTextMode } from '../components/Workspace/FullTextMode';
|
||||
import { DeepReadMode } from '../components/Workspace/DeepReadMode';
|
||||
import { BatchModeComplete } from '../components/Workspace/BatchModeComplete';
|
||||
import '../styles/workspace.css';
|
||||
|
||||
interface WorkspacePageProps {
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) => {
|
||||
const { kbId } = useParams<{ kbId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
currentKb,
|
||||
documents,
|
||||
loading,
|
||||
fetchKnowledgeBaseById,
|
||||
fetchDocuments,
|
||||
deleteDocument,
|
||||
} = useKnowledgeBaseStore();
|
||||
|
||||
const {
|
||||
workMode,
|
||||
selectedDocuments,
|
||||
setWorkMode,
|
||||
setSelectedDocuments,
|
||||
} = useWorkMode('full_text');
|
||||
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
const [isPdfOpen, setIsPdfOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (kbId) {
|
||||
fetchKnowledgeBaseById(kbId);
|
||||
fetchDocuments(kbId);
|
||||
}
|
||||
}, [kbId]);
|
||||
|
||||
// 轮询文档处理状态
|
||||
useEffect(() => {
|
||||
if (!kbId) return;
|
||||
|
||||
const hasProcessing = documents.some(doc =>
|
||||
['uploading', 'parsing', 'indexing'].includes(doc.status)
|
||||
);
|
||||
|
||||
if (!hasProcessing) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchDocuments(kbId);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [kbId, documents]);
|
||||
|
||||
// 工作模式配置
|
||||
const workModeConfig = {
|
||||
full_text: { label: '全文阅读', icon: <BookOpen className="w-4 h-4" />, color: 'text-blue-600' },
|
||||
deep_read: { label: '逐篇精读', icon: <FileSearch className="w-4 h-4" />, color: 'text-purple-600' },
|
||||
batch: { label: '批处理', icon: <Zap className="w-4 h-4" />, color: 'text-orange-600' },
|
||||
};
|
||||
|
||||
const workModeMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'full_text',
|
||||
label: (
|
||||
<div className="flex items-center py-1.5 px-1">
|
||||
<BookOpen className="w-4 h-4 mr-3 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">全文阅读</div>
|
||||
<div className="text-xs text-slate-500">加载全部文档,综合问答</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'deep_read',
|
||||
label: (
|
||||
<div className="flex items-center py-1.5 px-1">
|
||||
<FileSearch className="w-4 h-4 mr-3 text-purple-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">逐篇精读</div>
|
||||
<div className="text-xs text-slate-500">选择单篇文档,深度分析</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'batch',
|
||||
label: (
|
||||
<div className="flex items-center py-1.5 px-1">
|
||||
<Zap className="w-4 h-4 mr-3 text-orange-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">批处理</div>
|
||||
<div className="text-xs text-slate-500">批量提取,结构化输出</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleWorkModeChange: MenuProps['onClick'] = ({ key }) => {
|
||||
setWorkMode(key as 'full_text' | 'deep_read' | 'batch');
|
||||
};
|
||||
|
||||
// 逐篇精读的文档选择器
|
||||
const completedDocs = documents.filter(d => d.status === 'completed');
|
||||
const docMenuItems: MenuProps['items'] = completedDocs.map(doc => ({
|
||||
key: doc.id,
|
||||
label: (
|
||||
<div className="flex items-center py-1 max-w-xs">
|
||||
<FileText className="w-4 h-4 mr-2 text-red-500 flex-shrink-0" />
|
||||
<span className="truncate text-sm">{doc.filename}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const handleDocSelect: MenuProps['onClick'] = ({ key }) => {
|
||||
const doc = documents.find(d => d.id === key);
|
||||
if (doc) {
|
||||
setSelectedDocuments([doc]);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusMap: Record<string, { text: string; className: string; icon?: React.ReactNode }> = {
|
||||
completed: {
|
||||
text: '解析完成',
|
||||
className: 'bg-green-50 text-green-700 border-green-200',
|
||||
icon: <CheckCircle2 className="w-3 h-3" />
|
||||
},
|
||||
uploading: {
|
||||
text: 'MinerU 版面分析',
|
||||
className: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />
|
||||
},
|
||||
parsing: {
|
||||
text: '结构化提取',
|
||||
className: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />
|
||||
},
|
||||
indexing: {
|
||||
text: '向量索引',
|
||||
className: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />
|
||||
},
|
||||
error: {
|
||||
text: '解析失败',
|
||||
className: 'bg-red-50 text-red-700 border-red-200'
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusMap[status] || statusMap.completed;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border ${config.className}`}>
|
||||
{config.icon}
|
||||
<span className={config.icon ? 'ml-1.5' : ''}>{config.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (docId: string) => {
|
||||
try {
|
||||
await deleteDocument(docId);
|
||||
message.success('文档删除成功');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentKb) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClass = standalone
|
||||
? "fixed inset-0 z-50 flex flex-col bg-gray-50"
|
||||
: "flex flex-col h-screen bg-gray-50";
|
||||
|
||||
const currentModeConfig = workModeConfig[workMode];
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{/* 单层Header - 包含所有导航元素 */}
|
||||
<header className="h-14 bg-slate-900 text-white flex items-center justify-between px-5 flex-shrink-0 z-30 shadow-lg">
|
||||
{/* 左侧:返回 + 知识库名称 */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => navigate('/knowledge-base/dashboard')}
|
||||
className="flex items-center text-slate-300 hover:text-white hover:bg-slate-700/50 px-3 py-2 rounded-lg transition-all group border border-slate-700 hover:border-slate-600"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1.5 group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span className="text-sm font-medium">返回知识库列表</span>
|
||||
</button>
|
||||
|
||||
<div className="h-5 w-px bg-slate-700 mx-4"></div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Database className="w-4 h-4 mr-2 text-blue-400" />
|
||||
<h1 className="text-base font-bold text-white">{currentKb.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:Tab切换(精致胶囊按钮) */}
|
||||
<div className="flex items-center bg-slate-800/60 rounded-xl p-1 border border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`flex items-center px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className={`w-4 h-4 mr-2 ${activeTab === 'chat' ? '' : 'opacity-70'}`} />
|
||||
智能问答
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('assets')}
|
||||
className={`flex items-center px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'assets'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`w-4 h-4 mr-2 ${activeTab === 'assets' ? '' : 'opacity-70'}`} />
|
||||
知识资产
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
|
||||
activeTab === 'assets' ? 'bg-blue-500' : 'bg-slate-700'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧:标签 + 设置 + 头像 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-xs text-slate-400 border border-slate-700 px-2.5 py-1 rounded-md bg-slate-800/50">
|
||||
{currentKb.fileCount || documents.length} 篇文档
|
||||
</span>
|
||||
<button className="text-slate-400 hover:text-white p-2 rounded-lg hover:bg-slate-700/50 transition-colors">
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center text-white font-bold text-xs shadow ring-2 ring-slate-700">
|
||||
DL
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{/* 智能问答Tab */}
|
||||
{activeTab === 'chat' && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 工作模式选择器 - 紧凑设计,只占一行 */}
|
||||
<div className="flex items-center justify-between px-5 py-2.5 border-b border-gray-200 bg-white flex-shrink-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Dropdown
|
||||
menu={{ items: workModeMenuItems, onClick: handleWorkModeChange }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<button className="flex items-center px-3 py-1.5 bg-gray-50 hover:bg-gray-100 rounded-lg border border-gray-200 transition-colors">
|
||||
<span className={currentModeConfig.color}>{currentModeConfig.icon}</span>
|
||||
<span className="ml-2 text-sm font-medium text-slate-700">{currentModeConfig.label}</span>
|
||||
<ChevronDown className="w-4 h-4 ml-2 text-slate-400" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* 逐篇精读时显示文档选择器 */}
|
||||
{workMode === 'deep_read' && (
|
||||
<Dropdown
|
||||
menu={{ items: docMenuItems, onClick: handleDocSelect }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<button className="flex items-center px-3 py-1.5 bg-purple-50 hover:bg-purple-100 rounded-lg border border-purple-200 transition-colors max-w-[200px]">
|
||||
<FileText className="w-4 h-4 text-purple-600 flex-shrink-0" />
|
||||
<span className="ml-2 text-sm font-medium text-purple-700 truncate">
|
||||
{selectedDocuments.length > 0 ? selectedDocuments[0].filename : '选择文档'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 text-purple-400 flex-shrink-0" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400">
|
||||
已加载 <span className="font-semibold text-slate-600">{completedDocs.length}</span> / {documents.length} 篇
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天区域 - 全屏展开,白色背景 */}
|
||||
<div className="flex-1 flex overflow-hidden relative bg-white">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{workMode === 'full_text' && (
|
||||
<FullTextMode
|
||||
kbId={kbId!}
|
||||
kbInfo={currentKb}
|
||||
documents={documents}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workMode === 'deep_read' && (
|
||||
<DeepReadMode
|
||||
kbId={kbId!}
|
||||
kbInfo={currentKb}
|
||||
selectedDocuments={selectedDocuments}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workMode === 'batch' && (
|
||||
<BatchModeComplete
|
||||
kbId={kbId!}
|
||||
kbInfo={currentKb}
|
||||
documents={documents}
|
||||
template="clinicalResearch"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PDF侧边栏 */}
|
||||
{isPdfOpen && (
|
||||
<div className="w-[45%] flex flex-col bg-slate-100 border-l border-gray-200 shadow-xl z-10">
|
||||
<div className="h-10 border-b border-gray-200 bg-white flex items-center justify-between px-3">
|
||||
<span className="text-xs font-bold text-slate-700 flex items-center truncate">
|
||||
<FileText className="w-3 h-3 mr-2 text-red-500" />
|
||||
PDF预览
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsPdfOpen(false)}
|
||||
className="p-1 hover:bg-gray-100 rounded text-slate-500"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 pdf-pattern p-8 overflow-y-auto flex justify-center">
|
||||
<div className="bg-white shadow-lg w-full max-w-xl p-10 opacity-95">
|
||||
<div className="w-1/3 h-6 bg-slate-800 mb-8"></div>
|
||||
<div className="space-y-4">
|
||||
<div className="w-full h-3 bg-slate-200"></div>
|
||||
<div className="w-full h-3 bg-slate-200"></div>
|
||||
<div className="w-5/6 h-3 bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle按钮 */}
|
||||
{!isPdfOpen && (
|
||||
<button
|
||||
onClick={() => setIsPdfOpen(true)}
|
||||
className="absolute right-0 top-1/2 transform -translate-y-1/2 bg-white border border-gray-200 border-r-0 shadow-md p-2 rounded-l-lg text-slate-500 hover:text-blue-600 z-10"
|
||||
title="展开 PDF 预览"
|
||||
>
|
||||
<PanelRightOpen className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识资产Tab */}
|
||||
{activeTab === 'assets' && (
|
||||
<div className="flex flex-col h-full bg-slate-50 p-8 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto w-full flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-end mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">文档资产管理</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
管理该知识库下的所有文件,查看 MinerU 解析状态。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button icon={<Filter className="w-4 h-4" />} className="shadow-sm">
|
||||
筛选
|
||||
</Button>
|
||||
<Button type="primary" icon={<Plus className="w-4 h-4" />} className="shadow-md font-medium">
|
||||
上传新文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文档表格 */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm flex-1 overflow-hidden">
|
||||
<Table
|
||||
dataSource={documents}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ y: '100%' }}
|
||||
size="middle"
|
||||
className="pkb-document-table"
|
||||
columns={[
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
render: (text) => (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="w-9 h-9 bg-red-50 text-red-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-700 text-sm">{text}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '解析状态 (MinerU Pipeline)',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status, record) => (
|
||||
<div className="flex flex-col space-y-1.5 max-w-[180px]">
|
||||
{getStatusBadge(status)}
|
||||
{status !== 'completed' && status !== 'error' && (
|
||||
<Progress
|
||||
percent={record.progress || 0}
|
||||
size="small"
|
||||
strokeColor="#3b82f6"
|
||||
trailColor="#e5e7eb"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'fileSizeBytes',
|
||||
key: 'fileSizeBytes',
|
||||
render: (size) => (
|
||||
<span className="text-slate-500 font-mono text-sm">
|
||||
{(size / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Tokens',
|
||||
dataIndex: 'tokensCount',
|
||||
key: 'tokensCount',
|
||||
render: (tokens) => (
|
||||
<span className="text-slate-500 font-mono text-sm">
|
||||
{tokens ? `${(tokens / 1000).toFixed(0)}k` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'uploadedAt',
|
||||
key: 'uploadedAt',
|
||||
render: (date) => (
|
||||
<span className="text-slate-400 text-sm">
|
||||
{new Date(date).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'right',
|
||||
render: (_, record) => (
|
||||
<button
|
||||
onClick={() => handleDeleteDocument(record.id)}
|
||||
className="text-slate-400 hover:text-red-500 p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<style>{`
|
||||
.pdf-pattern {
|
||||
background-color: #f1f5f9;
|
||||
background-image: linear-gradient(45deg, #e2e8f0 25%, transparent 25%, transparent 75%, #e2e8f0 75%, #e2e8f0),
|
||||
linear-gradient(45deg, #e2e8f0 25%, transparent 25%, transparent 75%, #e2e8f0 75%, #e2e8f0);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 10px 10px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspacePage;
|
||||
223
frontend-v2/src/modules/pkb/stores/useKnowledgeBaseStore.ts
Normal file
223
frontend-v2/src/modules/pkb/stores/useKnowledgeBaseStore.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* PKB知识库状态管理(v2版本)
|
||||
* 使用Zustand进行状态管理
|
||||
*/
|
||||
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
55
frontend-v2/src/modules/pkb/styles/workspace.css
Normal file
55
frontend-v2/src/modules/pkb/styles/workspace.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* PKB Workspace 样式优化
|
||||
* 根据差距文档进行精细化调整
|
||||
*/
|
||||
|
||||
/* 1. 全局字体和排版优化 */
|
||||
.pkb-document-table .ant-table {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-table-thead > tr > th {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-table-tbody > tr > td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-table-tbody > tr:hover > td {
|
||||
background-color: #f9fafb;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 2. 圆角统一 */
|
||||
.pkb-document-table .ant-table-container {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 3. 动画优化 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 4. 表格行悬停优化 */
|
||||
.pkb-document-table .ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
40
frontend-v2/src/modules/pkb/types/workspace.ts
Normal file
40
frontend-v2/src/modules/pkb/types/workspace.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* PKB工作台类型定义
|
||||
*/
|
||||
|
||||
export type WorkMode = 'full_text' | 'deep_read' | 'batch';
|
||||
|
||||
export type KBType = 'GUIDELINE' | 'RESEARCH' | 'CASE_REPORT' | 'DRUG_SAFETY' | 'EXAM' | 'CUSTOM';
|
||||
|
||||
export interface KBTypeConfig {
|
||||
id: KBType;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
desc: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface WorkModeConfig {
|
||||
id: WorkMode;
|
||||
name: string;
|
||||
icon: string;
|
||||
desc: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface DocumentSelectionParams {
|
||||
kbId: string;
|
||||
maxFiles?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface BatchTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ export { default as Placeholder } from './Placeholder';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
frontend-v2/src/vite-env.d.ts
vendored
4
frontend-v2/src/vite-env.d.ts
vendored
@@ -26,4 +26,8 @@ interface ImportMeta {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user