Files
HaHafeng d4d33528c7 feat(dc): Complete Phase 1 - Portal workbench page development
Summary:
- Implement DC module Portal page with 3 tool cards
- Create ToolCard component with decorative background and hover animations
- Implement TaskList component with table layout and progress bars
- Implement AssetLibrary component with tab switching and file cards
- Complete database verification (4 tables confirmed)
- Complete backend API verification (6 endpoints ready)
- Optimize UI to match prototype design (V2.html)

Frontend Components (~715 lines):
- components/ToolCard.tsx - Tool cards with animations
- components/TaskList.tsx - Recent tasks table view
- components/AssetLibrary.tsx - Data asset library with tabs
- hooks/useRecentTasks.ts - Task state management
- hooks/useAssets.ts - Asset state management
- pages/Portal.tsx - Main portal page
- types/portal.ts - TypeScript type definitions

Backend Verification:
- Backend API: 1495 lines code verified
- Database: dc_schema with 4 tables verified
- API endpoints: 6 endpoints tested (templates API works)

Documentation:
- Database verification report
- Backend API test report
- Phase 1 completion summary
- UI optimization report
- Development task checklist
- Development plan for Tool B

Status: Phase 1 completed (100%), ready for browser testing
Next: Phase 2 - Tool B Step 1 and 2 development
2025-12-02 21:53:24 +08:00

506 lines
26 KiB
TypeScript
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.
import React, { useState } from 'react';
import {
Table2,
Calculator,
CalendarClock,
Split,
Filter,
Download,
ArrowRight,
Trash2,
AlertCircle,
BarChart3,
Hash,
Type,
Calendar,
ChevronDown,
Undo2,
Redo2,
Save,
Wand2,
Settings,
FileUp,
Search,
ArrowLeftRight // 新增图标
} from 'lucide-react';
// --- 模拟数据 ---
const MOCK_DATA = [
{ id: 'P001', age: 45, gender: 'Male', bmi: 24.5, admission_date: '2023-01-12', lab_val: '4.5' },
{ id: 'P002', age: 62, gender: 'Female', bmi: 28.1, admission_date: '2023-01-15', lab_val: '5.1' },
{ id: 'P003', age: 205, gender: 'Male', bmi: null, admission_date: '2023-02-01', lab_val: '<0.1' },
{ id: 'P004', age: 58, gender: 'F', bmi: 22.4, admission_date: '2023-02-10', lab_val: '4.8' },
{ id: 'P005', age: 34, gender: 'Male', bmi: 21.0, admission_date: '2023-03-05', lab_val: '5.2' },
{ id: 'P006', age: 71, gender: 'Female', bmi: 30.5, admission_date: '2023-03-12', lab_val: '6.0' },
{ id: 'P007', age: null, gender: 'Male', bmi: 25.3, admission_date: '2023-04-01', lab_val: '4.9' },
{ id: 'P008', age: 49, gender: 'Male', bmi: 26.8, admission_date: '2023-04-05', lab_val: '5.5' },
{ id: 'P009', age: 55, gender: 'Female', bmi: 23.9, admission_date: '2023-04-10', lab_val: '4.2' },
{ id: 'P010', age: 66, gender: 'Male', bmi: 29.1, admission_date: '2023-04-12', lab_val: '5.8' },
];
const COLUMNS = [
{ id: 'id', name: '病人ID', type: 'text', locked: true },
{ id: 'age', name: '年龄', type: 'number' },
{ id: 'gender', name: '性别', type: 'category' },
{ id: 'bmi', name: 'BMI指数', type: 'number' },
{ id: 'admission_date', name: '入院日期', type: 'date' },
{ id: 'lab_val', name: '肌酐', type: 'text' },
];
const ToolC_EditorV2 = () => {
const [selectedColId, setSelectedColId] = useState<string | null>(null);
const [showSidebar, setShowSidebar] = useState(false);
const [showModal, setShowModal] = useState<'calc' | 'recode' | 'pivot' | null>(null); // 新增 pivot 状态
const selectedCol = COLUMNS.find(c => c.id === selectedColId);
const handleColClick = (colId: string) => {
if (selectedColId === colId) {
setShowSidebar(!showSidebar);
} else {
setSelectedColId(colId);
setShowSidebar(true);
}
};
// --- 组件:顶部工具按钮 ---
const ToolbarButton = ({
icon: Icon,
label,
desc,
colorClass = "text-slate-600 bg-slate-50 hover:bg-slate-100",
onClick
}: { icon: any, label: string, desc: string, colorClass?: string, onClick?: () => void }) => (
<button
onClick={onClick}
className="relative group flex flex-col items-center justify-center w-24 h-16 rounded-lg transition-all hover:shadow-sm"
>
<div className={`p-2 rounded-lg mb-1 ${colorClass}`}>
<Icon className="w-5 h-5" />
</div>
<span className="text-[11px] font-medium text-slate-600">{label}</span>
{/* Custom Tooltip */}
<div className="absolute top-full mt-2 px-3 py-2 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
{desc}
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-800 rotate-45"></div>
</div>
</button>
);
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-800 flex flex-col relative overflow-hidden">
{/* 1. Header (基础操作) */}
<header className="bg-white border-b border-slate-200 h-14 flex items-center justify-between px-4 z-30">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center text-emerald-600">
<Table2 className="w-5 h-5" />
</div>
<span className="font-bold text-slate-900"></span>
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
<span className="text-xs text-slate-500">_2023.csv ()</span>
</div>
<div className="flex items-center gap-2">
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full" title="撤销"><Undo2 className="w-4 h-4" /></button>
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full" title="重做"><Redo2 className="w-4 h-4" /></button>
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
<button className="flex items-center gap-2 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200">
<Save className="w-3 h-3" />
</button>
<button className="flex items-center gap-2 px-4 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 shadow-sm">
<Download className="w-3 h-3" />
</button>
</div>
</header>
{/* 2. Top Toolbar (扁平化核心功能) */}
<div className="bg-white border-b border-slate-200 px-6 py-2 flex items-center gap-2 overflow-x-auto">
<ToolbarButton
icon={Calculator}
label="生成新变量"
desc="使用公式计算生成新列 (如 BMI = 体重/身高²)"
colorClass="bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100"
onClick={() => setShowModal('calc')}
/>
<ToolbarButton
icon={CalendarClock}
label="计算时间差"
desc="计算两个日期之间的天数/年数 (如 年龄、住院天数)"
colorClass="bg-blue-50 text-blue-600 group-hover:bg-blue-100"
/>
{/* 新增:长宽转换按钮 */}
<ToolbarButton
icon={ArrowLeftRight}
label="长宽转换"
desc="将'一人多行'转换为'一人一行' (透视/Pivot)"
colorClass="bg-blue-50 text-blue-600 group-hover:bg-blue-100"
onClick={() => setShowModal('pivot')}
/>
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
<ToolbarButton
icon={FileUp}
label="拆分数据集"
desc="按某一列的值将数据拆分为多个文件或 Sheet"
colorClass="bg-purple-50 text-purple-600 group-hover:bg-purple-100"
/>
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
<ToolbarButton
icon={AlertCircle}
label="跨列规则检查"
desc="自定义逻辑规则 (如: 男性不能有孕产史) 并标记错误行"
colorClass="bg-orange-50 text-orange-600 group-hover:bg-orange-100"
/>
<ToolbarButton
icon={Filter}
label="构建入排标准"
desc="组合多重筛选条件,生成最终分析集 (Cohort Selection)"
colorClass="bg-indigo-50 text-indigo-600 group-hover:bg-indigo-100"
/>
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
<div className="flex-1"></div> {/* Spacer */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" placeholder="搜索值..." className="pl-9 pr-4 py-1.5 text-sm border border-slate-200 rounded-full focus:border-emerald-500 outline-none" />
</div>
</div>
{/* 3. Main Grid Area */}
<div className="flex-1 relative overflow-hidden bg-slate-100 flex">
{/* Grid */}
<div className="flex-1 overflow-auto p-6 transition-all duration-300" style={{ marginRight: showSidebar ? '320px' : '0' }}>
<div className="bg-white border border-slate-200 shadow-sm rounded-lg overflow-hidden min-w-[800px]">
<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-emerald-50 text-emerald-700' : ''}`}
style={{ width: col.id === 'id' ? '100px' : '150px' }}
onClick={() => handleColClick(col.id)}
>
<div className="flex items-center gap-1.5">
{col.type === 'number' && <Hash className="w-3 h-3 text-slate-400" />}
{col.type === 'text' && <Type className="w-3 h-3 text-slate-400" />}
{col.type === 'category' && <Split className="w-3 h-3 text-slate-400" />}
{col.type === 'date' && <Calendar className="w-3 h-3 text-slate-400" />}
{col.name}
</div>
<ChevronDown className="w-3 h-3 text-slate-300" />
</div>
))}
</div>
<div className="divide-y divide-slate-100 text-sm text-slate-700">
{MOCK_DATA.map((row, idx) => (
<div key={idx} className="flex hover:bg-slate-50 transition-colors">
{COLUMNS.map((col) => {
const val = row[col.id as keyof typeof row];
const isNull = val === null || val === '';
const isOutlier = col.id === 'age' && (val as number) > 120;
const isDirty = col.id === 'lab_val' && typeof val === 'string' && val.includes('<');
return (
<div
key={col.id}
className={`h-10 px-4 flex items-center border-r border-slate-100
${isNull ? 'bg-red-50' : ''}
${isOutlier ? 'bg-orange-100 text-orange-700 font-bold' : ''}
${isDirty ? 'text-purple-600 font-medium' : ''}
${selectedColId === col.id ? 'bg-emerald-50/30' : ''}
`}
style={{ width: col.id === 'id' ? '100px' : '150px' }}
>
{isNull ? <span className="text-[10px] text-red-400 italic">NULL</span> : val}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
{/* 4. Smart Sidebar (基于列类型的操作聚合) */}
{showSidebar && selectedCol && (
<div className="w-80 bg-white border-l border-slate-200 flex flex-col shadow-xl animate-in slide-in-from-right duration-300 absolute right-0 top-0 bottom-0 z-20">
{/* Header */}
<div className="p-4 border-b border-slate-100 bg-slate-50 flex justify-between items-start">
<div>
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1"></div>
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
{selectedCol.name}
<span className="px-2 py-0.5 bg-slate-200 text-slate-600 text-[10px] rounded-full font-mono">{selectedCol.id}</span>
</h3>
</div>
<button onClick={() => setShowSidebar(false)} className="text-slate-400 hover:text-slate-600">
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* A. 数值列操作 */}
{selectedCol.type === 'number' && (
<>
<div className="space-y-3">
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-slate-500" />
</h4>
{/* 图表 */}
<div className="h-24 flex items-end gap-1 px-2 border-b border-slate-200 pb-1">
{[10, 25, 45, 80, 50, 30, 15, 5, 2, 1].map((h, i) => (
<div key={i} className="flex-1 bg-emerald-200 hover:bg-emerald-400 transition-colors rounded-t-sm" style={{ height: `${h}%` }}></div>
))}
</div>
<div className="flex justify-between text-xs text-slate-400 px-1">
<span>Min: 34</span>
<span>Max: 205</span>
</div>
{/* 异常值卡片 */}
<div className="bg-orange-50 border border-orange-100 p-3 rounded-lg flex gap-3 items-start">
<AlertCircle className="w-4 h-4 text-orange-600 mt-0.5" />
<div>
<div className="text-xs font-bold text-orange-700"></div>
<div className="text-[10px] text-orange-600 mt-1"> <strong>205</strong> </div>
<button className="mt-2 text-xs bg-white border border-orange-200 text-orange-700 px-2 py-1 rounded hover:bg-orange-100">
</button>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-bold text-slate-800"></h4>
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
<Split className="w-4 h-4 text-emerald-600" />
<div>
<div className="font-medium"> (Binning)</div>
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">例如: 将年龄分为 //</div>
</div>
</button>
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
<Wand2 className="w-4 h-4 text-blue-600" />
<div>
<div className="font-medium"></div>
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">使</div>
</div>
</button>
</div>
</>
)}
{/* B. 分类/文本列操作 */}
{(selectedCol.type === 'category' || selectedCol.type === 'text') && (
<>
<div className="space-y-3">
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-slate-500" />
</h4>
<div className="space-y-2">
{['Male (50%)', 'Female (40%)', 'F (10%)'].map((label, i) => (
<div key={i} className="relative h-6 bg-slate-50 rounded overflow-hidden">
<div className="absolute top-0 left-0 h-full bg-emerald-100" style={{ width: label.includes('50') ? '50%' : label.includes('40') ? '40%' : '10%' }}></div>
<div className="absolute inset-0 flex items-center px-2 text-xs text-slate-700 justify-between">
<span>{label.split(' ')[0]}</span>
<span className="text-slate-400">{label.split(' ')[1]}</span>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-bold text-slate-800"></h4>
<button
className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group"
onClick={() => setShowModal('recode')}
>
<Settings className="w-4 h-4 text-blue-600" />
<div>
<div className="font-medium"> (Recode)</div>
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">例如: Male-&gt;1, Female-&gt;0</div>
</div>
</button>
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
<Wand2 className="w-4 h-4 text-purple-600" />
<div>
<div className="font-medium"> ()</div>
<div className="text-[10px] text-slate-400 group-hover:text-slate-500"></div>
</div>
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
{/* Modals (Mock) */}
{showModal === 'calc' && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in fade-in">
<div className="bg-white rounded-xl shadow-2xl w-[500px] overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-bold text-slate-900"> (Formula)</h3>
<button onClick={() => setShowModal(null)}><Trash2 className="w-4 h-4 text-slate-400 rotate-45" /></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 mb-1"></label>
<input type="text" className="w-full border p-2 rounded text-sm" placeholder="例如: BMI_Calc" defaultValue="New_Var" />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1"></label>
<div className="w-full h-24 border p-2 rounded bg-slate-50 font-mono text-sm text-slate-700">
[] / ([] / 100) ^ 2
</div>
<div className="flex gap-2 mt-2">
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">+ </button>
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">- </button>
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">* </button>
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">/ </button>
<button className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200">ln()</button>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg"></button>
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"></button>
</div>
</div>
</div>
)}
{/* Recode Modal */}
{showModal === 'recode' && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in fade-in">
<div className="bg-white rounded-xl shadow-2xl w-[400px] overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-bold text-slate-900"> (Recode)</h3>
<button onClick={() => setShowModal(null)}><Trash2 className="w-4 h-4 text-slate-400 rotate-45" /></button>
</div>
<div className="p-6">
<div className="text-xs text-slate-500 mb-4"></div>
<div className="space-y-2">
<div className="flex items-center gap-4">
<span className="w-20 text-sm font-medium text-right">Male</span>
<ArrowRight className="w-4 h-4 text-slate-300" />
<input type="text" className="w-20 border p-1 rounded text-center" defaultValue="1" />
</div>
<div className="flex items-center gap-4">
<span className="w-20 text-sm font-medium text-right">Female</span>
<ArrowRight className="w-4 h-4 text-slate-300" />
<input type="text" className="w-20 border p-1 rounded text-center" defaultValue="0" />
</div>
<div className="flex items-center gap-4">
<span className="w-20 text-sm font-medium text-right">F</span>
<ArrowRight className="w-4 h-4 text-slate-300" />
<input type="text" className="w-20 border p-1 rounded text-center" defaultValue="0" />
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg"></button>
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"></button>
</div>
</div>
</div>
)}
{/* 新增Pivot Modal (长宽转换) */}
{showModal === 'pivot' && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in fade-in">
<div className="bg-white rounded-xl shadow-2xl w-[600px] overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-blue-50/50">
<div className="flex items-center gap-2">
<ArrowLeftRight className="w-5 h-5 text-blue-600" />
<div>
<h3 className="font-bold text-slate-900"> (Pivot)</h3>
<p className="text-xs text-slate-500">"一人多行""一人一行"</p>
</div>
</div>
<button onClick={() => setShowModal(null)}><Trash2 className="w-4 h-4 text-slate-400 rotate-45" /></button>
</div>
<div className="p-6 grid grid-cols-3 gap-6">
{/* 列 1: 主键 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 block">1. ID? ()</label>
<select className="w-full p-2 border border-slate-300 rounded text-sm bg-white">
<option>ID</option>
<option></option>
</select>
<p className="text-[10px] text-slate-400">ID只保留一行</p>
</div>
{/* 列 2: 区分列 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 block">2. ? ()</label>
<select className="w-full p-2 border border-slate-300 rounded text-sm bg-white">
<option></option>
<option></option>
</select>
<p className="text-[10px] text-slate-400">生成列名如: 白细胞_20230101</p>
</div>
{/* 列 3: 值列 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 block">3. ? ()</label>
<div className="border border-slate-300 rounded p-2 h-32 overflow-y-auto bg-slate-50 text-sm space-y-1">
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span></span></label>
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span></span></label>
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>B超结果</span></label>
<label className="flex items-center gap-2"><input type="checkbox" className="rounded text-blue-600" /> <span> ()</span></label>
</div>
</div>
</div>
{/* 预览图示 */}
<div className="px-6 pb-6">
<div className="bg-slate-50 border border-slate-200 rounded p-3">
<div className="text-xs font-bold text-slate-500 mb-2">:</div>
<div className="flex items-center gap-4 text-xs">
<div className="border p-1 bg-white text-slate-400 rounded">
ID | | <br/>
P1 | T1 | A<br/>
P1 | T2 | B
</div>
<ArrowRight className="w-4 h-4 text-blue-500" />
<div className="border p-1 bg-white text-blue-600 font-medium rounded shadow-sm">
ID | _T1 | _T2<br/>
P1 | A | B
</div>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg"></button>
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-sm"></button>
</div>
</div>
</div>
)}
</div>
);
};
export default ToolC_EditorV2;