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
513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
/**
|
||
* 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;
|