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,164 @@
# **AI 临床医生与医院知识库 \- MVP 阶段产品需求文档 (PRD) V5.0**
|
| 版本号 | 日期 | 修改人 | 修改内容 |
| V4.0 | 2024-06-XX | Product Lead | 降维策略 MVP |
| V5.0 | 2024-06-XX | Product Lead | 完整落地版:基于“全量阅读”策略,补全 UI/UX 规范与详细功能定义 |
## **1\. 核心战略与范围 (Strategy & Scope)**
### **1.1 核心价值主张**
放弃传统的“切片式 RAG”利用 DeepSeek-V3 / Qwen-Max 的长窗口能力,通过 **Document-Level RAG (文档级阅读)** 技术,为医生提供**逻辑完整、引用精准**的知识库问答体验。
### **1.2 MVP 阶段边界 (Scope Freeze)**
* **适用终端:** Web 端 (PC/Mac 浏览器),兼容 iPad。
* **支持格式:** PDF, Word (.docx), PPT (.pptx)。
* **硬性限制:** 单库文件数 ≤ 30 个;单文件大小 ≤ 20MB。
* **核心场景:** 循证检索 (指南)、深度研读 (文献)、用药助手 (药品)、考典刷题 (考试)。
## **2\. 功能模块详情 (Detailed Requirements)**
### **2.1 模块一:仪表盘 (Dashboard)**
**用户故事:** 作为医生,我希望一眼看到我有多少个知识库,并能快速创建一个新的专科知识库。
#### **2.1.1 页面布局与 UI 规范**
* **布局结构:**
* **顶部:** 全局导航栏 (Logo \+$$智能统计$$$$智能清洗$$$$\*\*AI 知识库\*\*$$$$AI 问答$$
\+ 头像)。
* **主体:** "1+3" 卡片阵列布局 (Grid System)。
* **卡片设计:**
* **Slot 1 (新建入口):**
* **样式:** 浅蓝色渐变背景 (bg-blue-50),深蓝色虚线边框。
* **内容:** 大号 "+" 图标,文案 "创建知识库",下方并列展示 5 个场景图标 (指南/文献/病例/药品/考试) 以提示能力。
* **Slot 2-N (现有知识库):**
* **样式:** 白色卡片,微阴影 (shadow-sm \-\> hover shadow-md)。
* **内容:** 图标(左上) \+ 标题(加粗) \+ 类型标签(胶囊样式) \+ "进入工作台"按钮(底部通栏)。
#### **2.1.2 交互流程:创建知识库**
* **触发:** 点击“创建知识库”卡片 \-\> 弹出模态框 (Modal)。
* **Step 1: 类型选择 (Type Selection)**
* **UI:** 5 个大卡片网格。
* **选项:**
1. **循证检索 (指南):** 图标 BookOpen (Blue)。文案:"查诊疗标准、用药剂量"。
2. **深度研读 (文献):** 图标 Microscope (Purple)。文案:"文献综述、横向对比"。
3. **临床决策 (病例):** 图标 Stethoscope (Green)。文案:"疑难病例参考"。
4. **用药助手 (药品):** 图标 Pill (Rose)。文案:"查配伍禁忌"。
5. **考典刷题 (考试):** 图标 GraduationCap (Orange)。文案:"主治/副高备考"。
* **Step 2: 基础信息 & 角色注入**
* **字段:** 知识库名称 (必填)、所属科室 (下拉选:心内/呼吸/消化...)。
* **逻辑:** 选中“心内科”后,后端自动在 System Prompt 中注入 *"你是一名心内科专家..."*
* **Step 3: 文件上传 (Upload)**
* **UI:** 大面积拖拽上传区 (Dropzone)。
* **逻辑:**
* 前端校验大小 (\>20MB 飘红报错)。
* 前端校验数量 (\>30 个 飘红报错)。
* 上传成功后显示列表,状态流转:上传中... \-\> 就绪。
### **2.2 模块二:沉浸式工作台 (Workspace)**
**用户故事:** 作为医生,我希望在一个无干扰的环境中与我的资料对话,并能随时核对原文。
#### **2.2.1 全局框架 (Immersive Layout)**
* **顶部导航 (Header):**
* **样式:** 深色背景 (bg-slate-900),高度 56px。
* **左侧:** \< 返回 (白色文字按钮,点击返回 Dashboard)。
* **中间:** 知识库名称 \+ 图标。
* **右侧:** 简单的设置齿轮。
* **模式切换 (Tabs):**
* 位于 Header 下方,白色背景,高度 48px。
* **Tab A:** \[💬 智能问答\] (默认选中,底部蓝条)。
* **Tab B:** \[📂 知识资产\] (显示文件计数 Badge)。
#### **2.2.2 视图 A智能问答 (Smart Chat)**
* **布局:**
* **默认:** 单栏宽屏聊天窗口 (最大宽度 900px居中)。
* **扩展:** 当点击引用或手动展开时,右侧滑出 PDF 阅读器 (占比 45%),聊天窗自动收缩至左侧。
* **对话交互 (Chat Interaction):**
* **输入框:** 底部固定。支持 Shift+Enter 换行。
* **AI 回答:** 流式输出 (Typewriter Effect)。
* **引用标注:** 必须以 \[文档名\] 或 \[1\] 形式高亮显示,颜色为品牌蓝。
* **后端策略路由 (核心逻辑):**
* **场景一 (小库直读):** 知识库总 Token \< 32k。
* *UI:* 顶部 Toast 提示 "已加载全量上下文AI 具备全知视角"。
* **场景二 (大库路由):** 知识库总 Token \> 32k。
* *UI:* 用户提问后,输入框上方出现微型 Loading 状态 —— "正在分析摘要..." \-\> "已定位至《2024指南》等 3 篇文档" \-\> 开始回答。
#### **2.2.3 视图 B知识资产 (Assets Management)**
* **UI 形式:** 全屏数据表格 (Table)。
* **列定义:**
1. **文件名:** 图标 (PDF/Word) \+ 名称。
2. **AI 摘要:** 展示 2-3 行核心摘要文本 (由 DeepSeek 生成),支持 hover 查看全部。
3. **状态:** 准备中 (灰色) / 就绪 (绿色) / 失败 (红色)。
4. **操作:** 预览、删除。
* **筛选器:** 右上角“筛选”按钮,点击弹出下拉面板 (按时间/状态/类型)。
## **3\. 详细 UI/UX 设计规范 (Design Specs)**
为确保还原度,请 UI 设计师和前端开发遵循以下规范:
### **3.1 色彩体系 (Color Palette)**
使用 Tailwind CSS 默认色板:
* **主色 (Primary):** Blue-600 (\#2563EB) \- 用于按钮、链接、高亮。
* **场景色 (Category Colors):**
* 指南: Blue-500
* 文献: Purple-600
* 病例: Emerald-600
* 药品: Rose-600
* 考试: Orange-500
* **中性色:** Slate-50 (背景), Slate-900 (Header), Slate-500 (次要文字)。
### **3.2 图标系统 (Iconography)**
* **风格:** 线性图标 (Stroke 2px)。
* **技术:** 使用 **Inline SVG** (参考 TDD V3.0 的 Icons 常量),严禁引入外部 Icon Font。
### **3.3 字体与排版**
* **字体:** 系统默认无衬线字体 (Inter, Roboto, PingFang SC)。
* **字号:**
* H1 (页面标题): 20px Bold
* H2 (模块标题): 16px Bold
* Body (正文): 14px Regular (行高 1.6)
* Caption (说明): 12px Text-Slate-400
## **4\. 异常流程与边界处理 (Exception Handling)**
### **4.1 上传失败**
* **场景:** 用户上传了加密 PDF 或损坏文件。
* **后端:** 解析器捕获异常,返回 status: FAILEDerror\_msg: "File encrypted"。
* **前端:** 资产列表中该行变红,显示“解析失败:文件已加密”,并提供“删除”按钮。
### **4.2 AI 回答超时/失败**
* **场景:** DeepSeek API 响应超时 (\>60s)。
* **UI:**
* 消息气泡显示红色感叹号。
* 提示文案: "AI 思考超时,请尝试精简问题或重试。"
* 提供$$重试$$
按钮。
### **4.3 引用源定位失败**
* **场景:** 用户点击 \[1\],但该文件已被删除。
* **UI:** 弹出 Toast 提示 "源文件已被删除,无法查看原文"。
## **5\. 验收测试标准 (QA Acceptance)**
| ID | 测试点 | 预期结果 | 优先级 |
| TC01 | 格式兼容性 | 上传 .pdf, .docx, .pptx 文件,均能在资产列表显示“就绪”,且能被检索到。 | P0 |
| TC02 | 边界限制 | 尝试上传第 31 个文件,前端应阻止上传并弹窗提示限制。 | P0 |
| TC03 | 引用跳转 | 提问后,点击回答中的 \[文档名\],右侧面板应滑出并正确加载该文档。 | P0 |
| TC04 | Word表格解析 | 上传包含表格的 Word 指南询问表格内数据AI 应能准确回答数值。 | P1 |
| TC05 | 路由逻辑 | 在 \>32k Token 的库中提问,观察 Network 请求,应看到系统先请求了摘要接口,再请求了全文接口。 | P1 |

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>

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>