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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
// 性能优化
|
||||
|
||||
@@ -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="重要提示"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user