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,577 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 临床医生与医院知识库 Dashboard V5</title>
<!-- 1. 引入 Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 2. 配置 Import Map -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
"lucide-react": "https://esm.sh/lucide-react@0.344.0"
}
}
</script>
<!-- 3. 引入 Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.animate-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
/* 增加一个微弱的呼吸动画给新建按钮 */
@keyframes pulse-soft {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.2); }
50% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
}
.hover-pulse:hover {
animation: pulse-soft 2s infinite;
}
</style>
</head>
<body class="bg-gray-50 text-slate-800 font-sans antialiased overflow-hidden">
<div id="root"></div>
<!-- 4. React 代码逻辑 -->
<script type="text/babel" data-type="module">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
Plus, BookOpen, Microscope, FileText, User, X, CheckCircle2,
Loader2, ChevronRight, Upload, Database, BrainCircuit, MessageSquare,
MoreHorizontal, GraduationCap, Pill, Stethoscope, Wrench, BarChart3,
Eraser, ArrowRight, Sparkles, Trash2
} from 'lucide-react';
// --- MOCK DATA: Platform Context ---
const PLATFORM_MODULES = [
{ name: '智能统计分析', icon: BarChart3, active: false },
{ name: '智能数据清洗', icon: Eraser, active: false },
{ name: '智能文献', icon: BookOpen, active: false },
{ name: 'AI 问答', icon: MessageSquare, active: false },
{ name: 'AI 知识库', icon: Database, active: true },
];
// --- MOCK DATA: Knowledge Base Types (Reverted to V3 Clinical Terms) ---
const KB_TYPES = [
{
id: 'GUIDELINE',
name: '临床指南',
icon: BookOpen,
color: 'text-blue-600',
bg: 'bg-blue-100',
desc: '存储诊疗规范、专家共识。支持精确检索与引用跳转。',
tags: ['RAG', '精准溯源']
},
{
id: 'RESEARCH',
name: '科研文献',
icon: Microscope,
color: 'text-purple-600',
bg: 'bg-purple-100',
desc: '存储同主题文献。支持全文深度阅读、横向对比总结。',
tags: ['Long Context', '深度分析']
},
{
id: 'CASE_REPORT',
name: '典型病例',
icon: Stethoscope,
color: 'text-emerald-600',
bg: 'bg-emerald-100',
desc: '存储疑难病历。支持相似病例检索、临床决策辅助。',
tags: ['Multimodal', '时序分析']
},
{
id: 'DRUG_SAFETY',
name: '药品安全',
icon: Pill,
color: 'text-rose-600',
bg: 'bg-rose-100',
desc: '存储药品说明书。支持配伍禁忌、不良反应查询。',
tags: ['RAG', '结构化提取']
},
{
id: 'EXAM',
name: '职称考试',
icon: GraduationCap,
color: 'text-orange-600',
bg: 'bg-orange-100',
desc: '存储题库与解析。支持考点生成、模拟练习。',
tags: ['Hybrid', '题库模式']
},
{
id: 'CUSTOM',
name: '自定义',
icon: Wrench,
color: 'text-slate-600',
bg: 'bg-slate-200',
desc: '自由配置 AI 引擎参数,混合管理多种文档。',
tags: ['Advanced', '自由配置']
},
];
// --- MOCK DATA: Existing Assets (Default 3 items) ---
const MOCK_KBS = [
{
id: 1,
name: "心内科 2024 临床指南合集",
type: "GUIDELINE",
docCount: 12,
lastActive: "10分钟前",
snippet: "包含高血压、心衰、房颤等核心指南",
status: "ready"
},
{
id: 2,
name: "GLP-1 药物心脏获益研究",
type: "RESEARCH",
docCount: 5,
lastActive: "昨天",
snippet: "NEJM, Lancet 等顶级期刊文献综述",
status: "ready"
},
{
id: 3,
name: "2023 年度科室疑难病例归档",
type: "CASE_REPORT",
docCount: 28,
lastActive: "3天前",
snippet: "包含影像学报告与病程记录",
status: "processing"
}
];
function DashboardV5() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [createStep, setCreateStep] = useState(1);
const [selectedTypeId, setSelectedTypeId] = useState(null);
// Create Form State
const [formData, setFormData] = useState({ name: '', department: 'Cardiology' });
const [files, setFiles] = useState([]);
// Simulation: File Upload Pipeline
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 getKbTypeConfig = (id) => KB_TYPES.find(t => t.id === id) || KB_TYPES[0];
const handleCreateOpen = () => {
setCreateStep(1);
setSelectedTypeId(null);
setFormData({ name: '', department: 'Cardiology' });
setFiles([]);
setIsModalOpen(true);
};
const getStatusText = (status) => {
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 font-sans text-slate-800">
{/* ---------------- 1. PLATFORM GLOBAL NAVIGATION ---------------- */}
<header className="bg-white border-b border-gray-200 h-16 flex items-center px-6 justify-between flex-shrink-0 z-30 shadow-sm relative">
<div className="flex items-center space-x-8">
{/* Logo Area */}
<div className="flex items-center space-x-2 mr-2 cursor-pointer hover:opacity-80 transition-opacity">
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-blue-700 rounded-lg flex items-center justify-center text-white shadow-md">
<BrainCircuit className="w-5 h-5" />
</div>
<span className="text-lg font-bold text-slate-800 tracking-tight">AI 科研平台</span>
</div>
{/* Module Nav */}
<nav className="hidden lg:flex space-x-1">
{PLATFORM_MODULES.map((module) => {
const Icon = module.icon;
return (
<button
key={module.name}
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
module.active
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-100'
: 'text-slate-500 hover:text-slate-800 hover:bg-gray-50'
}`}
>
<Icon className={`w-4 h-4 ${module.active ? 'text-blue-600' : 'text-slate-400'}`} />
<span>{module.name}</span>
</button>
);
})}
</nav>
</div>
{/* User Profile */}
<div className="flex items-center space-x-4">
<div className="hidden md:flex flex-col items-end mr-1">
<span className="text-sm font-semibold text-slate-700">Dr. Li</span>
<span className="text-xs text-slate-500">心内科 | 主治医师</span>
</div>
<div className="w-9 h-9 bg-indigo-100 rounded-full flex items-center justify-center text-indigo-700 font-bold border-2 border-white shadow-sm cursor-pointer hover:ring-2 hover:ring-indigo-200 transition-all">
DL
</div>
</div>
</header>
{/* ---------------- 2. MAIN CONTENT (1+3 GRID) ---------------- */}
<main className="flex-1 overflow-y-auto p-6 md:p-10 w-full max-w-[1600px] mx-auto">
{/* Layout Strategy: 4 Columns
1st Column: Create New (Highlighted)
2nd-4th Columns: Existing KBs
*/}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* --- CARD 1: THE "HERO" CREATE CARD --- */}
<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 hover-pulse"
>
{/* Main Action */}
<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>
{/* Micro-Showcase: Capabilities (Added Text Labels) */}
<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>
{/* Added Text Label Here */}
<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>
{/* --- CARDS 2-4: EXISTING ASSETS --- */}
{MOCK_KBS.map(kb => {
const style = getKbTypeConfig(kb.type);
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 & Body */}
<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>
<div className="flex items-center space-x-1">
{kb.status === 'processing' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
<button className="text-gray-300 hover:text-gray-600 p-1 rounded hover:bg-gray-100">
<MoreHorizontal className="w-5 h-5" />
</button>
</div>
</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 border-transparent ${style.bg} ${style.color.replace('text-', 'text-opacity-90 ')}`}>
{style.name}
</span>
</div>
<p className="text-xs text-slate-400 mb-3 line-clamp-2 h-8 leading-relaxed">{kb.snippet}</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.docCount} 份文档</span>
<span>{kb.lastActive}</span>
</div>
<button 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>
{/* ---------------- 3. THE "WIZARD" MODAL (Updated Terminology) ---------------- */}
{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: Type Selection (Reverted to V3 Names) */}
{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: Basic Info */}
{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>
<p className="text-xs text-slate-400 mt-2 flex items-center">
<User className="w-3 h-3 mr-1" />
系统将自动为您设定该科室专家的 Prompt 角色
</p>
</div>
</div>
</div>
)}
{/* STEP 3: Upload */}
{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={() => setIsModalOpen(false)}
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>
);
}
const root = createRoot(document.getElementById('root'));
root.render(<DashboardV5 />);
</script>
</body>
</html>