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

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

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;