feat(dc): Add multi-metric transformation feature (direction 1+2)

Summary:
- Implement intelligent multi-metric grouping detection algorithm
- Add direction 1: timepoint-as-row, metric-as-column (analysis format)
- Add direction 2: timepoint-as-column, metric-as-row (display format)
- Fix column name pattern detection (FMA___ issue)
- Maintain original Record ID order in output
- Add full-select/clear buttons in UI
- Integrate into TransformDialog with Radio selection
- Update 3 documentation files

Technical Details:
- Python: detect_metric_groups(), apply_multi_metric_to_long(), apply_multi_metric_to_matrix()
- Backend: 3 new methods in QuickActionService
- Frontend: MultiMetricPanel.tsx (531 lines)
- Total: ~1460 lines of new code

Status: Fully tested and verified, ready for production
This commit is contained in:
2025-12-21 15:06:15 +08:00
parent 8be8cdcf53
commit 9b81aef9a7
123 changed files with 4781 additions and 150 deletions

View File

@@ -0,0 +1,287 @@
/**
* Pivot面板长表→宽表
* 从PivotDialog.tsx提取作为TransformDialog的子组件
*/
import React, { useState } from 'react';
import { Select, Button, Alert, Checkbox, Radio, App } from 'antd';
import { ArrowLeftRight, Info } from 'lucide-react';
interface Props {
columns: Array<{ id: string; name: string }>;
sessionId: string | null;
onApply: (newData: any[]) => void;
onClose: () => void;
}
const PivotPanel: React.FC<Props> = ({
columns,
sessionId,
onApply,
onClose,
}) => {
const { message } = App.useApp();
const [indexColumn, setIndexColumn] = useState<string>('');
const [pivotColumn, setPivotColumn] = useState<string>('');
const [valueColumns, setValueColumns] = useState<string[]>([]);
const [aggfunc, setAggfunc] = useState<'first' | 'last' | 'mean' | 'sum'>('first');
const [loading, setLoading] = useState(false);
const [keepUnusedColumns, setKeepUnusedColumns] = useState(false);
const [unusedAggMethod, setUnusedAggMethod] = useState<'first' | 'mode' | 'mean'>('first');
// 执行
const handleApply = async () => {
if (!sessionId) {
message.error('Session ID不存在');
return;
}
// 验证
if (!indexColumn) {
message.warning('请选择索引列(唯一标识列)');
return;
}
if (!pivotColumn) {
message.warning('请选择透视列(要变成列名的列)');
return;
}
if (valueColumns.length === 0) {
message.warning('请至少选择一个值列');
return;
}
setLoading(true);
try {
const response = await fetch('/api/v1/dc/tool-c/quick-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
action: 'pivot',
params: {
indexColumn,
pivotColumn,
valueColumns,
aggfunc,
keepUnusedColumns,
unusedAggMethod,
},
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Pivot转换失败');
}
message.success('Pivot转换成功');
// 更新父组件数据
if (result.data?.newDataPreview) {
onApply(result.data.newDataPreview);
}
// 成功后关闭
onClose();
} catch (error: any) {
message.error(error.message || '执行失败');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4 pb-4">
{/* 说明 */}
<Alert
description={
<div className="text-xs space-y-1">
<div> "一人多行""一人一行"</div>
<div> 访线访2访1</div>
<div> Event_Name列的"基线""随访2周" FMA_基线FMA_随访2周</div>
</div>
}
type="info"
showIcon
icon={<Info size={16} />}
className="mb-4"
/>
{/* 索引列 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
ID<span className="text-red-500">*</span>
</label>
<Select
placeholder="选择唯一标识列"
value={indexColumn || undefined}
onChange={setIndexColumn}
className="w-full"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={columns.map(col => ({ label: col.name, value: col.id }))}
/>
<div className="text-xs text-slate-500 mt-1">
</div>
</div>
{/* 透视列 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
访<span className="text-red-500">*</span>
</label>
<Select
placeholder="选择透视列"
value={pivotColumn || undefined}
onChange={setPivotColumn}
className="w-full"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={columns
.filter(col => col.id !== indexColumn)
.map(col => ({ label: col.name, value: col.id }))}
/>
<div className="text-xs text-slate-500 mt-1">
</div>
</div>
{/* 值列 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
<span className="text-red-500">*</span>
</label>
<div className="border rounded-lg p-3 max-h-48 overflow-y-auto bg-slate-50">
<Checkbox.Group
value={valueColumns}
onChange={setValueColumns}
className="flex flex-col gap-2"
>
{columns
.filter(col => col.id !== indexColumn && col.id !== pivotColumn)
.map((col) => (
<Checkbox key={col.id} value={col.id}>
{col.name}
</Checkbox>
))}
</Checkbox.Group>
</div>
<div className="text-xs text-slate-500 mt-1">
FMA_基线FMA_随访2周
</div>
</div>
{/* 聚合方式 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
</label>
<Radio.Group value={aggfunc} onChange={(e) => setAggfunc(e.target.value)}>
<div className="space-y-2">
<Radio value="first">
<div className="ml-2">
<span className="font-medium"></span>
<span className="text-xs text-slate-500 ml-2"></span>
</div>
</Radio>
<Radio value="last">
<span className="ml-2 font-medium"></span>
</Radio>
<Radio value="mean">
<span className="ml-2 font-medium"></span>
</Radio>
<Radio value="sum">
<span className="ml-2 font-medium"></span>
</Radio>
</div>
</Radio.Group>
</div>
{/* 高级选项 */}
<div className="border-t pt-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium text-slate-700"> </span>
</div>
<Checkbox
checked={keepUnusedColumns}
onChange={(e) => setKeepUnusedColumns(e.target.checked)}
>
<span className="text-sm font-medium"></span>
</Checkbox>
{keepUnusedColumns && (
<div className="ml-6 mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200">
<label className="text-sm font-medium text-slate-700 mb-2 block">
</label>
<Radio.Group
value={unusedAggMethod}
onChange={(e) => setUnusedAggMethod(e.target.value)}
>
<div className="space-y-2">
<Radio value="first">
<div className="ml-2">
<span className="font-medium text-sm"></span>
<span className="text-xs text-slate-500 ml-2"></span>
</div>
</Radio>
<Radio value="mode">
<span className="ml-2 font-medium text-sm"></span>
</Radio>
<Radio value="mean">
<div className="ml-2">
<span className="font-medium text-sm"></span>
<span className="text-xs text-slate-500 ml-2"></span>
</div>
</Radio>
</div>
</Radio.Group>
</div>
)}
</div>
{/* 警告 */}
<Alert
description={
<div className="text-xs space-y-1">
<div> Pivot操作会显著改变数据结构</div>
<div> </div>
</div>
}
type="warning"
showIcon={false}
className="bg-orange-50 border-orange-200"
/>
{/* 底部按钮 */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button onClick={onClose}>
</Button>
<Button
type="primary"
onClick={handleApply}
loading={loading}
icon={<ArrowLeftRight size={16} />}
>
</Button>
</div>
</div>
);
};
export default PivotPanel;