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:
2026-01-06 22:15:42 +08:00
parent b31255031e
commit 5a17d096a7
226 changed files with 14899 additions and 224 deletions

View 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;