feat(dc): Complete Tool C quick action buttons Phase 1-2 - 7 functions

Summary:
- Implement 7 quick action functions (filter, recode, binning, conditional, dropna, compute, pivot)
- Refactor to pre-written Python functions architecture (stable and secure)
- Add 7 Python operations modules with full type hints
- Add 7 frontend Dialog components with user-friendly UI
- Fix NaN serialization issues and auto type conversion
- Update all related documentation

Technical Details:
- Python: operations/ module (filter.py, recode.py, binning.py, conditional.py, dropna.py, compute.py, pivot.py)
- Backend: QuickActionService.ts with 7 execute methods
- Frontend: 7 Dialog components with complete validation
- Toolbar: Enable 7 quick action buttons

Status: Phase 1-2 completed, basic testing passed, ready for further testing
This commit is contained in:
2025-12-08 17:38:08 +08:00
parent af325348b8
commit f729699510
158 changed files with 13814 additions and 273 deletions

View File

@@ -0,0 +1,352 @@
/**
* 生成分类变量(分箱)对话框 - 改进版
*
* 改进:
* 1. 显示所有列(不过滤)
* 2. 自定义切点UI更友好
* 3. 提供示例说明
*/
import React, { useState } from 'react';
import { Modal, Select, Input, Button, Radio, Space, Tag, App, Alert } from 'antd';
import { Info } from 'lucide-react';
interface BinningDialogProps {
visible: boolean;
columns: Array<{ id: string; name: string; type?: string }>;
sessionId: string | null;
onClose: () => void;
onApply: (newData: any[]) => void;
}
const BinningDialog: React.FC<BinningDialogProps> = ({
visible,
columns,
sessionId,
onClose,
onApply,
}) => {
const { message } = App.useApp();
const [selectedColumn, setSelectedColumn] = useState<string>('');
const [method, setMethod] = useState<'custom' | 'equal_width' | 'equal_freq'>('equal_width');
const [newColumnName, setNewColumnName] = useState('');
// 自定义切点(改进:只存储切点值,标签自动生成)
const [customBins, setCustomBins] = useState<string>('18, 60');
const [customLabels, setCustomLabels] = useState<string>('青少年, 成年, 老年');
// ✅ 重要2个切点 → 3个区间 → 3个标签
// 等宽/等频
const [numBins, setNumBins] = useState<number>(3);
const [autoLabels, setAutoLabels] = useState<string[]>(['低', '中', '高']);
const [loading, setLoading] = useState(false);
// 更新列选择
const handleColumnChange = (value: string) => {
setSelectedColumn(value);
const column = columns.find((c) => c.id === value);
if (column) {
setNewColumnName(`${column.name}_分组`);
}
};
// 执行分箱
const handleApply = async () => {
if (!sessionId || !selectedColumn) {
message.error('请选择列');
return;
}
if (!newColumnName) {
message.warning('请输入新列名');
return;
}
let params: any = {
column: selectedColumn,
method,
newColumnName,
};
if (method === 'custom') {
// 解析切点(支持中英文逗号)
const binsInput = customBins.replace(//g, ','); // 中文逗号转英文
const binsArray = binsInput.split(',').map(b => parseFloat(b.trim())).filter(b => !isNaN(b));
if (binsArray.length < 1) {
message.warning('至少需要1个切点60 表示分为≤60和>60两组');
return;
}
// 检查是否升序
const sorted = [...binsArray].sort((a, b) => a - b);
if (JSON.stringify(binsArray) !== JSON.stringify(sorted)) {
message.warning('切点必须按从小到大排列');
return;
}
// 解析标签(支持中英文逗号)
const labelsInput = customLabels.replace(//g, ','); // 中文逗号转英文
const labelsArray = labelsInput.split(',').map(l => l.trim()).filter(l => l);
// 切点数量 + 1 = 区间数量 = 标签数量
const expectedLabelCount = binsArray.length + 1;
if (labelsArray.length > 0 && labelsArray.length !== expectedLabelCount) {
message.warning(`需要${expectedLabelCount}个标签(${binsArray.length}个切点会生成${expectedLabelCount}个区间),或留空自动生成`);
return;
}
params.bins = binsArray;
params.labels = labelsArray.length > 0 ? labelsArray : undefined;
} else {
// 等宽/等频
params.numBins = numBins;
// 解析标签
const labelsArray = autoLabels.filter(l => l);
if (labelsArray.length > 0 && labelsArray.length !== numBins) {
message.warning(`需要${numBins}个标签,或留空自动生成`);
return;
}
if (labelsArray.length > 0) {
params.labels = labelsArray;
}
}
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: 'binning',
params,
}),
});
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('[BinningDialog] 执行失败:', error);
message.error({
content: '网络错误,请检查服务是否正常运行',
duration: 5,
});
} finally {
setLoading(false);
}
};
return (
<Modal
title="📊 生成分类变量(分箱)"
open={visible}
onCancel={onClose}
width={700}
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={handleColumnChange}
showSearch
style={{ width: '100%' }}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={columns.map((col) => ({
value: col.id,
label: col.name
}))}
/>
<div className="text-xs text-slate-500 mt-1">
💡
</div>
</div>
{/* 分箱方法 */}
{selectedColumn && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<Radio.Group value={method} onChange={(e) => setMethod(e.target.value)}>
<Space direction="vertical">
<Radio value="equal_width">
<span className="font-medium"></span>
<span className="text-xs text-slate-500 ml-2">
</span>
</Radio>
<Radio value="equal_freq">
<span className="font-medium"></span>
<span className="text-xs text-slate-500 ml-2">
</span>
</Radio>
<Radio value="custom">
<span className="font-medium"></span>
<span className="text-xs text-slate-500 ml-2">
</span>
</Radio>
</Space>
</Radio.Group>
</div>
{/* 等宽/等频配置 */}
{(method === 'equal_width' || method === 'equal_freq') && (
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<div className="mb-3">
<label className="text-sm font-medium text-slate-700 mb-2 block">
</label>
<Select
value={numBins}
onChange={(value) => {
setNumBins(value);
if (value === 3) {
setAutoLabels(['低', '中', '高']);
} else if (value === 4) {
setAutoLabels(['低', '中低', '中高', '高']);
} else if (value === 5) {
setAutoLabels(['极低', '低', '中', '高', '极高']);
} else {
setAutoLabels(Array.from({ length: value }, (_, i) => `${i + 1}`));
}
}}
style={{ width: '100%' }}
options={[
{ value: 2, label: '2组二分类' },
{ value: 3, label: '3组低、中、高' },
{ value: 4, label: '4组四分位' },
{ value: 5, label: '5组五分类' },
]}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
使
</label>
<div className="flex flex-wrap gap-2">
{autoLabels.map((label, index) => (
<Tag key={index} color="blue">
{label}
</Tag>
))}
</div>
</div>
</div>
)}
{/* 自定义切点配置(改进版) */}
{method === 'custom' && (
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<Alert
message="如何使用自定义切点"
description={
<div className="text-xs space-y-1 mt-2">
<div> <strong></strong> <code className="bg-white px-1">18, 60</code> <code className="bg-white px-1">1860</code></div>
<div> <strong></strong>31818-60&gt;60</div>
<div> <strong></strong> <code className="bg-white px-1">, , </code></div>
<div> <strong></strong><span className="text-red-600 font-semibold">+1=</span>23</div>
<div className="bg-yellow-50 border-l-2 border-yellow-400 pl-2 py-1 mt-2">
💡 <strong></strong> <code className="bg-white px-1">60</code> 260&gt;60
</div>
</div>
}
type="info"
showIcon
icon={<Info size={16} />}
className="mb-3"
/>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
</label>
<Input
placeholder="如18, 60 或 1860 或 60"
value={customBins}
onChange={(e) => setCustomBins(e.target.value)}
/>
<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={customLabels}
onChange={(e) => setCustomLabels(e.target.value)}
/>
<div className="text-xs text-slate-500 mt-1">
<span className="text-red-600 font-semibold"></span> = + 1使
</div>
</div>
</div>
</div>
)}
{/* 新列名 */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<Input
placeholder="输入新列名"
value={newColumnName}
onChange={(e) => setNewColumnName(e.target.value)}
/>
</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 || !newColumnName}
>
</Button>
</div>
</div>
</Modal>
);
};
export default BinningDialog;