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,425 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 知识库工作台 V3 (交互优化版)</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.263.1"
}
}
</script>
<!-- 3. 引入 Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards;
}
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* PDF 模拟背景 */
.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>
</head>
<body class="bg-gray-100 text-slate-800 font-sans antialiased overflow-hidden">
<div id="root"></div>
<script type="text/babel" data-type="module">
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import {
ArrowLeft, MessageSquare, Database, Search, Plus,
FileText, Trash2, RotateCw, CheckCircle2, Loader2,
Bot, User, Send, ChevronRight, MoreHorizontal,
Maximize2, Download, AlertCircle, FileSearch, SplitSquareHorizontal,
X, Settings, ChevronDown, Filter, LayoutDashboard, PanelRightOpen, Calendar
} from 'lucide-react';
// --- MOCK DATA ---
const KB_INFO = {
id: 'KB001',
name: '心内科 2024 临床指南合集',
type: '临床指南',
docCount: 12,
engine: 'Dify RAG'
};
const MOCK_MESSAGES = [
{ id: 1, role: 'ai', content: '您好,李医生。我是您的循证医学助手。已加载 12 份心内科相关指南。请问有什么可以帮您?', citations: [] },
{ id: 2, role: 'user', content: '请总结一下2024年高血压指南中关于老年患者降压目标值的变化。', citations: [] },
{ id: 3, role: 'ai', content: '根据《中国高血压防治指南(2024修订版)》[1],针对老年高血压患者的降压目标值主要更新如下:\n\n1. **65~79岁老年人**:如能耐受,推荐将收缩压降至 <140 mmHg若耐受性良好可进一步降至 <130 mmHg。\n2. **≥80岁高龄老年人**:一般建议将收缩压降至 <150 mmHg如耐受性良好可尝试降至 <140 mmHg。\n\n相比2018版新版指南更加强调在耐受前提下进行强化降压。', citations: [{id: 'doc1', page: 45, text: '老年高血压...'}] }
];
const MOCK_FILES = [
{ id: 1, name: '中国高血压防治指南(2024修订版).pdf', size: '4.2 MB', uploadTime: '2024-05-20 10:30', status: 'ready', progress: 100, tokens: '45k' },
{ id: 2, name: '慢性心力衰竭诊断治疗指南2023.pdf', size: '3.8 MB', uploadTime: '2024-05-19 14:20', status: 'ready', progress: 100, tokens: '32k' },
{ id: 3, name: '房颤抗凝治疗专家共识.pdf', size: '1.5 MB', uploadTime: '2024-05-19 14:22', status: 'ready', progress: 100, tokens: '12k' },
{ id: 4, name: '冠心病心脏康复基层指南.pdf', size: '2.8 MB', uploadTime: '2024-05-18 09:15', status: 'ready', progress: 100, tokens: '28k' },
{ id: 5, name: '最新_心肌梗死急诊流程_草稿.pdf', size: '2.1 MB', uploadTime: '刚刚', status: 'analyzing_layout', progress: 35, tokens: '-' },
];
function WorkspaceV3() {
// Navigation State
const [activeTab, setActiveTab] = useState('CHAT');
// Chat State
const [messages, setMessages] = useState(MOCK_MESSAGES);
const [inputValue, setInputValue] = useState('');
// V3 UPDATE: Default isPdfOpen is now FALSE
const [isPdfOpen, setIsPdfOpen] = useState(false);
// Asset State
const [files, setFiles] = useState(MOCK_FILES);
const [isFilterOpen, setIsFilterOpen] = useState(false); // Filter Dropdown State
// Simulation of MinerU Progress
useEffect(() => {
const interval = setInterval(() => {
setFiles(prev => prev.map(f => {
if (f.status === 'ready') return f;
let newProgress = f.progress + 5;
let newStatus = f.status;
if (newProgress > 30) newStatus = 'extracting_table';
if (newProgress > 70) newStatus = 'indexing';
if (newProgress >= 100) { newProgress = 100; newStatus = 'ready'; }
return { ...f, progress: newProgress, status: newStatus };
}));
}, 800);
return () => clearInterval(interval);
}, []);
// Helper for Status Badge
const getStatusBadge = (status) => {
switch(status) {
case 'ready': return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-green-700 border border-green-100"><CheckCircle2 className="w-3 h-3 mr-1"/> 解析完成</span>;
case 'analyzing_layout': return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100"><Loader2 className="w-3 h-3 mr-1 animate-spin"/> MinerU 版面分析</span>;
case 'extracting_table': return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700 border border-purple-100"><Loader2 className="w-3 h-3 mr-1 animate-spin"/> 结构化提取</span>;
case 'indexing': return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-50 text-orange-700 border border-orange-100"><Loader2 className="w-3 h-3 mr-1 animate-spin"/> 向量索引</span>;
default: return null;
}
};
// V3 UPDATE: Toggle PDF function
const handleCitationClick = () => {
if (!isPdfOpen) setIsPdfOpen(true);
};
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* 1. IMMERSIVE HEADER */}
<header className="h-14 bg-slate-900 text-white flex items-center justify-between px-4 flex-shrink-0 z-30 shadow-md">
<div className="flex items-center">
<button className="flex items-center text-slate-300 hover:text-white hover:bg-slate-800 px-3 py-1.5 rounded-lg transition-all group mr-4">
<ChevronDown className="w-4 h-4 mr-1 transform rotate-90 group-hover:-translate-x-1 transition-transform" />
<span className="font-bold text-sm">返回知识库列表</span>
</button>
<div className="h-6 w-px bg-slate-700 mx-2"></div>
<div className="ml-2">
<h1 className="text-base font-bold flex items-center text-white">
<Database className="w-4 h-4 mr-2 text-blue-400" />
{KB_INFO.name}
</h1>
</div>
</div>
<div className="flex items-center space-x-4">
<span className="text-xs text-slate-400 border border-slate-700 px-2 py-0.5 rounded bg-slate-800">{KB_INFO.type}</span>
<button className="text-slate-400 hover:text-white"><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-sm ring-2 ring-slate-800">DL</div>
</div>
</header>
{/* 2. PROMINENT TAB NAVIGATION */}
<div className="bg-white border-b border-gray-200 px-6 flex items-center shadow-sm z-20 h-14">
<div className="flex space-x-8 h-full">
<button
onClick={() => setActiveTab('CHAT')}
className={`flex items-center px-2 border-b-2 transition-all h-full ${activeTab === 'CHAT' ? 'border-blue-600 text-blue-600 font-bold' : 'border-transparent text-slate-500 hover:text-slate-700 font-medium'}`}
>
<MessageSquare className={`w-5 h-5 mr-2 ${activeTab === 'CHAT' ? 'fill-blue-100' : ''}`} />
<span className="text-base">智能问答</span>
</button>
<button
onClick={() => setActiveTab('ASSETS')}
className={`flex items-center px-2 border-b-2 transition-all h-full ${activeTab === 'ASSETS' ? 'border-blue-600 text-blue-600 font-bold' : 'border-transparent text-slate-500 hover:text-slate-700 font-medium'}`}
>
<FileText className={`w-5 h-5 mr-2 ${activeTab === 'ASSETS' ? 'fill-blue-100' : ''}`} />
<span className="text-base">知识资产</span>
<span className="ml-2 bg-gray-100 text-slate-500 text-xs px-2 py-0.5 rounded-full">12</span>
</button>
</div>
</div>
{/* 3. MAIN CONTENT */}
<main className="flex-1 overflow-hidden relative">
{/* === VIEW A: CHAT MODE === */}
{activeTab === 'CHAT' && (
<div className="flex h-full animate-fade-in">
{/* Left: Chat Messages (Width dynamic based on PDF state) */}
<div className={`flex-1 flex flex-col bg-white border-r border-gray-200 transition-all duration-300`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`flex items-start max-w-3xl ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 mt-1 shadow-sm ${msg.role === 'user' ? 'bg-indigo-100 text-indigo-700 ml-3' : 'bg-white border border-gray-200 text-blue-600 mr-3'}`}>
{msg.role === 'user' ? <User className="w-5 h-5"/> : <Bot className="w-5 h-5"/>}
</div>
<div className={`p-4 rounded-2xl text-sm leading-relaxed shadow-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-slate-50 text-slate-800 rounded-tl-none border border-gray-100'}`}>
<div className="whitespace-pre-wrap">{msg.content}</div>
{msg.citations && msg.citations.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200/50 flex flex-wrap gap-2">
{msg.citations.map((cite, idx) => (
<button
key={idx}
onClick={handleCitationClick}
className="bg-white border border-blue-200 text-blue-600 px-2 py-1 rounded text-xs hover:bg-blue-50 transition-colors flex items-center shadow-sm"
>
<FileText className="w-3 h-3 mr-1" />
来源 [{idx + 1}]
</button>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
<div className="p-5 border-t border-gray-200 bg-white">
<div className="max-w-4xl mx-auto relative">
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="请输入临床问题AI 将基于知识库回答..."
className="w-full pl-5 pr-12 py-3.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none shadow-sm text-sm"
/>
<button className="absolute right-2 top-2 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Right: PDF Viewer (Conditional) */}
{isPdfOpen && (
<div className="w-[45%] flex flex-col bg-slate-100 border-l border-gray-200 shadow-xl z-10 animate-slide-in-right">
<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" />
中国高血压防治指南(2024修订版).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 h-[1200px] p-10 opacity-95 relative">
<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 className="mt-12 border-l-4 border-yellow-400 bg-yellow-50/50 p-4 -mx-4">
<div className="w-full h-3 bg-slate-300 mb-2"></div>
<div className="w-3/4 h-3 bg-slate-300"></div>
</div>
<div className="mt-12 space-y-4">
<div className="w-full h-3 bg-slate-200"></div>
<div className="w-full h-3 bg-slate-200"></div>
</div>
</div>
</div>
</div>
)}
{/* Show Toggle Button only when closed */}
{!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>
)}
{/* === VIEW B: ASSETS MODE === */}
{activeTab === 'ASSETS' && (
<div className="flex flex-col h-full bg-slate-50 p-8 overflow-hidden animate-fade-in">
<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 relative">
{/* Filter Button & Dropdown */}
<div className="relative">
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`flex items-center px-4 py-2 border rounded-lg text-sm font-medium shadow-sm transition-colors ${isFilterOpen ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-white border-gray-300 text-slate-700 hover:bg-gray-50'}`}
>
<Filter className="w-4 h-4 mr-2" />
筛选
</button>
{/* V3 UPDATE: Filter Dropdown Implementation */}
{isFilterOpen && (
<div className="absolute top-12 right-0 w-72 bg-white rounded-xl shadow-xl border border-gray-200 p-4 z-50 animate-fade-in">
<div className="space-y-4">
<div>
<h4 className="text-xs font-bold text-slate-500 uppercase mb-2">解析状态</h4>
<div className="space-y-2">
<label className="flex items-center space-x-2 text-sm text-slate-700 cursor-pointer hover:bg-gray-50 p-1 rounded">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" defaultChecked />
<span>全部</span>
</label>
<label className="flex items-center space-x-2 text-sm text-slate-700 cursor-pointer hover:bg-gray-50 p-1 rounded">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span className="flex items-center"><Loader2 className="w-3 h-3 mr-1 text-blue-500"/> 进行中 (MinerU)</span>
</label>
<label className="flex items-center space-x-2 text-sm text-slate-700 cursor-pointer hover:bg-gray-50 p-1 rounded">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span className="flex items-center"><AlertCircle className="w-3 h-3 mr-1 text-red-500"/> 解析失败</span>
</label>
</div>
</div>
<div className="h-px bg-gray-100"></div>
<div>
<h4 className="text-xs font-bold text-slate-500 uppercase mb-2">上传时间</h4>
<div className="space-y-2">
<label className="flex items-center space-x-2 text-sm text-slate-700 cursor-pointer hover:bg-gray-50 p-1 rounded">
<input type="radio" name="time" className="border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>最近 7 </span>
</label>
<label className="flex items-center space-x-2 text-sm text-slate-700 cursor-pointer hover:bg-gray-50 p-1 rounded">
<input type="radio" name="time" className="border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>最近 30 </span>
</label>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 flex justify-end">
<button onClick={() => setIsFilterOpen(false)} className="text-xs text-blue-600 font-bold hover:underline">应用筛选</button>
</div>
</div>
)}
</div>
<button className="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-md transition-colors">
<Plus className="w-4 h-4 mr-2" />
上传新文件
</button>
</div>
</div>
{/* Table Container */}
<div className="bg-white border border-gray-200 rounded-xl shadow-sm flex-1 overflow-hidden flex flex-col">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50/50 text-slate-500 font-medium border-b border-gray-200">
<tr>
<th className="px-6 py-4 w-12"><input type="checkbox" className="rounded border-gray-300"/></th>
<th className="px-6 py-4">文件名</th>
<th className="px-6 py-4">解析状态 (MinerU Pipeline)</th>
<th className="px-6 py-4">文件大小</th>
<th className="px-6 py-4">Tokens</th>
<th className="px-6 py-4">上传时间</th>
<th className="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{files.map(file => (
<tr key={file.id} className="hover:bg-blue-50/30 transition-colors group">
<td className="px-6 py-4"><input type="checkbox" className="rounded border-gray-300"/></td>
<td className="px-6 py-4">
<div className="flex items-center">
<div className="w-9 h-9 bg-red-50 text-red-500 rounded-lg flex items-center justify-center mr-3">
<FileText className="w-5 h-5" />
</div>
<span className="font-bold text-slate-700">{file.name}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col space-y-1.5 max-w-[180px]">
<div>{getStatusBadge(file.status)}</div>
{file.status !== 'ready' && (
<div className="w-full bg-gray-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-500 h-full transition-all duration-300" style={{width: `${file.progress}%`}}></div>
</div>
)}
</div>
</td>
<td className="px-6 py-4 text-slate-500 font-mono">{file.size}</td>
<td className="px-6 py-4 text-slate-500 font-mono">{file.tokens}</td>
<td className="px-6 py-4 text-slate-400">{file.uploadTime}</td>
<td className="px-6 py-4 text-right">
<button className="text-slate-400 hover:text-red-500 p-2 rounded-lg hover:bg-red-50 transition-colors opacity-0 group-hover:opacity-100">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
const root = createRoot(document.getElementById('root'));
root.render(<WorkspaceV3 />);
</script>
</body>
</html>