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:
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;
|
||||
Reference in New Issue
Block a user