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,401 @@
/**
* 指标-时间表转换面板
*
* 将多个时间点列转换为"指标行+时间点列"格式
* 典型场景制作临床研究Table 1横向对比同一指标的时间变化
*/
import React, { useState, useEffect } from 'react';
import { Button, Alert, Checkbox, App, Input, Spin, Tag } from 'antd';
import { ArrowLeftRight, Info, Sparkles } from 'lucide-react';
interface Props {
columns: Array<{ id: string; name: string }>;
sessionId: string | null;
onApply: (newData: any[]) => void;
onClose: () => void;
}
interface DetectedPattern {
common_prefix: string;
separator: string;
timepoints: string[];
confidence: number;
message: string;
}
const MetricTimePanel: React.FC<Props> = ({
columns,
sessionId,
onApply,
onClose,
}) => {
const { message } = App.useApp();
const [idVars, setIdVars] = useState<string[]>([]);
const [valueVars, setValueVars] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [detecting, setDetecting] = useState(false);
// 检测结果
const [pattern, setPattern] = useState<DetectedPattern | null>(null);
const [metricName, setMetricName] = useState('');
const [separator, setSeparator] = useState('');
const [timepointColName, setTimepointColName] = useState('时间点');
// 当值列变化时,自动检测模式
useEffect(() => {
if (valueVars.length >= 2) {
detectPattern();
} else {
setPattern(null);
setMetricName('');
setSeparator('');
}
}, [valueVars]);
// 自动检测模式
const detectPattern = async () => {
setDetecting(true);
try {
const response = await fetch('/api/v1/dc/tool-c/metric-time/detect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
valueVars,
}),
});
const result = await response.json();
if (result.success && result.pattern) {
setPattern(result.pattern);
setMetricName(result.pattern.common_prefix || '');
setSeparator(result.pattern.separator || '');
if (result.pattern.confidence >= 0.8) {
message.success(`自动检测成功!置信度: ${(result.pattern.confidence * 100).toFixed(0)}%`);
} else if (result.pattern.confidence >= 0.5) {
message.warning(`检测成功但置信度较低 (${(result.pattern.confidence * 100).toFixed(0)}%),建议检查结果`);
} else {
message.warning('检测置信度较低,建议手动调整参数');
}
} else {
message.error(result.error || '模式检测失败');
}
} catch (error: any) {
message.error('检测失败:' + error.message);
} finally {
setDetecting(false);
}
};
// 执行转换
const handleApply = async () => {
if (!sessionId) {
message.error('Session ID不存在');
return;
}
// 验证
if (idVars.length === 0) {
message.warning('请至少选择1个ID列');
return;
}
if (valueVars.length < 2) {
message.warning('请至少选择2个值列');
return;
}
// 验证ID列和值列不能重复
const overlap = idVars.filter(id => valueVars.includes(id));
if (overlap.length > 0) {
message.error(`ID列和值列不能重复${overlap.join(', ')}`);
return;
}
if (!metricName.trim()) {
message.warning('请输入指标名称');
return;
}
if (!timepointColName.trim()) {
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: 'metric_time',
params: {
idVars,
valueVars,
metricName,
separator: separator || undefined,
timepointColName,
},
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '指标-时间表转换失败');
}
message.success('指标-时间表转换成功!');
// 更新父组件数据
if (result.data?.newDataPreview) {
onApply(result.data.newDataPreview);
}
// 成功后关闭
onClose();
} catch (error: any) {
message.error(error.message || '执行失败');
} finally {
setLoading(false);
}
};
// 获取时间点预览从pattern中
const timepoints = pattern?.timepoints || [];
const timepointsSample = timepoints.slice(0, 5);
const hasMoreTimepoints = timepoints.length > 5;
return (
<div className="space-y-4 pb-4">
{/* 说明 */}
<Alert
description={
<div className="text-xs space-y-1">
<div> "指标行+时间点列"</div>
<div> Table 1</div>
<div> FMA___基线FMA___2周 FMA+ 线 + 2</div>
</div>
}
type="info"
showIcon
icon={<Info size={16} />}
className="mb-4"
/>
{/* 第1步ID列 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
1ID列<span className="text-red-500">*</span>
</label>
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto bg-slate-50">
<Checkbox.Group
value={idVars}
onChange={setIdVars}
className="flex flex-col gap-2"
>
{columns.map((col) => (
<Checkbox key={col.id} value={col.id} disabled={valueVars.includes(col.id)}>
{col.name}
</Checkbox>
))}
</Checkbox.Group>
</div>
<div className="text-xs text-slate-500 mt-1">
Record_ID
</div>
</div>
{/* 第2步值列 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
2<span className="text-red-500">*</span>
</label>
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto bg-slate-50">
<Checkbox.Group
value={valueVars}
onChange={setValueVars}
className="flex flex-col gap-2"
>
{columns.map((col) => (
<Checkbox key={col.id} value={col.id} disabled={idVars.includes(col.id)}>
{col.name}
</Checkbox>
))}
</Checkbox.Group>
</div>
<div className="text-xs text-slate-500 mt-1">
2 {valueVars.length}
</div>
</div>
{/* 第3步自动检测结果 */}
{valueVars.length >= 2 && (
<div className="border-t pt-4">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={16} className="text-purple-500" />
<span className="text-sm font-medium text-slate-700">3</span>
{detecting && <Spin size="small" />}
</div>
{pattern ? (
<div className="space-y-3">
{/* 检测结果 */}
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-start justify-between mb-2">
<div className="text-sm font-medium text-green-800">
</div>
<Tag color={pattern.confidence >= 0.8 ? 'green' : pattern.confidence >= 0.5 ? 'orange' : 'red'}>
: {(pattern.confidence * 100).toFixed(0)}%
</Tag>
</div>
<div className="text-xs text-green-700 space-y-1">
<div> {timepoints.length} </div>
<div> {pattern.message}</div>
</div>
</div>
{/* 指标名称(可编辑) */}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
<span className="text-red-500">*</span>
</label>
<Input
placeholder="如FMA总得分"
value={metricName}
onChange={(e) => setMetricName(e.target.value)}
maxLength={100}
suffix={
<span className="text-xs text-slate-400">🖊 </span>
}
/>
<div className="text-xs text-slate-500 mt-1">
</div>
</div>
{/* 分隔符(可编辑) */}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
</label>
<Input
placeholder="如___"
value={separator}
onChange={(e) => setSeparator(e.target.value)}
maxLength={10}
suffix={
<span className="text-xs text-slate-400">
{separator ? `"${separator}"` : '无'}
</span>
}
/>
<div className="text-xs text-slate-500 mt-1">
{separator ? `"${separator}"` : '未检测到'}
</div>
</div>
{/* 时间点列名 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
<span className="text-red-500">*</span>
</label>
<Input
placeholder="如:时间点"
value={timepointColName}
onChange={(e) => setTimepointColName(e.target.value)}
maxLength={30}
/>
<div className="text-xs text-slate-500 mt-1">
</div>
</div>
{/* 时间点预览 */}
<div className="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="text-xs font-medium text-slate-700 mb-2">
{timepoints.length}
</div>
<div className="flex flex-wrap gap-1">
{timepointsSample.map((tp, idx) => (
<Tag key={idx} color="blue">
{tp}
</Tag>
))}
{hasMoreTimepoints && (
<Tag color="default">
... {timepoints.length - 5}
</Tag>
)}
</div>
</div>
</div>
) : (
<div className="p-3 bg-slate-50 rounded-lg text-center text-sm text-slate-500">
...
</div>
)}
</div>
)}
{/* 转换结果预览 */}
{pattern && valueVars.length >= 2 && (
<Alert
description={
<div className="text-xs space-y-1">
<div className="font-medium mb-1"></div>
<div className="pl-2 space-y-0.5">
<div> "{timepointColName}"{metricName}</div>
<div> {valueVars.length} {timepoints.length} </div>
<div> {timepointsSample.join(', ')}{hasMoreTimepoints ? '...' : ''}</div>
</div>
</div>
}
type="success"
showIcon={false}
className="bg-green-50 border-green-200"
/>
)}
{/* 警告 */}
<Alert
description={
<div className="text-xs space-y-1">
<div> </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}
disabled={!pattern || valueVars.length < 2 || idVars.length === 0}
icon={<ArrowLeftRight size={16} />}
>
</Button>
</div>
</div>
);
};
export default MetricTimePanel;