Files
AIclinicalresearch/frontend-v2/src/modules/pkb/pages/WorkspacePage.tsx
HaHafeng 5a17d096a7 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
2026-01-06 22:15:42 +08:00

513 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;