feat(dc/tool-c): Add pivot column ordering and NA handling features

Major features:
1. Pivot transformation enhancements:
   - Add option to keep unselected columns with 3 aggregation methods
   - Maintain original column order after pivot (aligned with source file)
   - Preserve pivot value order (first appearance order)

2. NA handling across 4 core functions:
   - Recode: Support keep/map/drop for NA values
   - Filter: Already supports is_null/not_null operators
   - Binning: Support keep/label/assign for NA values (fix nan display)
   - Conditional: Add is_null/not_null operators

3. UI improvements:
   - Enable column header tooltips with custom header component
   - Add closeable alert for 50-row preview
   - Fix page scrollbar issues

Modified files:
Python: pivot.py, recode.py, binning.py, conditional.py, main.py
Backend: SessionController, QuickActionController, QuickActionService
Frontend: PivotDialog, RecodeDialog, BinningDialog, ConditionalDialog, DataGrid, index

Status: Ready for testing
This commit is contained in:
2025-12-09 14:40:14 +08:00
parent 75ceeb0653
commit f4f1d09837
19 changed files with 2314 additions and 123 deletions

View File

@@ -42,6 +42,11 @@ const BinningDialog: React.FC<BinningDialogProps> = ({
const [autoLabels, setAutoLabels] = useState<string[]>(['低', '中', '高']);
const [loading, setLoading] = useState(false);
// ✨ NA处理
const [naHandling, setNaHandling] = useState<'keep' | 'label' | 'assign'>('keep');
const [naLabel, setNaLabel] = useState<string>('缺失');
const [naAssignTo, setNaAssignTo] = useState<number>(0);
// 更新列选择
const handleColumnChange = (value: string) => {
@@ -118,6 +123,14 @@ const BinningDialog: React.FC<BinningDialogProps> = ({
}
}
// ✨ 添加NA处理参数
params.naHandling = naHandling;
if (naHandling === 'label') {
params.naLabel = naLabel;
} else if (naHandling === 'assign') {
params.naAssignTo = naAssignTo;
}
setLoading(true);
try {
const response = await fetch('/api/v1/dc/tool-c/quick-action', {
@@ -328,6 +341,59 @@ const BinningDialog: React.FC<BinningDialogProps> = ({
onChange={(e) => setNewColumnName(e.target.value)}
/>
</div>
{/* ✨ NA处理区域 */}
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium text-slate-700"> /NA </span>
<span className="text-xs text-slate-500"></span>
</div>
<Radio.Group value={naHandling} onChange={(e) => setNaHandling(e.target.value)}>
<Space direction="vertical">
<Radio value="keep">
<span className="text-sm"></span>
</Radio>
<Radio value="label">
<span className="text-sm"></span>
{naHandling === 'label' && (
<Input
placeholder="如:缺失、未知"
value={naLabel}
onChange={(e) => setNaLabel(e.target.value)}
size="small"
className="ml-2"
style={{ width: 150 }}
/>
)}
</Radio>
<Radio value="assign">
<span className="text-sm"></span>
{naHandling === 'assign' && (
<Select
value={naAssignTo}
onChange={setNaAssignTo}
size="small"
className="ml-2"
style={{ width: 150 }}
>
{(method === 'custom'
? (customLabels.split(',').filter(l => l.trim()).length || 3)
: numBins
) && Array.from({ length: method === 'custom'
? (customLabels.split(',').filter(l => l.trim()).length || 3)
: numBins
}).map((_, i) => (
<Select.Option key={i} value={i}>
{i + 1}
</Select.Option>
))}
</Select>
)}
</Radio>
</Space>
</Radio.Group>
</div>
</>
)}

View File

@@ -51,6 +51,8 @@ const ConditionalDialog: React.FC<Props> = ({
{ label: '小于 (<)', value: '<' },
{ label: '大于等于 (>=)', value: '>=' },
{ label: '小于等于 (<=)', value: '<=' },
{ label: '为空(空值/NA', value: 'is_null' }, // ✨ 新增
{ label: '不为空', value: 'not_null' }, // ✨ 新增
];
// 添加规则
@@ -365,20 +367,23 @@ const ConditionalDialog: React.FC<Props> = ({
</Select.Option>
))}
</Select>
<Input
placeholder="值"
value={condition.value}
onChange={(e) =>
handleUpdateCondition(
ruleIndex,
condIndex,
'value',
e.target.value
)
}
className="w-32"
size="small"
/>
{/* ✨ 只在不是is_null/not_null时显示值输入框 */}
{condition.operator !== 'is_null' && condition.operator !== 'not_null' && (
<Input
placeholder="值"
value={condition.value}
onChange={(e) =>
handleUpdateCondition(
ruleIndex,
condIndex,
'value',
e.target.value
)
}
className="w-32"
size="small"
/>
)}
{rule.conditions.length > 1 && (
<Button
type="text"

View File

@@ -23,6 +23,19 @@ interface DataGridProps {
onCellValueChanged?: (params: any) => void;
}
// ✨ 自定义表头组件带tooltip
const CustomHeader = (props: any) => {
return (
<div
className="ag-header-cell-label"
title={props.displayName}
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{props.displayName}
</div>
);
};
const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }) => {
// 防御性编程:确保 data 和 columns 始终是数组
const safeData = data || [];
@@ -41,8 +54,13 @@ const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }
// ✅ 修复使用安全的field名索引通过valueGetter获取实际数据
field: `col_${index}`,
headerName: col.name,
// ✅ 优化添加tooltip显示完整列名
// ✅ 优化添加tooltip显示完整列名(双保险)
headerTooltip: col.name,
// ✨ 使用自定义表头组件确保tooltip一定显示
headerComponent: CustomHeader,
headerComponentParams: {
displayName: col.name,
},
// ✅ 关键修复使用valueGetter直接从原始数据中获取值
valueGetter: (params: any) => {
return params.data?.[col.id];
@@ -93,7 +111,7 @@ const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }
// 空状态
if (safeData.length === 0) {
return (
<div className="bg-white border-2 border-slate-200 shadow-lg rounded-2xl p-12 text-center h-full flex items-center justify-center">
<div className="bg-white border-2 border-slate-200 shadow-lg rounded-2xl p-12 text-center flex items-center justify-center" style={{ height: '100%' }}>
<div className="text-slate-400 text-sm space-y-3">
<p className="text-2xl">📊 </p>
<p className="text-base text-slate-500">AI助手中上传CSV或Excel文件</p>
@@ -118,6 +136,8 @@ const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }
domLayout="normal"
suppressCellFocus={false}
enableCellTextSelection={true}
// ✅ 启用浏览器原生tooltip让headerTooltip生效
enableBrowserTooltips={true}
// ✅ 修复 AG Grid #239使用 legacy 主题模式
theme="legacy"
// 性能优化

View File

@@ -23,6 +23,9 @@ const PivotDialog: React.FC<Props> = ({
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');
// 重置状态
useEffect(() => {
@@ -31,6 +34,8 @@ const PivotDialog: React.FC<Props> = ({
setPivotColumn('');
setValueColumns([]);
setAggfunc('first');
setKeepUnusedColumns(false); // ✨ 重置:不保留未选择的列
setUnusedAggMethod('first'); // ✨ 重置:默认取第一个值
}
}, [visible]);
@@ -71,6 +76,8 @@ const PivotDialog: React.FC<Props> = ({
pivotColumn,
valueColumns,
aggfunc,
keepUnusedColumns, // ✨ 新增:是否保留未选择的列
unusedAggMethod, // ✨ 新增:未选择列的聚合方式
},
}),
});
@@ -238,6 +245,53 @@ const PivotDialog: React.FC<Props> = ({
</div>
</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 className="text-xs text-slate-500 mt-2">
</div>
</div>
)}
</div>
{/* 警告 */}
<Alert
title="重要提示"

View File

@@ -40,6 +40,11 @@ const RecodeDialog: React.FC<RecodeDialogProps> = ({
const [newColumnName, setNewColumnName] = useState('');
const [loading, setLoading] = useState(false);
const [extracting, setExtracting] = useState(false);
// ✨ NA处理
const [hasNA, setHasNA] = useState(false);
const [naCount, setNaCount] = useState(0); // ✨ NA数量
const [naHandling, setNaHandling] = useState<'keep' | 'map' | 'drop'>('keep');
const [naValue, setNaValue] = useState<string>('');
// 当选择列时,从后端获取唯一值
useEffect(() => {
@@ -65,17 +70,38 @@ const RecodeDialog: React.FC<RecodeDialogProps> = ({
}
const unique = result.data.uniqueValues;
const naCountFromBackend = result.data.naCount || 0; // ✨ 从后端获取NA数量
setUniqueValues(unique);
// 初始化映射表
const initialMapping = unique.map((val: any) => ({
// ✨ 检测是否有NA值后端用<空值/NA>标记)
const hasNAValue = unique.some((val: any) =>
val === null ||
val === undefined ||
val === '' ||
val === '<空值/NA>' // ✨ 后端返回的特殊标记
);
setHasNA(hasNAValue);
setNaCount(naCountFromBackend); // ✨ 保存NA数量
// 初始化映射表排除NA值NA单独处理
const nonNAValues = unique.filter((val: any) =>
val !== null &&
val !== undefined &&
val !== '' &&
val !== '<空值/NA>' // ✨ 排除特殊标记
);
const initialMapping = nonNAValues.map((val: any) => ({
originalValue: val,
newValue: '',
}));
setMappingTable(initialMapping);
// 重置NA处理
setNaHandling('keep');
setNaValue('');
// 生成默认新列名
setNewColumnName(`${selectedColumn}_编码`);
} catch (error: any) {
@@ -172,6 +198,8 @@ const RecodeDialog: React.FC<RecodeDialogProps> = ({
mapping,
createNewColumn,
newColumnName: createNewColumn ? newColumnName : undefined,
naHandling, // ✨ NA处理方式
naValue: naHandling === 'map' ? naValue : undefined, // ✨ NA映射值
},
}),
});
@@ -239,7 +267,12 @@ const RecodeDialog: React.FC<RecodeDialogProps> = ({
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
{uniqueValues.length}
{mappingTable.length}
{hasNA && (
<span className="ml-2 text-xs text-yellow-600">
+/NA
</span>
)}
</label>
<span className="text-xs text-slate-500">
💡 1,2,3...
@@ -276,6 +309,46 @@ const RecodeDialog: React.FC<RecodeDialogProps> = ({
</div>
)}
</div>
{/* ✨ NA处理区域 */}
{hasNA && (
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-slate-700"> /NA </span>
<span className="text-xs text-slate-500">
{naCount}
</span>
</div>
<Select
value={naHandling}
onChange={setNaHandling}
style={{ width: '100%' }}
size="small"
>
<Select.Option value="keep">
<span className="text-sm">NA</span>
</Select.Option>
<Select.Option value="map">
<span className="text-sm"></span>
</Select.Option>
<Select.Option value="drop">
<span className="text-sm text-red-600">NA的行</span>
</Select.Option>
</Select>
{naHandling === 'map' && (
<div className="mt-2">
<Input
placeholder="输入NA映射的值9, 未知)"
value={naValue}
onChange={(e) => setNaValue(e.target.value)}
size="small"
prefix={<span className="text-xs text-slate-500">NA </span>}
/>
</div>
)}
</div>
)}
</>
)}
</>

View File

@@ -36,6 +36,7 @@ interface ToolCState {
// UI状态
isLoading: boolean;
isSidebarOpen: boolean;
isAlertClosed: boolean; // ✨ 新增:提示条关闭状态
// ✨ 功能按钮对话框状态
filterDialogVisible: boolean;
@@ -69,6 +70,7 @@ const ToolC = () => {
messages: [],
isLoading: false,
isSidebarOpen: true,
isAlertClosed: false, // ✨ 初始状态:未关闭
filterDialogVisible: false,
recodeDialogVisible: false,
binningDialogVisible: false,
@@ -228,7 +230,7 @@ const ToolC = () => {
// ==================== 渲染 ====================
return (
<div className="h-screen w-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100 overflow-hidden">
<div className="h-screen w-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
{/* 顶部栏 */}
<Header
fileName={state.fileName || '未上传文件'}
@@ -237,10 +239,10 @@ const ToolC = () => {
onToggleSidebar={() => updateState({ isSidebarOpen: !state.isSidebarOpen })}
/>
{/* 主工作区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧:表格区域 */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 主工作区 - 移除overflow-hidden让子元素自己处理滚动 */}
<div className="flex-1 flex min-h-0">
{/* 左侧:表格区域 - 独立滚动 */}
<div className="flex-1 flex flex-col min-w-0">
<Toolbar
sessionId={state.sessionId}
onFilterClick={() => updateState({ filterDialogVisible: true })}
@@ -251,23 +253,34 @@ const ToolC = () => {
onComputeClick={() => updateState({ computeDialogVisible: true })}
onPivotClick={() => updateState({ pivotDialogVisible: true })}
/>
<div className="flex-1 p-4 overflow-hidden flex flex-col">
{/* ✨ 优化提示只显示前50行 */}
{state.data.length > 0 && (
<div className="mb-2 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2 text-sm">
<span className="text-blue-600"></span>
<span className="text-blue-700">
<strong></strong> <strong>50</strong> <strong></strong>
</span>
<div className="flex-1 p-4 flex flex-col min-h-0">
{/* ✨ 优化提示只显示前50行(可关闭) */}
{state.data.length > 0 && !state.isAlertClosed && (
<div className="mb-2 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-blue-600"></span>
<span className="text-blue-700">
<strong></strong> <strong>50</strong> <strong></strong>
</span>
</div>
<button
onClick={() => updateState({ isAlertClosed: true })}
className="text-blue-400 hover:text-blue-600 transition-colors p-1 rounded hover:bg-blue-100"
title="关闭提示"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</div>
)}
<div className="flex-1 overflow-hidden">
<div className="flex-1 min-h-0">
<DataGrid data={state.data} columns={state.columns} />
</div>
</div>
</div>
{/* 右侧AI 数据清洗助手 */}
{/* 右侧AI 数据清洗助手 - 独立滚动 */}
{state.isSidebarOpen && (
<Sidebar
isOpen={state.isSidebarOpen}