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

@@ -519,6 +519,12 @@ export default FulltextDetailDrawer;

View File

@@ -118,6 +118,12 @@ export function useFulltextResults({

View File

@@ -81,6 +81,12 @@ export function useFulltextTask({

View File

@@ -472,6 +472,12 @@ export default FulltextResults;

View File

@@ -116,6 +116,12 @@ export const useAssets = (activeTab: AssetTabType) => {

View File

@@ -106,6 +106,12 @@ export const useRecentTasks = () => {

View File

@@ -345,3 +345,9 @@ export default BinningDialog;

View File

@@ -308,3 +308,9 @@ export default DropnaDialog;

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;

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>
);
};

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;

View File

@@ -131,7 +131,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
{/* 高级按钮Phase 3 */}
<ToolbarButton
icon={ArrowLeftRight}
label="长宽表"
label="长宽表"
colorClass="text-indigo-600 bg-indigo-50 hover:bg-indigo-100"
onClick={onPivotClick}
disabled={!sessionId}

View File

@@ -0,0 +1,111 @@
/**
* 数据表转换对话框
*
* 功能:
* - Tab 1: 长→宽表Pivot
* - Tab 2: 宽→长表Unpivot- 基础功能
* - Tab 3: 多指标转换 - 医学研究专用,支持多指标重复测量数据转换
*/
import React, { useState } from 'react';
import { Modal, Tabs } from 'antd';
import { ArrowLeftRight, Sparkles, BarChart3 } from 'lucide-react';
import PivotPanel from './PivotPanel';
import UnpivotPanel from './UnpivotPanel';
import { MultiMetricPanel } from './MultiMetricPanel';
interface Props {
visible: boolean;
onClose: () => void;
onApply: (newData: any[]) => void;
columns: Array<{ id: string; name: string }>;
sessionId: string | null;
}
const TransformDialog: React.FC<Props> = ({
visible,
onClose,
onApply,
columns,
sessionId,
}) => {
const [activeTab, setActiveTab] = useState<'pivot' | 'unpivot' | 'multi_metric'>('pivot');
return (
<Modal
title={
<div className="flex items-center gap-2">
<ArrowLeftRight size={20} className="text-purple-500" />
<span></span>
</div>
}
open={visible}
onCancel={onClose}
width={750}
footer={null}
destroyOnClose
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as any)}
items={[
{
key: 'pivot',
label: (
<span className="flex items-center gap-2">
<ArrowLeftRight size={16} />
<span> (Pivot)</span>
</span>
),
children: (
<PivotPanel
columns={columns}
sessionId={sessionId}
onApply={onApply}
onClose={onClose}
/>
),
},
{
key: 'unpivot',
label: (
<span className="flex items-center gap-2">
<ArrowLeftRight size={16} className="rotate-90" />
<span> (Unpivot)</span>
</span>
),
children: (
<UnpivotPanel
columns={columns}
sessionId={sessionId}
onApply={onApply}
onClose={onClose}
/>
),
},
{
key: 'multi_metric',
label: (
<span className="flex items-center gap-2">
<BarChart3 size={16} className="text-blue-500" />
<span></span>
</span>
),
children: (
<MultiMetricPanel
sessionId={sessionId || ''}
columns={columns.map(c => c.name)}
onConfirm={onApply}
onCancel={onClose}
/>
),
},
]}
/>
</Modal>
);
};
export default TransformDialog;

View File

@@ -0,0 +1,392 @@
/**
* Unpivot面板宽表→长表
* 新功能:将横向数据转为纵向重复数据
*/
import React, { useState } from 'react';
import { Select, Button, Alert, Checkbox, App, Input, Collapse, Radio } 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 UnpivotPanel: React.FC<Props> = ({
columns,
sessionId,
onApply,
onClose,
}) => {
const { message } = App.useApp();
const [idVars, setIdVars] = useState<string[]>([]);
const [valueVars, setValueVars] = useState<string[]>([]);
const [varName, setVarName] = useState('时间点');
const [valueName, setValueName] = useState('测量值');
const [loading, setLoading] = useState(false);
// 高级选项
const [parseColumnNames, setParseColumnNames] = useState(false);
const [separator, setSeparator] = useState('_');
const [metricName, setMetricName] = useState('指标');
const [timeName, setTimeName] = useState('时间点');
// 执行
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;
}
if (!varName.trim()) {
message.warning('请输入变量名列名');
return;
}
if (!valueName.trim()) {
message.warning('请输入值列名');
return;
}
// 验证ID列和值列不能重复
const overlap = idVars.filter(id => valueVars.includes(id));
if (overlap.length > 0) {
message.error(`ID列和值列不能重复${overlap.join(', ')}`);
return;
}
// 高级选项验证
if (parseColumnNames) {
if (!separator.trim()) {
message.warning('请输入分隔符');
return;
}
if (!metricName.trim()) {
message.warning('请输入指标列名');
return;
}
if (!timeName.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: 'unpivot',
params: {
idVars,
valueVars,
varName,
valueName,
parseColumnNames,
separator,
metricName: parseColumnNames ? metricName : undefined,
timeName: parseColumnNames ? timeName : undefined,
dropna: false,
},
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unpivot转换失败');
}
message.success('Unpivot转换成功');
// 更新父组件数据
if (result.data?.newDataPreview) {
onApply(result.data.newDataPreview);
}
// 成功后关闭
onClose();
} catch (error: any) {
message.error(error.message || '执行失败');
} finally {
setLoading(false);
}
};
// 示例数据(用于说明)
const selectedValueVarsSample = valueVars.slice(0, 3);
const hasMoreValueVars = valueVars.length > 3;
return (
<div className="space-y-4 pb-4">
{/* 说明 */}
<Alert
description={
<div className="text-xs space-y-1">
<div> "一人一行""一人多行"</div>
<div> 访FMA_基线FMA_2周 + FMA值列</div>
<div> FMA_基线FMA_随访2周 Event_Name列线访2+ FMA列</div>
</div>
}
type="info"
showIcon
icon={<Info size={16} />}
className="mb-4"
/>
{/* ID列 */}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
ID列<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={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">
ID
</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={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>
{/* 列名设置 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
<span className="text-red-500">*</span>
</label>
<Input
placeholder="如:时间点"
value={varName}
onChange={(e) => setVarName(e.target.value)}
maxLength={30}
/>
<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>
<Input
placeholder="如:测量值"
value={valueName}
onChange={(e) => setValueName(e.target.value)}
maxLength={30}
/>
<div className="text-xs text-slate-500 mt-1">
</div>
</div>
</div>
{/* 预览示例 */}
{valueVars.length > 0 && (
<Alert
description={
<div className="text-xs space-y-1">
<div className="font-medium mb-1"></div>
<div className="pl-2">
{selectedValueVarsSample.map((varId) => {
const col = columns.find(c => c.id === varId);
return (
<div key={varId} className="text-slate-600">
"{col?.name}" {varName}="{col?.name}", {valueName}=
</div>
);
})}
{hasMoreValueVars && (
<div className="text-slate-500">... {valueVars.length - 3} </div>
)}
</div>
</div>
}
type="success"
showIcon={false}
className="bg-green-50 border-green-200"
/>
)}
{/* 高级选项:列名解析 */}
<Collapse
ghost
items={[
{
key: '1',
label: (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700"> </span>
{parseColumnNames && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded"></span>
)}
</div>
),
children: (
<div className="space-y-4 pt-2">
<Checkbox
checked={parseColumnNames}
onChange={(e) => setParseColumnNames(e.target.checked)}
>
<span className="text-sm font-medium"></span>
</Checkbox>
{parseColumnNames && (
<>
<Alert
description={
<div className="text-xs">
"FMA_基线""FMA" + "基线"
</div>
}
type="info"
showIcon={false}
className="bg-blue-50 border-blue-200"
/>
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
</label>
<Radio.Group value={separator} onChange={(e) => setSeparator(e.target.value)}>
<Radio value="_">线_</Radio>
<Radio value="-">-</Radio>
<Radio value=".">.</Radio>
</Radio.Group>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
</label>
<Input
placeholder="如:指标"
value={metricName}
onChange={(e) => setMetricName(e.target.value)}
maxLength={30}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
</label>
<Input
placeholder="如:时间点"
value={timeName}
onChange={(e) => setTimeName(e.target.value)}
maxLength={30}
/>
</div>
</div>
{/* 解析示例 */}
{valueVars.length > 0 && (
<div className="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="text-xs font-medium text-slate-700 mb-2"></div>
{selectedValueVarsSample.map((varId) => {
const col = columns.find(c => c.id === varId);
const parts = col?.name.split(separator);
const metric = parts?.[0] || col?.name;
const time = parts?.slice(1).join(separator) || '';
return (
<div key={varId} className="text-xs text-slate-600 mb-1">
"{col?.name}" {metricName}="{metric}", {timeName}="{time}"
</div>
);
})}
</div>
)}
</>
)}
</div>
),
},
]}
/>
{/* 警告 */}
<Alert
description={
<div className="text-xs space-y-1">
<div> Unpivot操作会扩展数据 = × </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} className="rotate-90" />}
>
</Button>
</div>
</div>
);
};
export default UnpivotPanel;

View File

@@ -16,7 +16,7 @@ import BinningDialog from './components/BinningDialog';
import ConditionalDialog from './components/ConditionalDialog';
import MissingValueDialog from './components/MissingValueDialog';
import ComputeDialog from './components/ComputeDialog';
import PivotDialog from './components/PivotDialog';
import TransformDialog from './components/TransformDialog';
import * as api from '../../api/toolC';
// ==================== 类型定义 ====================
@@ -341,7 +341,7 @@ const ToolC = () => {
onApply={handleQuickActionDataUpdate}
/>
<PivotDialog
<TransformDialog
visible={state.pivotDialogVisible}
columns={state.columns}
sessionId={state.sessionId}

View File

@@ -71,3 +71,9 @@ export interface DataStats {

View File

@@ -64,6 +64,12 @@ export type AssetTabType = 'all' | 'processed' | 'raw';

View File

@@ -22,3 +22,9 @@ export { default as Placeholder } from './Placeholder';