Files
AIclinicalresearch/frontend-v2/src/modules/dc/pages/tool-c/components/RecodeDialog.tsx
HaHafeng 91cab452d1 fix(dc/tool-c): Fix special character handling and improve UX
Major fixes:
- Fix pivot transformation with special characters in column names
- Fix compute column validation for Chinese punctuation
- Fix recode dialog to fetch unique values from full dataset via new API
- Add column mapping mechanism to handle special characters

Database migration:
- Add column_mapping field to dc_tool_c_sessions table
- Migration file: 20251208_add_column_mapping

UX improvements:
- Darken table grid lines for better visibility
- Reduce column width by 40% with tooltip support
- Insert new columns next to source columns
- Preserve original row order after operations
- Add notice about 50-row preview limit

Modified files:
- Backend: SessionService, SessionController, QuickActionService, routes
- Python: pivot.py, compute.py, recode.py, binning.py, conditional.py
- Frontend: DataGrid, RecodeDialog, index.tsx, ag-grid-custom.css
- Database: schema.prisma, migration SQL

Status: Code complete, database migrated, ready for testing
2025-12-08 23:20:55 +08:00

303 lines
8.6 KiB
TypeScript
Raw 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, useEffect } from 'react';
import { Modal, Select, Input, Button, Checkbox, Table, Spin, App } from 'antd';
interface RecodeDialogProps {
visible: boolean;
columns: Array<{ id: string; name: string }>;
data: any[];
sessionId: string | null;
onClose: () => void;
onApply: (newData: any[]) => void;
}
interface MappingRow {
originalValue: any;
newValue: string;
}
const RecodeDialog: React.FC<RecodeDialogProps> = ({
visible,
columns,
data,
sessionId,
onClose,
onApply,
}) => {
const { message } = App.useApp();
const [selectedColumn, setSelectedColumn] = useState<string>('');
const [uniqueValues, setUniqueValues] = useState<any[]>([]);
const [mappingTable, setMappingTable] = useState<MappingRow[]>([]);
const [createNewColumn, setCreateNewColumn] = useState(true);
const [newColumnName, setNewColumnName] = useState('');
const [loading, setLoading] = useState(false);
const [extracting, setExtracting] = useState(false);
// 当选择列时,从后端获取唯一值
useEffect(() => {
if (!selectedColumn || !sessionId) {
setUniqueValues([]);
setMappingTable([]);
return;
}
const fetchUniqueValues = async () => {
setExtracting(true);
try {
// ✨ 调用后端API获取唯一值从完整数据中提取不受前端50行限制
const response = await fetch(
`/api/v1/dc/tool-c/sessions/${sessionId}/unique-values?column=${encodeURIComponent(selectedColumn)}`
);
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '获取唯一值失败');
}
const unique = result.data.uniqueValues;
setUniqueValues(unique);
// 初始化映射表
const initialMapping = unique.map((val: any) => ({
originalValue: val,
newValue: '',
}));
setMappingTable(initialMapping);
// 生成默认新列名
setNewColumnName(`${selectedColumn}_编码`);
} catch (error: any) {
console.error('[RecodeDialog] 获取唯一值失败:', error);
message.error(error.message || '获取唯一值失败');
setUniqueValues([]);
setMappingTable([]);
} finally {
setExtracting(false);
}
};
fetchUniqueValues();
}, [selectedColumn, sessionId, message]);
// 更新映射值
const updateMapping = (originalValue: any, newValue: string) => {
setMappingTable(
mappingTable.map((row) =>
row.originalValue === originalValue ? { ...row, newValue } : row
)
);
};
// 表格列定义
const tableColumns = [
{
title: '原值',
dataIndex: 'originalValue',
key: 'originalValue',
width: '45%',
render: (value: any) => (
<span className="font-mono text-sm">{String(value)}</span>
),
},
{
title: '→',
key: 'arrow',
width: '10%',
align: 'center' as const,
render: () => <span className="text-slate-400"></span>,
},
{
title: '新值',
dataIndex: 'newValue',
key: 'newValue',
width: '45%',
render: (_: any, record: MappingRow) => (
<Input
placeholder="输入新值"
value={record.newValue}
onChange={(e) => updateMapping(record.originalValue, e.target.value)}
size="small"
/>
),
},
];
// 执行重编码
const handleApply = async () => {
if (!sessionId || !selectedColumn) {
message.error('请先选择列');
return;
}
// 验证映射表
const emptyMapping = mappingTable.find((row) => !row.newValue);
if (emptyMapping) {
message.warning('请为所有原值指定新值');
return;
}
if (createNewColumn && !newColumnName) {
message.warning('请输入新列名');
return;
}
// 构建映射对象
const mapping: Record<string, any> = {};
mappingTable.forEach((row) => {
mapping[String(row.originalValue)] = row.newValue;
});
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: 'recode',
params: {
column: selectedColumn,
mapping,
createNewColumn,
newColumnName: createNewColumn ? newColumnName : undefined,
},
}),
});
const result = await response.json();
if (result.success) {
message.success('重编码成功!');
onApply(result.data.newDataPreview);
onClose();
} else {
// 友好的错误提示
message.error({
content: result.error || '重编码失败',
duration: 5,
});
}
} catch (error: any) {
console.error('[RecodeDialog] 执行失败:', error);
message.error({
content: '网络错误,请检查服务是否正常运行',
duration: 5,
});
} finally {
setLoading(false);
}
};
return (
<Modal
title="🔄 数值映射(重编码)"
open={visible}
onCancel={onClose}
width={800}
footer={null}
>
<div className="space-y-4">
{/* 选择列 */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<Select
placeholder="选择列"
value={selectedColumn || undefined}
onChange={setSelectedColumn}
showSearch
style={{ width: '100%' }}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={columns.map((col) => ({ value: col.id, label: col.name }))}
/>
</div>
{/* 映射表 */}
{selectedColumn && (
<>
{extracting ? (
<div className="text-center py-8">
<Spin tip="正在提取唯一值..." />
</div>
) : (
<>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
{uniqueValues.length}
</label>
<span className="text-xs text-slate-500">
💡 1,2,3...
</span>
</div>
<Table
dataSource={mappingTable}
columns={tableColumns}
pagination={false}
size="small"
rowKey="originalValue"
scroll={{ y: 300 }}
/>
</div>
{/* 新列选项 */}
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<Checkbox
checked={createNewColumn}
onChange={(e) => setCreateNewColumn(e.target.checked)}
>
<span className="text-sm font-medium"></span>
</Checkbox>
{createNewColumn && (
<div className="mt-2">
<Input
placeholder="输入新列名"
value={newColumnName}
onChange={(e) => setNewColumnName(e.target.value)}
size="small"
prefix={<span className="text-xs text-slate-500"></span>}
/>
</div>
)}
</div>
</>
)}
</>
)}
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-2 pt-4 border-t border-slate-200">
<Button onClick={onClose}></Button>
<Button
type="primary"
onClick={handleApply}
loading={loading}
disabled={!selectedColumn || mappingTable.length === 0}
>
</Button>
</div>
</div>
</Modal>
);
};
export default RecodeDialog;