Files
HaHafeng 8be741cd52 docs(dc/tool-c): Complete Tool C MVP planning and TODO list
Summary:
- Update Tool C MVP Development Plan (V1.3)
  * Clarify Python execution as core feature
  * Add 15 real medical data cleaning scenarios (basic/medium/advanced)
  * Enhance System Prompt with 10 Few-shot examples
  * Discover existing Python service (extraction_service)
  * Update to extend existing service instead of rebuilding
- Create Tool C MVP Development TODO List
  * 3-week plan with 30 tasks (Day 1-15)
  * 4 core milestones with clear acceptance criteria
  * Daily checklist and risk management
  * Detailed task breakdown for each day

Key Changes:
- Python service: Extend existing extraction_service instead of new setup
- Test scenarios: 15 scenarios (5 basic + 5 medium + 5 advanced)
- Success criteria: Basic >90%, Medium >80%, Advanced >60%, Total >80%
- Development time: Reduced from 3 weeks to 2 weeks (reuse infrastructure)

Status: Planning complete, ready to start Day 1 development
2025-12-06 11:00:44 +08:00

479 lines
35 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工具C - 科研数据编辑器 V6 (修复版)</title>
<!-- 1. 引入 Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 2. 引入 React 和 ReactDOM (切换为 jsDelivr 源,更稳定,国内访问更快) -->
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<!-- 3. 引入 Babel (用于解析 JSX) -->
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7.23.5/babel.min.js"></script>
<!-- 4. 引入 Lucide Icons (指定 UMD 版本,修复 Identifier 'Infinity' 报错) -->
<script src="https://cdn.jsdelivr.net/npm/lucide@0.460.0/dist/umd/lucide.min.js"></script>
<style>
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes zoomIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.animate-in { animation: fadeIn 0.3s ease-out forwards; }
.slide-in-right { animation: slideInRight 0.3s ease-out forwards; }
.zoom-in { animation: zoomIn 0.2s ease-out forwards; }
/* 滚动条美化 */
::-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; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased overflow-hidden">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- 图标组件适配 (Safe Shim) ---
// 使用 ref 容器隔离 React 和 DOM 操作,修复图标渲染问题
const LucideIcon = ({ name, className, size = 16, ...props }) => {
const containerRef = useRef(null);
useEffect(() => {
// 确保 lucide 全局对象存在且 createIcons 方法可用
if (containerRef.current && typeof lucide !== 'undefined' && lucide.createIcons) {
containerRef.current.innerHTML = '';
const i = document.createElement('i');
i.setAttribute('data-lucide', name);
containerRef.current.appendChild(i);
lucide.createIcons({
root: containerRef.current,
nameAttr: 'data-lucide',
attrs: { class: className, width: size, height: size, ...props }
});
}
}, [name, className, size]);
return <span ref={containerRef} style={{ display: 'inline-flex', verticalAlign: 'middle' }}></span>;
};
// --- 模拟数据 ---
const INITIAL_DATA = [
{ id: 'P001', age: 45, gender: '男', bmi: 24.5, admission_date: '2023-01-12', lab_val: '4.5' },
{ id: 'P002', age: 62, gender: '女', bmi: 28.1, admission_date: '2023-01-15', lab_val: '5.1' },
{ id: 'P003', age: 205, gender: '男', bmi: null, admission_date: '2023-02-01', lab_val: '<0.1' },
{ id: 'P004', age: 58, gender: '女', bmi: 22.4, admission_date: '2023-02-10', lab_val: '4.8' },
{ id: 'P005', age: 34, gender: '男', bmi: 21.0, admission_date: '2023-03-05', lab_val: '5.2' },
{ id: 'P006', age: 71, gender: '女', bmi: null, admission_date: '2023-03-12', lab_val: '6.0' },
{ id: 'P007', age: null, gender: '男', bmi: null, admission_date: '2023-04-01', lab_val: '4.9' },
{ id: 'P008', age: 49, gender: '男', bmi: 26.8, admission_date: '2023-04-05', lab_val: '5.5' },
{ id: 'P009', age: 55, gender: '女', bmi: 23.9, admission_date: '2023-04-10', lab_val: '4.2' },
{ id: 'P010', age: 66, gender: '男', bmi: 29.1, admission_date: '2023-04-12', lab_val: '5.8' },
];
const INITIAL_COLUMNS = [
{ id: 'id', name: '病人ID', type: 'text', width: 100 },
{ id: 'age', name: '年龄', type: 'number', width: 80 },
{ id: 'gender', name: '性别', type: 'category', width: 80 },
{ id: 'bmi', name: 'BMI指数', type: 'number', width: 100 },
{ id: 'admission_date', name: '入院日期', type: 'date', width: 120 },
{ id: 'lab_val', name: '肌酐', type: 'text', width: 100 },
];
const ToolC_EditorV6 = () => {
// --- 核心状态 ---
const [data, setData] = useState(INITIAL_DATA);
const [columns, setColumns] = useState(INITIAL_COLUMNS);
const [selectedColId, setSelectedColId] = useState(null);
// 侧边栏状态 (默认打开 Chat)
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [sidebarTab, setSidebarTab] = useState('chat'); // 'chat' | 'insight'
// Chat 状态
const [messages, setMessages] = useState([
{ id: 1, role: 'system', content: '您好!我是您的 AI 数据分析师。我可以为您编写代码来清洗数据。试试说“把年龄大于60的设为老年组”。' }
]);
const [inputValue, setInputValue] = useState('');
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef(null);
// 滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(scrollToBottom, [messages, isTyping]);
// 处理发送消息 (模拟 AI 生成代码)
const handleSend = () => {
if (!inputValue.trim()) return;
const userText = inputValue;
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: userText }]);
setInputValue('');
setIsTyping(true);
// 模拟 AI 思考和生成代码
setTimeout(() => {
setIsTyping(false);
let aiResponse;
if (userText.includes('老年') || userText.includes('年龄')) {
aiResponse = {
id: Date.now(),
role: 'assistant',
content: '没问题。我将使用 Pandas 对 `age` 列进行分箱处理,生成新的 `age_group` 列。请检查以下代码并运行:',
code: {
lang: 'python',
content: `import pandas as pd\n\n# 定义分箱逻辑\ndef categorize_age(age):\n if pd.isna(age):\n return None\n return '老年组' if age > 60 else '非老年组'\n\n# 应用函数生成新列\ndf['age_group'] = df['age'].apply(categorize_age)`,
status: 'pending' // pending | running | success | error
}
};
} else {
aiResponse = {
id: Date.now(),
role: 'assistant',
content: '收到。这是一个数据清洗任务,但我目前只演示“年龄分组”的 Python 代码生成能力。您可以试试输入“把年龄大于60的标记为老年组”。'
};
}
setMessages(prev => [...prev, aiResponse]);
}, 1200);
};
// 执行代码 (模拟 Pyodide 运行)
const handleRunCode = (msgId) => {
// 1. 更新消息状态为 running
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, code: { ...m.code, status: 'running' } } : m
));
// 2. 模拟执行延迟
setTimeout(() => {
// 3. 更新表格数据
const newData = data.map(row => ({
...row,
age_group: row.age && row.age > 60 ? '老年组' : (row.age ? '非老年组' : null)
}));
setData(newData);
// 检查列是否存在,不存在则添加
if (!columns.find(c => c.id === 'age_group')) {
setColumns(prev => [...prev, { id: 'age_group', name: 'age_group', type: 'category', width: 100 }]);
}
// 4. 更新消息状态为 success
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, code: { ...m.code, status: 'success' } } : m
));
// 5. 追加成功提示
setMessages(prev => [...prev, {
id: Date.now(),
role: 'system',
content: '✅ 代码执行成功!已新增列 `age_group`。',
isSuccess: true
}]);
}, 1500);
};
// --- UI 组件 ---
const ToolbarButton = ({ iconName, label, colorClass = "text-slate-600 hover:bg-slate-100" }) => (
<button className={`flex flex-col items-center justify-center w-20 h-14 rounded-lg transition-all hover:shadow-sm ${colorClass}`}>
<LucideIcon name={iconName} className="w-5 h-5 mb-1" />
<span className="text-[10px] font-medium">{label}</span>
</button>
);
return (
<div className="h-screen w-screen bg-slate-50 font-sans text-slate-800 flex flex-col overflow-hidden">
{/* 1. Header */}
<header className="bg-white border-b border-slate-200 h-14 flex-none flex items-center justify-between px-4 z-20 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-600 rounded-lg flex items-center justify-center text-white shadow-emerald-200 shadow-md">
<LucideIcon name="table-2" size={20} />
</div>
<span className="font-bold text-lg text-slate-900 tracking-tight">科研数据编辑器 <span className="text-emerald-600 text-xs px-1.5 py-0.5 bg-emerald-50 rounded-full ml-1">Pro</span></span>
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
<span className="text-xs text-slate-500 font-mono">lung_cancer_2023.csv</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center bg-slate-100 rounded-lg p-1">
<button className="p-1.5 text-slate-400 hover:text-slate-700 rounded-md hover:bg-white transition-all"><LucideIcon name="undo-2" size={16} /></button>
<button className="p-1.5 text-slate-400 hover:text-slate-700 rounded-md hover:bg-white transition-all"><LucideIcon name="redo-2" size={16} /></button>
</div>
<button className="flex items-center gap-2 px-4 py-1.5 bg-slate-900 text-white rounded-lg text-xs font-medium hover:bg-slate-800 transition-all shadow-md">
<LucideIcon name="download" size={12} /> 导出结果
</button>
</div>
</header>
{/* 2. Main Workspace (Flex Layout) */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Table Area (Flexible Width) */}
<div className="flex-1 flex flex-col min-w-0 bg-slate-100/50">
{/* Flat Toolbar */}
<div className="bg-white border-b border-slate-200 px-4 py-2 flex items-center gap-1 overflow-x-auto flex-none shadow-sm z-10">
<ToolbarButton iconName="calculator" label="生成新变量" colorClass="text-emerald-600 bg-emerald-50 hover:bg-emerald-100" />
<ToolbarButton iconName="calendar-clock" label="时间差" colorClass="text-blue-600 bg-blue-50 hover:bg-blue-100" />
<ToolbarButton iconName="arrow-left-right" label="横纵转换" colorClass="text-cyan-600 bg-cyan-50 hover:bg-cyan-100" />
<div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
<ToolbarButton iconName="file-search" label="查重" colorClass="text-orange-600 bg-orange-50 hover:bg-orange-100" />
<ToolbarButton iconName="wand-2" label="多重插补" colorClass="text-rose-600 bg-rose-50 hover:bg-rose-100" />
<div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
<ToolbarButton iconName="filter" label="筛选分析集" colorClass="text-indigo-600 bg-indigo-50 hover:bg-indigo-100" />
<div className="flex-1"></div>
<div className="relative">
<LucideIcon name="search" size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input className="pl-9 pr-4 py-1.5 text-sm bg-slate-100 border-none rounded-full w-48 focus:w-64 transition-all outline-none focus:ring-2 focus:ring-emerald-500/20" placeholder="搜索值..." />
</div>
</div>
{/* Grid Container */}
<div className="flex-1 overflow-auto p-6 relative">
<div className="bg-white border border-slate-200 shadow-sm rounded-xl overflow-hidden min-w-[800px]">
{/* Grid Header */}
<div className="flex bg-slate-50 border-b border-slate-200 sticky top-0 z-10">
{columns.map(col => (
<div
key={col.id}
className={`h-10 px-4 flex items-center justify-between border-r border-slate-200 text-xs font-bold text-slate-600 cursor-pointer hover:bg-slate-100 transition-colors ${selectedColId === col.id ? 'bg-indigo-50 text-indigo-600' : ''}`}
style={{ width: col.width, flex: 'none' }}
onClick={() => {
setSelectedColId(col.id);
if (!isSidebarOpen) setIsSidebarOpen(true);
setSidebarTab('insight');
}}
>
<div className="flex items-center gap-1.5 truncate">
{col.type === 'number' && <LucideIcon name="hash" size={12} className="text-slate-400" />}
{col.type === 'text' && <LucideIcon name="type" size={12} className="text-slate-400" />}
{col.type === 'category' && <LucideIcon name="split" size={12} className="text-slate-400" />}
{col.type === 'date' && <LucideIcon name="calendar" size={12} className="text-slate-400" />}
{col.name}
</div>
{col.id === 'age_group' && <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>}
</div>
))}
</div>
{/* Grid Body */}
<div className="divide-y divide-slate-100 text-sm text-slate-700 bg-white">
{data.map((row, idx) => (
<div key={idx} className={`flex hover:bg-slate-50 transition-colors ${row.age_group ? 'bg-emerald-50/30' : ''}`}>
{columns.map(col => {
const val = row[col.id];
const isNull = val === null || val === '';
const isOutlier = col.id === 'age' && val > 120;
return (
<div
key={col.id}
className={`h-10 px-4 flex items-center border-r border-slate-100 truncate
${isNull ? 'bg-red-50' : ''}
${isOutlier ? 'bg-orange-100 text-orange-700 font-bold' : ''}
${selectedColId === col.id ? 'bg-indigo-50/10' : ''}
`}
style={{ width: col.width, flex: 'none' }}
>
{isNull ? <span className="text-[10px] text-red-400 italic">NULL</span> : val}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
{/* Right: Smart Sidebar (Fixed Width, No Overlay) */}
{isSidebarOpen && (
<div className="w-[400px] flex-none border-l border-slate-200 bg-white flex flex-col shadow-xl z-20">
{/* Sidebar Tabs */}
<div className="flex border-b border-slate-200 bg-slate-50">
<button
onClick={() => setSidebarTab('chat')}
className={`flex-1 py-3 text-xs font-bold text-center border-b-2 transition-all flex items-center justify-center gap-2 ${sidebarTab === 'chat' ? 'border-purple-600 text-purple-700 bg-white' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
<LucideIcon name="sparkles" size={16} /> AI Copilot
</button>
<button
onClick={() => setSidebarTab('insight')}
className={`flex-1 py-3 text-xs font-bold text-center border-b-2 transition-all flex items-center justify-center gap-2 ${sidebarTab === 'insight' ? 'border-indigo-600 text-indigo-700 bg-white' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
<LucideIcon name="bar-chart-3" size={16} /> 统计概览
</button>
<button onClick={() => setIsSidebarOpen(false)} className="px-3 text-slate-400 hover:text-slate-600 hover:bg-slate-200 border-l border-slate-200 transition-colors">
<LucideIcon name="arrow-right" size={16} />
</button>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* === TAB 1: AI Chat (Code Interpreter Mode) === */}
{sidebarTab === 'chat' && (
<>
<div className="flex-1 overflow-y-auto p-4 space-y-5 bg-slate-50/50">
{messages.map((msg) => (
<div key={msg.id} className={`flex flex-col animate-in slide-in-right duration-300 ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
{/* 文本气泡 */}
<div className={`max-w-[90%] px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm ${
msg.role === 'user'
? 'bg-purple-600 text-white rounded-tr-none'
: msg.isSuccess
? 'bg-emerald-50 text-emerald-800 border border-emerald-100'
: 'bg-white text-slate-700 border border-slate-200 rounded-tl-none'
}`}>
{msg.role === 'assistant' && !msg.isSuccess && (
<div className="flex items-center gap-2 mb-2 text-purple-600 font-bold text-xs uppercase tracking-wider">
<LucideIcon name="bot" size={12} /> AI Analysis
</div>
)}
{msg.content}
</div>
{/* 代码块卡片 (Code Block) */}
{msg.code && (
<div className="mt-3 w-full bg-[#1e1e1e] rounded-xl overflow-hidden shadow-md border border-slate-300 animate-in zoom-in duration-300">
<div className="bg-[#2d2d2d] px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-slate-400">
<LucideIcon name="terminal" size={12} /> Python Environment
</div>
<span className="text-[10px] text-slate-500 font-mono">pandas available</span>
</div>
<div className="p-3 overflow-x-auto">
<pre className="text-xs font-mono text-blue-300 leading-5">
{msg.code.content}
</pre>
</div>
{/* 执行状态栏 */}
<div className="bg-[#252526] px-3 py-2 border-t border-[#333] flex items-center justify-between">
{msg.code.status === 'pending' && (
<button
onClick={() => handleRunCode(msg.id)}
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs px-3 py-1.5 rounded transition-colors"
>
<LucideIcon name="play" size={12} className="fill-current" /> 运行代码
</button>
)}
{msg.code.status === 'running' && (
<span className="flex items-center gap-2 text-yellow-500 text-xs">
<LucideIcon name="loader-2" size={12} className="animate-spin" /> 执行中 (Pyodide)...
</span>
)}
{msg.code.status === 'success' && (
<span className="flex items-center gap-2 text-emerald-500 text-xs">
<LucideIcon name="check-circle-2" size={12} /> 执行成功 (0.4s)
</span>
)}
</div>
</div>
)}
</div>
))}
{isTyping && (
<div className="flex items-center gap-2 text-slate-400 text-xs ml-2">
<span className="w-2 h-2 bg-purple-400 rounded-full animate-bounce"></span>
<span className="w-2 h-2 bg-purple-400 rounded-full animate-bounce delay-100"></span>
<span className="w-2 h-2 bg-purple-400 rounded-full animate-bounce delay-200"></span>
</div>
)}
<div ref={messagesEndRef}></div>
</div>
{/* Input */}
<div className="p-4 bg-white border-t border-slate-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<div className="relative">
<textarea
className="w-full pl-4 pr-12 py-3 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none resize-none shadow-inner"
rows={2}
placeholder="输入指令例如把年龄大于60的标记为老年组..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => { if(e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
/>
<button
className={`absolute right-2 bottom-2 p-2 rounded-lg transition-all ${inputValue.trim() ? 'bg-purple-600 text-white hover:bg-purple-700 shadow-md' : 'bg-slate-200 text-slate-400'}`}
disabled={!inputValue.trim()}
onClick={handleSend}
>
<LucideIcon name="arrow-right" size={16} />
</button>
</div>
<div className="text-[10px] text-slate-400 mt-2 text-center flex items-center justify-center gap-1">
<LucideIcon name="settings" size={12} /> 代码在本地浏览器沙箱运行数据不上传云端
</div>
</div>
</>
)}
{/* === TAB 2: Insight Panel (统计概览) === */}
{sidebarTab === 'insight' && (
<div className="flex-1 overflow-y-auto p-5 space-y-6 animate-in">
{selectedColId ? (
<>
<div className="p-4 bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">SELECTED COLUMN</div>
<h3 className="text-xl font-bold text-slate-900">{columns.find(c => c.id === selectedColId)?.name}</h3>
<div className="text-xs font-mono text-slate-400 mt-1">{selectedColId}</div>
</div>
{/* Mock Charts */}
<div className="space-y-3">
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<LucideIcon name="bar-chart-3" size={16} className="text-slate-500" /> 分布概览
</h4>
<div className="h-32 bg-slate-50 rounded-lg border border-slate-100 flex items-end justify-between px-2 pb-2 gap-1">
{[20, 45, 30, 60, 80, 50, 20, 10].map((h, i) => (
<div key={i} className="flex-1 bg-indigo-200 hover:bg-indigo-400 transition-colors rounded-t" style={{height: `${h}%`}}></div>
))}
</div>
</div>
<div className="space-y-2 pt-4 border-t border-slate-100">
<button className="w-full text-left px-4 py-3 rounded-xl bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-indigo-300 transition-all">
<LucideIcon name="split" size={16} className="text-indigo-600" />
<div><div className="font-bold">生成分类变量</div><div className="text-[10px] text-slate-500">Binning</div></div>
</button>
<button className="w-full text-left px-4 py-3 rounded-xl bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-indigo-300 transition-all">
<LucideIcon name="wand-2" size={16} className="text-rose-600" />
<div><div className="font-bold">缺失值填补</div><div className="text-[10px] text-slate-500">Imputation</div></div>
</button>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 text-sm opacity-60">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<LucideIcon name="bar-chart-3" size={32} />
</div>
<p>请点击表格列头<br/>查看详细统计</p>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<ToolC_EditorV6 />);
</script>
</body>
</html>