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,530 @@
/**
* 多指标转换面板组件
*
* 功能:
* - 方向1时间点为行指标为列统计分析格式
* - 方向2时间点为列指标为行展示格式
* - 自动检测多个指标并分组
*/
import React, { useState, useEffect } from 'react';
import { Form, Select, Button, Alert, Table, Spin, Divider, Space, Card, Tag, message, Radio } from 'antd';
const { Option } = Select;
interface MultiMetricPanelProps {
sessionId: string;
columns: string[];
onConfirm: (params: any) => void;
onCancel: () => void;
}
interface MetricGrouping {
success: boolean;
metric_groups?: Record<string, string[]>;
separator?: string;
timepoints?: string[];
confidence?: number;
message?: string;
}
export const MultiMetricPanel: React.FC<MultiMetricPanelProps> = ({
sessionId,
columns,
onConfirm,
onCancel,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [detecting, setDetecting] = useState(false);
const [grouping, setGrouping] = useState<MetricGrouping | null>(null);
const [previewData, setPreviewData] = useState<any[]>([]);
const [previewColumns, setPreviewColumns] = useState<any[]>([]);
const [direction, setDirection] = useState<'to_long' | 'to_matrix'>('to_long'); // 转换方向
// 选中的列变化时,自动检测分组
useEffect(() => {
const valueVars = form.getFieldValue('valueVars');
if (valueVars && valueVars.length >= 2) {
detectGrouping(valueVars);
} else {
setGrouping(null);
setPreviewData([]);
setPreviewColumns([]);
}
}, []);
/**
* 自动检测多指标分组
*/
const detectGrouping = async (valueVars: string[]) => {
if (!valueVars || valueVars.length < 2) {
setGrouping(null);
return;
}
setDetecting(true);
try {
console.log(`[MultiMetricPanel] 检测多指标分组: ${valueVars.length}`);
const response = await fetch('/api/v1/dc/tool-c/multi-metric/detect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
valueVars,
}),
});
const data = await response.json();
if (data.success) {
setGrouping(data.grouping);
console.log(`[MultiMetricPanel] 分组检测成功:`, data.grouping);
// 自动生成预览
generatePreview(
form.getFieldValue('idVars') || [],
valueVars,
data.grouping
);
} else {
console.error(`[MultiMetricPanel] 分组检测失败:`, data.error);
setGrouping({
success: false,
message: data.error || '分组检测失败'
});
}
} catch (error: any) {
console.error(`[MultiMetricPanel] 分组检测异常:`, error);
setGrouping({
success: false,
message: error.message || '分组检测异常'
});
} finally {
setDetecting(false);
}
};
/**
* 生成预览数据
*/
const generatePreview = async (idVars: string[], valueVars: string[], groupingData: MetricGrouping) => {
if (!groupingData.success || !idVars || idVars.length === 0) {
return;
}
setLoading(true);
try {
// 根据转换方向调用不同的API
const action = direction === 'to_long' ? 'multi_metric_to_long' : 'multi_metric_to_matrix';
const params = direction === 'to_long'
? {
idVars,
valueVars,
eventColName: form.getFieldValue('eventColName') || 'Event_Name',
}
: {
idVars,
valueVars,
metricColName: form.getFieldValue('metricColName') || '指标名',
};
// 调用preview API
const response = await fetch('/api/v1/dc/tool-c/quick-action/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
action,
params,
}),
});
const data = await response.json();
if (data.success) {
const resultData = data.data.newDataPreview || [];
if (resultData.length > 0) {
// 生成列定义
const cols = Object.keys(resultData[0]).map((key) => ({
title: key,
dataIndex: key,
key,
width: 150,
}));
setPreviewColumns(cols);
setPreviewData(resultData.slice(0, 10)); // 只显示前10行
}
} else {
console.error(`[MultiMetricPanel] 预览失败:`, data.error);
}
} catch (error: any) {
console.error(`[MultiMetricPanel] 预览异常:`, error);
} finally {
setLoading(false);
}
};
/**
* 处理表单提交
*/
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (!grouping || !grouping.success) {
message.error('请先检测指标分组');
return;
}
setLoading(true);
console.log(`[MultiMetricPanel] 提交多指标转换 (${direction}):`, values);
// 根据转换方向调用不同的API
const action = direction === 'to_long' ? 'multi_metric_to_long' : 'multi_metric_to_matrix';
const params = direction === 'to_long'
? {
idVars: values.idVars,
valueVars: values.valueVars,
eventColName: values.eventColName || 'Event_Name',
}
: {
idVars: values.idVars,
valueVars: values.valueVars,
metricColName: values.metricColName || '指标名',
};
// 调用快速操作API执行转换
const response = await fetch('/api/v1/dc/tool-c/quick-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
action,
params,
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '多指标转换失败');
}
const successMsg = direction === 'to_long' ? '多指标转长表成功!' : '多指标转矩阵成功!';
message.success(successMsg);
// 更新父组件数据
if (result.data?.newDataPreview) {
onConfirm(result.data.newDataPreview);
}
// 成功后关闭
onCancel();
} catch (error: any) {
console.error(`[MultiMetricPanel] 转换失败:`, error);
message.error(error.message || '执行失败');
} finally {
setLoading(false);
}
};
/**
* 处理值列变化
*/
const handleValueVarsChange = (valueVars: string[]) => {
form.setFieldsValue({ valueVars });
detectGrouping(valueVars);
};
/**
* 处理ID列变化
*/
const handleIdVarsChange = (idVars: string[]) => {
form.setFieldsValue({ idVars });
// 重新生成预览
const valueVars = form.getFieldValue('valueVars');
if (grouping && grouping.success && valueVars && valueVars.length >= 2) {
generatePreview(idVars, valueVars, grouping);
}
};
return (
<div>
<Form
form={form}
layout="vertical"
initialValues={{
idVars: [],
valueVars: [],
eventColName: 'Event_Name',
metricColName: '指标名',
}}
>
{/* 0. 转换方向 */}
<Card size="small" style={{ marginBottom: 16, backgroundColor: '#f0f5ff' }}>
<div style={{ marginBottom: 8 }}>
<strong>📍 </strong>
</div>
<Radio.Group
value={direction}
onChange={(e) => {
setDirection(e.target.value);
// 重新生成预览
const idVars = form.getFieldValue('idVars');
const valueVars = form.getFieldValue('valueVars');
if (grouping && grouping.success && idVars && valueVars && valueVars.length >= 2) {
generatePreview(idVars, valueVars, grouping);
}
}}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="to_long">
<div>
<strong></strong> <Tag color="blue"></Tag>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
GEE
</div>
</div>
</Radio>
<Radio value="to_matrix">
<div>
<strong></strong> <Tag color="green"></Tag>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
CRF核对
</div>
</div>
</Radio>
</Space>
</Radio.Group>
</Card>
{/* 1. 选择ID列 */}
<Form.Item
label="1⃣ 选择ID列"
name="idVars"
rules={[{ required: true, message: '请选择至少1个ID列' }]}
>
<Select
mode="multiple"
placeholder="选择保持不变的列Record_ID、Subject_ID"
onChange={handleIdVarsChange}
showSearch
filterOption={(input, option) =>
String(option?.label || option?.value || '').toLowerCase().includes(input.toLowerCase())
}
>
{columns.map((col) => (
<Option key={col} value={col}>
{col}
</Option>
))}
</Select>
</Form.Item>
{/* 2. 选择值列 */}
<Form.Item
label={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>2 </span>
<Space size="small">
<Button
type="link"
size="small"
onClick={() => {
const availableCols = columns.filter((col) => !form.getFieldValue('idVars')?.includes(col));
form.setFieldsValue({ valueVars: availableCols });
handleValueVarsChange(availableCols);
}}
style={{ padding: 0, height: 'auto' }}
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
form.setFieldsValue({ valueVars: [] });
handleValueVarsChange([]);
}}
style={{ padding: 0, height: 'auto' }}
>
</Button>
</Space>
</div>
}
name="valueVars"
rules={[
{ required: true, message: '请选择至少2个值列' },
{
validator: (_, value) => {
if (value && value.length >= 2) {
return Promise.resolve();
}
return Promise.reject(new Error('至少需要选择2列才能进行转换'));
},
},
]}
extra="选择多个指标的多个时间点列例如FMA总得分_基线、FMA总得分_随访1、ADL总分_基线、ADL总分_随访1"
>
<Select
mode="multiple"
placeholder="选择多个指标的多个时间点列"
onChange={handleValueVarsChange}
showSearch
filterOption={(input, option) =>
String(option?.label || option?.value || '').toLowerCase().includes(input.toLowerCase())
}
loading={detecting}
>
{columns
.filter((col) => !form.getFieldValue('idVars')?.includes(col))
.map((col) => (
<Option key={col} value={col}>
{col}
</Option>
))}
</Select>
</Form.Item>
{/* 3. 自动检测结果 */}
{detecting && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Spin tip="正在检测指标分组..." />
</div>
)}
{!detecting && grouping && (
<Card
title="3⃣ 自动检测结果"
size="small"
style={{ marginBottom: 16 }}
type={grouping.success ? 'inner' : 'inner'}
>
{grouping.success ? (
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<strong> {Object.keys(grouping.metric_groups || {}).length} </strong>
<div style={{ marginTop: 8 }}>
{Object.entries(grouping.metric_groups || {}).map(([metric, cols]) => (
<Tag key={metric} color="blue" style={{ marginBottom: 4 }}>
{metric} ({cols.length})
</Tag>
))}
</div>
</div>
<div>
<strong> {grouping.timepoints?.length || 0} </strong>
<div style={{ marginTop: 8 }}>
{grouping.timepoints?.slice(0, 5).map((tp) => (
<Tag key={tp} color="green" style={{ marginBottom: 4 }}>
{tp}
</Tag>
))}
{(grouping.timepoints?.length || 0) > 5 && (
<Tag color="default">... {(grouping.timepoints?.length || 0) - 5} </Tag>
)}
</div>
</div>
<div>
<strong> </strong>
<Tag color="orange">{grouping.separator || '(无)'}</Tag>
</div>
{grouping.confidence !== undefined && grouping.confidence < 1.0 && (
<Alert
message="提示"
description={grouping.message || '检测置信度较低,请检查选中的列是否正确'}
type="warning"
showIcon
/>
)}
</Space>
) : (
<Alert
message="检测失败"
description={grouping.message || '未能检测到有效的指标分组'}
type="error"
showIcon
/>
)}
</Card>
)}
{/* 4. 列名设置 */}
{direction === 'to_long' ? (
<Form.Item
label="4⃣ 时间点列名"
name="eventColName"
rules={[{ required: true, message: '请输入时间点列名' }]}
>
<Select placeholder="选择或输入时间点列名">
<Option value="Event_Name">Event_Name</Option>
<Option value="时间点"></Option>
<Option value="Timepoint">Timepoint</Option>
<Option value="Visit">Visit</Option>
</Select>
</Form.Item>
) : (
<Form.Item
label="4⃣ 指标列名"
name="metricColName"
rules={[{ required: true, message: '请输入指标列名' }]}
>
<Select placeholder="选择或输入指标列名">
<Option value="指标名"></Option>
<Option value="Metric">Metric</Option>
<Option value="指标"></Option>
<Option value="Variable">Variable</Option>
</Select>
</Form.Item>
)}
{/* 5. 预览结果 */}
{previewData.length > 0 && (
<>
<Divider>10</Divider>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
<Table
dataSource={previewData}
columns={previewColumns}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
loading={loading}
bordered
/>
</div>
</>
)}
</Form>
{/* 操作按钮 */}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Space>
<Button onClick={onCancel}></Button>
<Button
type="primary"
onClick={handleSubmit}
disabled={!grouping || !grouping.success}
loading={loading}
>
</Button>
</Space>
</div>
</div>
);
};