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:
@@ -107,3 +107,5 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -97,3 +97,5 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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">18,60</code></div>
|
||||
<div>• <strong>结果</strong>:自动添加边界,生成3组(≤18、18-60、>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>(如2个切点需要3个标签)</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> → 生成2组(≤60、>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 或 18,60 或 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;
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 生成分类变量(分箱)对话框 - 改进版
|
||||
*
|
||||
* 改进:
|
||||
* 1. 显示所有列(不过滤)
|
||||
* 2. 自定义切点UI更友好
|
||||
* 3. 提供示例说明
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Select, Input, Button, Radio, Space, Tag, App, Alert } from 'antd';
|
||||
import { Plus, X, 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>('青少年, 成年, 老年');
|
||||
|
||||
// 等宽/等频
|
||||
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 binsArray = customBins.split(',').map(b => parseFloat(b.trim())).filter(b => !isNaN(b));
|
||||
if (binsArray.length < 2) {
|
||||
message.warning('至少需要2个切点(用逗号分隔,如:18, 60)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否升序
|
||||
const sorted = [...binsArray].sort((a, b) => a - b);
|
||||
if (JSON.stringify(binsArray) !== JSON.stringify(sorted)) {
|
||||
message.warning('切点必须按从小到大排列');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
const labelsArray = customLabels.split(',').map(l => l.trim()).filter(l => l);
|
||||
if (labelsArray.length > 0 && labelsArray.length !== binsArray.length - 1) {
|
||||
message.warning(`需要${binsArray.length - 1}个标签(切点数-1),或留空自动生成`);
|
||||
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></div>
|
||||
<div>• <strong>结果</strong>:生成3组(<18、18-60、>60)</div>
|
||||
<div>• <strong>标签</strong>:可选,用逗号分隔,如 <code className="bg-white px-1">青少年, 成年, 老年</code></div>
|
||||
<div>• <strong>注意</strong>:切点数量-1 = 标签数量(如2个切点需要3个标签)</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"
|
||||
value={customBins}
|
||||
onChange={(e) => setCustomBins(e.target.value)}
|
||||
/>
|
||||
</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">
|
||||
留空则使用默认区间标签(如:[18.0, 60.0))
|
||||
</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;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Input, Button, Alert, Collapse, Tag, App } from 'antd';
|
||||
import { Calculator, Lightbulb, BookOpen } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (newData: any[]) => void;
|
||||
columns: Array<{ id: string; name: string }>;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
const ComputeDialog: React.FC<Props> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onApply,
|
||||
columns,
|
||||
sessionId,
|
||||
}) => {
|
||||
const { message } = App.useApp();
|
||||
const [newColumnName, setNewColumnName] = useState('新列');
|
||||
const [formula, setFormula] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 公式示例
|
||||
const examples = [
|
||||
{
|
||||
name: 'BMI计算',
|
||||
formula: '体重 / (身高/100)**2',
|
||||
description: '需要身高(cm)和体重(kg)列',
|
||||
},
|
||||
{
|
||||
name: '年龄分组',
|
||||
formula: 'round(年龄 / 10) * 10',
|
||||
description: '按10岁为一组',
|
||||
},
|
||||
{
|
||||
name: '综合得分',
|
||||
formula: '(FMA得分 * 0.6 + ADL得分 * 0.4)',
|
||||
description: '加权平均分',
|
||||
},
|
||||
{
|
||||
name: '变化率(%)',
|
||||
formula: '(随访值 - 基线值) / 基线值 * 100',
|
||||
description: '计算变化百分比',
|
||||
},
|
||||
{
|
||||
name: '对数转换',
|
||||
formula: 'log(值 + 1)',
|
||||
description: '对数变换(处理偏态分布)',
|
||||
},
|
||||
];
|
||||
|
||||
// 支持的函数
|
||||
const functions = [
|
||||
{ name: 'abs(x)', desc: '绝对值' },
|
||||
{ name: 'round(x)', desc: '四舍五入' },
|
||||
{ name: 'sqrt(x)', desc: '平方根' },
|
||||
{ name: 'log(x)', desc: '自然对数' },
|
||||
{ name: 'log10(x)', desc: '常用对数' },
|
||||
{ name: 'exp(x)', desc: '指数函数' },
|
||||
{ name: 'floor(x)', desc: '向下取整' },
|
||||
{ name: 'ceil(x)', desc: '向上取整' },
|
||||
{ name: 'min(a,b)', desc: '最小值' },
|
||||
{ name: 'max(a,b)', desc: '最大值' },
|
||||
];
|
||||
|
||||
// 使用示例公式
|
||||
const useExample = (exampleFormula: string) => {
|
||||
setFormula(exampleFormula);
|
||||
};
|
||||
|
||||
// 执行
|
||||
const handleApply = async () => {
|
||||
if (!sessionId) {
|
||||
message.error('Session ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newColumnName.trim()) {
|
||||
message.warning('请输入新列名');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formula.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: 'compute',
|
||||
params: {
|
||||
newColumnName: newColumnName.trim(),
|
||||
formula: formula.trim(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator size={20} className="text-blue-500" />
|
||||
<span>计算列</span>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
onClick={handleApply}
|
||||
loading={loading}
|
||||
icon={<Calculator size={16} />}
|
||||
>
|
||||
执行计算
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 说明 */}
|
||||
<Alert
|
||||
title="如何使用"
|
||||
description={
|
||||
<div className="text-xs space-y-1">
|
||||
<div>• 基于现有列计算新列</div>
|
||||
<div>• 支持四则运算(+, -, *, /)和幂运算(**)</div>
|
||||
<div>• 支持常用数学函数(abs, round, sqrt, log等)</div>
|
||||
<div>• 列名直接引用,如:体重, 身高, 年龄</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* 新列名 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
新列名:
|
||||
</label>
|
||||
<Input
|
||||
placeholder="如:BMI, 综合得分"
|
||||
value={newColumnName}
|
||||
onChange={(e) => setNewColumnName(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 计算公式 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
计算公式:
|
||||
</label>
|
||||
<Input.TextArea
|
||||
placeholder="如:体重 / (身高/100)**2"
|
||||
value={formula}
|
||||
onChange={(e) => setFormula(e.target.value)}
|
||||
rows={3}
|
||||
size="large"
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
直接使用列名,支持 +、-、*、/、**(幂)、()(括号)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可用列名 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
|
||||
<BookOpen size={16} />
|
||||
可用列名(点击复制):
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-slate-50 rounded-lg max-h-32 overflow-y-auto">
|
||||
{columns.map((col) => (
|
||||
<Tag
|
||||
key={col.id}
|
||||
color="blue"
|
||||
className="cursor-pointer hover:bg-blue-100"
|
||||
onClick={() => {
|
||||
setFormula(formula + (formula ? ' ' : '') + col.name);
|
||||
}}
|
||||
>
|
||||
{col.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 公式示例 */}
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'examples',
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb size={16} className="text-yellow-500" />
|
||||
<span className="font-medium">公式示例(点击使用)</span>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="space-y-2">
|
||||
{examples.map((example, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border rounded-lg p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
onClick={() => useExample(example.formula)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm text-blue-600">
|
||||
{example.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 mt-1">
|
||||
{example.description}
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded mt-2 block font-mono">
|
||||
{example.formula}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'functions',
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator size={16} className="text-green-500" />
|
||||
<span className="font-medium">支持的函数</span>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{functions.map((func, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-slate-50 rounded"
|
||||
>
|
||||
<code className="text-xs font-mono text-blue-600">
|
||||
{func.name}
|
||||
</code>
|
||||
<span className="text-xs text-slate-600">
|
||||
{func.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
defaultActiveKey={['examples']}
|
||||
className="bg-white"
|
||||
/>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<Alert
|
||||
message={
|
||||
<div className="text-xs">
|
||||
<strong>提示</strong>:
|
||||
<div className="mt-1 space-y-1">
|
||||
<div>• 列名区分大小写,需与数据表中完全一致</div>
|
||||
<div>• 除零会产生无穷大或NaN值</div>
|
||||
<div>• log、sqrt等函数对负数/零会产生NaN</div>
|
||||
<div>• 建议先用简单公式测试,再使用复杂公式</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
className="bg-yellow-50 border-yellow-200"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComputeDialog;
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Input, Button, Select, Space, Alert, App, Card, Tag } from 'antd';
|
||||
import { PlusCircle, Trash2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Condition {
|
||||
column: string;
|
||||
operator: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
conditions: Condition[];
|
||||
logic: 'and' | 'or';
|
||||
result: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (newData: any[]) => void;
|
||||
columns: Array<{ field: string; headerName: string }>;
|
||||
data: any[];
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
const ConditionalDialog: React.FC<Props> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onApply,
|
||||
columns,
|
||||
data,
|
||||
sessionId,
|
||||
}) => {
|
||||
const { message } = App.useApp();
|
||||
const [newColumnName, setNewColumnName] = useState('新列');
|
||||
const [rules, setRules] = useState<Rule[]>([
|
||||
{
|
||||
conditions: [{ column: '', operator: '=', value: '' }],
|
||||
logic: 'and',
|
||||
result: '',
|
||||
},
|
||||
]);
|
||||
const [elseValue, setElseValue] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 运算符选项
|
||||
const operatorOptions = [
|
||||
{ label: '等于 (=)', value: '=' },
|
||||
{ label: '不等于 (!=)', value: '!=' },
|
||||
{ label: '大于 (>)', value: '>' },
|
||||
{ label: '小于 (<)', value: '<' },
|
||||
{ label: '大于等于 (>=)', value: '>=' },
|
||||
{ label: '小于等于 (<=)', value: '<=' },
|
||||
];
|
||||
|
||||
// 添加规则
|
||||
const handleAddRule = () => {
|
||||
setRules([
|
||||
...rules,
|
||||
{
|
||||
conditions: [{ column: '', operator: '=', value: '' }],
|
||||
logic: 'and',
|
||||
result: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 删除规则
|
||||
const handleDeleteRule = (ruleIndex: number) => {
|
||||
if (rules.length === 1) {
|
||||
message.warning('至少需要保留一条规则');
|
||||
return;
|
||||
}
|
||||
setRules(rules.filter((_, index) => index !== ruleIndex));
|
||||
};
|
||||
|
||||
// 添加条件
|
||||
const handleAddCondition = (ruleIndex: number) => {
|
||||
const newRules = [...rules];
|
||||
newRules[ruleIndex].conditions.push({
|
||||
column: '',
|
||||
operator: '=',
|
||||
value: '',
|
||||
});
|
||||
setRules(newRules);
|
||||
};
|
||||
|
||||
// 删除条件
|
||||
const handleDeleteCondition = (ruleIndex: number, condIndex: number) => {
|
||||
const newRules = [...rules];
|
||||
if (newRules[ruleIndex].conditions.length === 1) {
|
||||
message.warning('每条规则至少需要一个条件');
|
||||
return;
|
||||
}
|
||||
newRules[ruleIndex].conditions.splice(condIndex, 1);
|
||||
setRules(newRules);
|
||||
};
|
||||
|
||||
// 更新条件
|
||||
const handleUpdateCondition = (
|
||||
ruleIndex: number,
|
||||
condIndex: number,
|
||||
field: keyof Condition,
|
||||
value: any
|
||||
) => {
|
||||
const newRules = [...rules];
|
||||
newRules[ruleIndex].conditions[condIndex][field] = value;
|
||||
setRules(newRules);
|
||||
};
|
||||
|
||||
// 更新规则
|
||||
const handleUpdateRule = (
|
||||
ruleIndex: number,
|
||||
field: keyof Rule,
|
||||
value: any
|
||||
) => {
|
||||
const newRules = [...rules];
|
||||
(newRules[ruleIndex] as any)[field] = value;
|
||||
setRules(newRules);
|
||||
};
|
||||
|
||||
// 执行
|
||||
const handleApply = async () => {
|
||||
// 验证
|
||||
if (!newColumnName.trim()) {
|
||||
message.warning('请输入新列名');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i];
|
||||
for (let j = 0; j < rule.conditions.length; j++) {
|
||||
const cond = rule.conditions[j];
|
||||
if (!cond.column) {
|
||||
message.warning(`规则${i + 1}的条件${j + 1}:请选择列`);
|
||||
return;
|
||||
}
|
||||
if (cond.value === '' || cond.value === null || cond.value === undefined) {
|
||||
message.warning(`规则${i + 1}的条件${j + 1}:请输入值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (rule.result === '' || rule.result === null || rule.result === undefined) {
|
||||
message.warning(`规则${i + 1}:请输入结果值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
message.error('Session ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 转换数据类型(尝试转为数字)
|
||||
const processedRules = rules.map((rule) => ({
|
||||
...rule,
|
||||
conditions: rule.conditions.map((cond) => {
|
||||
const numValue = Number(cond.value);
|
||||
return {
|
||||
...cond,
|
||||
value: isNaN(numValue) ? cond.value : numValue,
|
||||
};
|
||||
}),
|
||||
result: (() => {
|
||||
const numResult = Number(rule.result);
|
||||
return isNaN(numResult) ? rule.result : numResult;
|
||||
})(),
|
||||
}));
|
||||
|
||||
const processedElseValue = (() => {
|
||||
if (!elseValue) return null;
|
||||
const num = Number(elseValue);
|
||||
return isNaN(num) ? elseValue : num;
|
||||
})();
|
||||
|
||||
// 调用API
|
||||
const response = await fetch('/api/v1/dc/tool-c/quick-action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
action: 'conditional',
|
||||
params: {
|
||||
newColumnName: newColumnName.trim(),
|
||||
rules: processedRules,
|
||||
elseValue: processedElseValue,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="条件生成列"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
onClick={handleApply}
|
||||
loading={loading}
|
||||
>
|
||||
执行
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 说明 */}
|
||||
<Alert
|
||||
message="如何使用"
|
||||
description={
|
||||
<div className="text-xs space-y-1">
|
||||
<div>• 定义多条IF-THEN规则,按顺序匹配</div>
|
||||
<div>• 每条规则可以包含多个条件(用AND或OR连接)</div>
|
||||
<div>• 如果所有规则都不满足,使用ELSE默认值</div>
|
||||
<div>
|
||||
• 示例:IF 年龄 >= 60 THEN "老年" ELSE "非老年"
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* 新列名 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
新列名:
|
||||
</label>
|
||||
<Input
|
||||
placeholder="如:住院患者暴露分组"
|
||||
value={newColumnName}
|
||||
onChange={(e) => setNewColumnName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
规则(按顺序匹配):
|
||||
</label>
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusCircle size={14} />}
|
||||
onClick={handleAddRule}
|
||||
>
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<Card
|
||||
key={ruleIndex}
|
||||
size="small"
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<Tag color="blue">规则 {ruleIndex + 1}</Tag>
|
||||
{rules.length > 1 && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => handleDeleteRule(ruleIndex)}
|
||||
>
|
||||
删除规则
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="bg-slate-50"
|
||||
>
|
||||
{/* 条件列表 */}
|
||||
<div className="space-y-2">
|
||||
{rule.conditions.map((condition, condIndex) => (
|
||||
<div key={condIndex}>
|
||||
{condIndex > 0 && (
|
||||
<div className="flex items-center mb-1">
|
||||
<Select
|
||||
value={rule.logic}
|
||||
onChange={(value) =>
|
||||
handleUpdateRule(ruleIndex, 'logic', value)
|
||||
}
|
||||
size="small"
|
||||
className="w-20"
|
||||
>
|
||||
<Select.Option value="and">且 (AND)</Select.Option>
|
||||
<Select.Option value="or">或 (OR)</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">IF</span>
|
||||
<Select
|
||||
placeholder="选择列"
|
||||
value={condition.column || undefined}
|
||||
onChange={(value) =>
|
||||
handleUpdateCondition(
|
||||
ruleIndex,
|
||||
condIndex,
|
||||
'column',
|
||||
value
|
||||
)
|
||||
}
|
||||
className="flex-1"
|
||||
size="small"
|
||||
showSearch
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<Select.Option key={col.field} value={col.field}>
|
||||
{col.headerName}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(value) =>
|
||||
handleUpdateCondition(
|
||||
ruleIndex,
|
||||
condIndex,
|
||||
'operator',
|
||||
value
|
||||
)
|
||||
}
|
||||
className="w-32"
|
||||
size="small"
|
||||
>
|
||||
{operatorOptions.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<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"
|
||||
size="small"
|
||||
danger
|
||||
icon={<Trash2 size={12} />}
|
||||
onClick={() =>
|
||||
handleDeleteCondition(ruleIndex, condIndex)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 添加条件 */}
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
block
|
||||
onClick={() => handleAddCondition(ruleIndex)}
|
||||
>
|
||||
+ 添加条件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 结果值 */}
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t">
|
||||
<span className="text-xs text-slate-500 font-semibold">
|
||||
THEN
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">值为:</span>
|
||||
<Input
|
||||
placeholder="如:1 或 暴露"
|
||||
value={rule.result}
|
||||
onChange={(e) =>
|
||||
handleUpdateRule(ruleIndex, 'result', e.target.value)
|
||||
}
|
||||
size="small"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ELSE默认值 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
ELSE(所有规则都不满足时的默认值):
|
||||
</label>
|
||||
<Input
|
||||
placeholder="留空表示 null,或输入如:0"
|
||||
value={elseValue}
|
||||
onChange={(e) => setElseValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<Alert
|
||||
message={
|
||||
<div className="text-xs">
|
||||
<AlertCircle size={14} className="inline mr-1" />
|
||||
<strong>提示</strong>:规则按顺序匹配,匹配到第一条满足的规则就停止,请合理安排规则顺序
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
className="bg-yellow-50 border-yellow-200"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionalDialog;
|
||||
|
||||
@@ -30,35 +30,52 @@ const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }
|
||||
|
||||
// 转换列定义为AG Grid格式
|
||||
const columnDefs: ColDef[] = useMemo(() => {
|
||||
return safeColumns.map((col) => ({
|
||||
field: col.id,
|
||||
headerName: col.name,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
editable: false, // MVP阶段暂不支持手动编辑
|
||||
width: 120,
|
||||
minWidth: 80,
|
||||
// 缺失值高亮
|
||||
cellClass: (params) => {
|
||||
if (
|
||||
params.value === null ||
|
||||
params.value === undefined ||
|
||||
params.value === ''
|
||||
) {
|
||||
return 'bg-red-50 text-red-400 italic';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
// 数值类型右对齐
|
||||
cellStyle: (params) => {
|
||||
if (typeof params.value === 'number') {
|
||||
return { textAlign: 'right' as const };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
return safeColumns.map((col, index) => {
|
||||
// ✅ 优化1.2:自动检测是否为数值列
|
||||
const sampleValues = safeData.slice(0, 10).map(row => row[col.id]);
|
||||
const isNumericColumn = sampleValues.length > 0 && sampleValues.every(
|
||||
val => val === null || val === undefined || val === '' || typeof val === 'number'
|
||||
);
|
||||
|
||||
return {
|
||||
// ✅ 修复:使用安全的field名(索引),通过valueGetter获取实际数据
|
||||
field: `col_${index}`,
|
||||
headerName: col.name,
|
||||
// ✅ 关键修复:使用valueGetter直接从原始数据中获取值
|
||||
valueGetter: (params: any) => {
|
||||
return params.data?.[col.id];
|
||||
},
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
editable: false, // MVP阶段暂不支持手动编辑
|
||||
width: 150, // ✅ 增加默认宽度,适应长列名
|
||||
minWidth: 100,
|
||||
|
||||
// ✅ 优化1.3:缺失值高亮(新CSS类名)
|
||||
cellClass: (params) => {
|
||||
if (
|
||||
params.value === null ||
|
||||
params.value === undefined ||
|
||||
params.value === ''
|
||||
) {
|
||||
return 'cell-missing'; // ✅ 使用新的CSS类名
|
||||
}
|
||||
return isNumericColumn ? 'numeric-cell' : '';
|
||||
},
|
||||
|
||||
// ✅ 优化1.2:数值列右对齐 + 等宽字体
|
||||
...(isNumericColumn && {
|
||||
cellStyle: {
|
||||
textAlign: 'right' as const,
|
||||
fontVariantNumeric: 'tabular-nums', // 等宽数字
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header', // 表头也右对齐
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, [safeColumns, safeData]);
|
||||
|
||||
// 默认列配置
|
||||
const defaultColDef: ColDef = useMemo(
|
||||
@@ -90,8 +107,8 @@ const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }
|
||||
<div className="bg-white border-2 border-slate-200 shadow-lg rounded-2xl overflow-hidden h-full">
|
||||
<div className="ag-theme-alpine h-full" style={{ width: '100%', height: '100%' }}>
|
||||
<AgGridReact
|
||||
rowData={safeData}
|
||||
columnDefs={columnDefs}
|
||||
rowData={safeData}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows={true}
|
||||
rowSelection="multiple"
|
||||
@@ -99,6 +116,8 @@ const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }
|
||||
domLayout="normal"
|
||||
suppressCellFocus={false}
|
||||
enableCellTextSelection={true}
|
||||
// ✅ 修复 AG Grid #239:使用 legacy 主题模式
|
||||
theme="legacy"
|
||||
// 性能优化
|
||||
rowBuffer={10}
|
||||
debounceVerticalScrollbar={true}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Radio, Button, Slider, Alert, App, Statistic, Row, Col, Checkbox } from 'antd';
|
||||
import { Trash2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (newData: any[]) => void;
|
||||
columns: Array<{ id: string; name: string }>;
|
||||
data: any[];
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
const DropnaDialog: React.FC<Props> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onApply,
|
||||
columns,
|
||||
data,
|
||||
sessionId,
|
||||
}) => {
|
||||
const { message } = App.useApp();
|
||||
const [method, setMethod] = useState<'row' | 'column' | 'both'>('row');
|
||||
const [threshold, setThreshold] = useState<number>(50); // 百分比
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 计算缺失值统计
|
||||
const [missingStats, setMissingStats] = useState<{
|
||||
totalMissing: number;
|
||||
rowsWithMissing: number;
|
||||
colsWithMissing: number;
|
||||
}>({ totalMissing: 0, rowsWithMissing: 0, colsWithMissing: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && data.length > 0) {
|
||||
// 计算缺失值
|
||||
let totalMissing = 0;
|
||||
let rowsWithMissing = 0;
|
||||
const colsMissing: Record<string, number> = {};
|
||||
|
||||
data.forEach(row => {
|
||||
let rowHasMissing = false;
|
||||
Object.keys(row).forEach(key => {
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
totalMissing++;
|
||||
colsMissing[key] = (colsMissing[key] || 0) + 1;
|
||||
rowHasMissing = true;
|
||||
}
|
||||
});
|
||||
if (rowHasMissing) {
|
||||
rowsWithMissing++;
|
||||
}
|
||||
});
|
||||
|
||||
setMissingStats({
|
||||
totalMissing,
|
||||
rowsWithMissing,
|
||||
colsWithMissing: Object.keys(colsMissing).length,
|
||||
});
|
||||
}
|
||||
}, [visible, data]);
|
||||
|
||||
// 执行
|
||||
const handleApply = async () => {
|
||||
if (!sessionId) {
|
||||
message.error('Session ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证
|
||||
if (method === 'row' && selectedColumns.length === 0) {
|
||||
// 删除所有行:不需要指定列
|
||||
}
|
||||
|
||||
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: 'dropna',
|
||||
params: {
|
||||
method,
|
||||
threshold: threshold / 100, // 转换为0-1
|
||||
subset: selectedColumns.length > 0 ? selectedColumns : undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
<span>删除缺失值</span>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={700}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
danger
|
||||
onClick={handleApply}
|
||||
loading={loading}
|
||||
icon={<Trash2 size={16} />}
|
||||
>
|
||||
执行删除
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 缺失值统计 */}
|
||||
<Alert
|
||||
message="当前数据缺失值统计"
|
||||
description={
|
||||
<Row gutter={16} className="mt-3">
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="总缺失值"
|
||||
value={missingStats.totalMissing}
|
||||
suffix={`/ ${data.length * columns.length} 单元格`}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="含缺失值的行"
|
||||
value={missingStats.rowsWithMissing}
|
||||
suffix={`/ ${data.length} 行`}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="含缺失值的列"
|
||||
value={missingStats.colsWithMissing}
|
||||
suffix={`/ ${columns.length} 列`}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* 删除方式 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-2 block">
|
||||
删除方式:
|
||||
</label>
|
||||
<Radio.Group
|
||||
value={method}
|
||||
onChange={(e) => setMethod(e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="border rounded-lg p-3 hover:border-blue-400 transition-colors">
|
||||
<Radio value="row">
|
||||
<div className="ml-2">
|
||||
<div className="font-medium">按行删除</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
删除包含任何缺失值的行(推荐)
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-3 hover:border-blue-400 transition-colors">
|
||||
<Radio value="column">
|
||||
<div className="ml-2">
|
||||
<div className="font-medium">按列删除</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
删除缺失值比例超过阈值的列
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-3 hover:border-blue-400 transition-colors">
|
||||
<Radio value="both">
|
||||
<div className="ml-2">
|
||||
<div className="font-medium">先删列,再删行</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
先删除缺失列,再删除剩余的缺失行
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</div>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{/* 缺失率阈值(仅对按列删除和先删列再删行有效) */}
|
||||
{(method === 'column' || method === 'both') && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-2 block">
|
||||
缺失率阈值:{threshold}%
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={threshold}
|
||||
onChange={setThreshold}
|
||||
marks={{
|
||||
0: '0%',
|
||||
30: '30%',
|
||||
50: '50%',
|
||||
70: '70%',
|
||||
100: '100%',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-2">
|
||||
删除缺失率超过 {threshold}% 的列
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 仅检查指定列(仅对按行删除有效) */}
|
||||
{method === 'row' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-2 block">
|
||||
仅检查指定列的缺失值(可选):
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 max-h-48 overflow-y-auto">
|
||||
<Checkbox.Group
|
||||
value={selectedColumns}
|
||||
onChange={setSelectedColumns}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<Checkbox key={col.id} value={col.id}>
|
||||
{col.name}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-2">
|
||||
留空表示检查所有列
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 警告提示 */}
|
||||
<Alert
|
||||
message={
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="font-semibold">重要提示</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className="text-xs space-y-1 mt-1">
|
||||
<div>• 删除操作不可撤销,请谨慎操作</div>
|
||||
<div>• 建议先使用AI对话功能探索数据</div>
|
||||
<div>• 如果删除后数据为空,操作会失败</div>
|
||||
<div>• 考虑使用"多重插补"代替直接删除</div>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
className="bg-orange-50 border-orange-200"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropnaDialog;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 高级筛选器对话框
|
||||
*
|
||||
* 功能:
|
||||
* - 支持多条件组合(AND/OR)
|
||||
* - 支持多种运算符(=、>、<、包含等)
|
||||
* - 实时预览筛选结果
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Select, Input, Button, Radio, App } from 'antd';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface FilterCondition {
|
||||
column: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterDialogProps {
|
||||
visible: boolean;
|
||||
columns: Array<{ id: string; name: string }>;
|
||||
sessionId: string | null;
|
||||
onClose: () => void;
|
||||
onApply: (newData: any[]) => void;
|
||||
}
|
||||
|
||||
const FilterDialog: React.FC<FilterDialogProps> = ({
|
||||
visible,
|
||||
columns,
|
||||
sessionId,
|
||||
onClose,
|
||||
onApply,
|
||||
}) => {
|
||||
const { message } = App.useApp();
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([
|
||||
{ column: '', operator: '=', value: '' }
|
||||
]);
|
||||
const [logic, setLogic] = useState<'and' | 'or'>('and');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<any>(null);
|
||||
|
||||
// 运算符选项
|
||||
const operators = [
|
||||
{ value: '=', label: '等于' },
|
||||
{ value: '!=', label: '不等于' },
|
||||
{ value: '>', label: '大于' },
|
||||
{ value: '<', label: '小于' },
|
||||
{ value: '>=', label: '大于等于' },
|
||||
{ value: '<=', label: '小于等于' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'not_contains', label: '不包含' },
|
||||
{ value: 'starts_with', label: '以...开头' },
|
||||
{ value: 'ends_with', label: '以...结尾' },
|
||||
{ value: 'is_null', label: '为空' },
|
||||
{ value: 'not_null', label: '不为空' },
|
||||
];
|
||||
|
||||
// 添加条件
|
||||
const addCondition = () => {
|
||||
setConditions([...conditions, { column: '', operator: '=', value: '' }]);
|
||||
};
|
||||
|
||||
// 删除条件
|
||||
const removeCondition = (index: number) => {
|
||||
if (conditions.length === 1) {
|
||||
message.warning('至少需要一个筛选条件');
|
||||
return;
|
||||
}
|
||||
setConditions(conditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 更新条件
|
||||
const updateCondition = (index: number, field: keyof FilterCondition, value: string) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index][field] = value;
|
||||
setConditions(newConditions);
|
||||
};
|
||||
|
||||
// 预览
|
||||
const handlePreview = async () => {
|
||||
if (!sessionId) {
|
||||
message.error('会话未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证条件
|
||||
const invalidCondition = conditions.find(
|
||||
(c) => !c.column || (c.operator !== 'is_null' && c.operator !== 'not_null' && !c.value)
|
||||
);
|
||||
|
||||
if (invalidCondition) {
|
||||
message.warning('请完整填写所有筛选条件');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/dc/tool-c/quick-action/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
action: 'filter',
|
||||
params: {
|
||||
conditions: conditions.map((c) => ({
|
||||
column: c.column,
|
||||
operator: c.operator,
|
||||
value: c.operator === 'is_null' || c.operator === 'not_null' ? undefined : c.value,
|
||||
})),
|
||||
logic,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setPreviewData(result.data);
|
||||
message.success(`预览成功:${result.data.estimatedChange}`);
|
||||
} else {
|
||||
message.error({
|
||||
content: result.error || '预览失败',
|
||||
duration: 5,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[FilterDialog] 预览失败:', error);
|
||||
message.error({
|
||||
content: '网络错误,请检查服务是否正常运行',
|
||||
duration: 5,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行
|
||||
const handleApply = async () => {
|
||||
if (!sessionId) {
|
||||
message.error('会话未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证条件
|
||||
const invalidCondition = conditions.find(
|
||||
(c) => !c.column || (c.operator !== 'is_null' && c.operator !== 'not_null' && !c.value)
|
||||
);
|
||||
|
||||
if (invalidCondition) {
|
||||
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: 'filter',
|
||||
params: {
|
||||
conditions: conditions.map((c) => ({
|
||||
column: c.column,
|
||||
operator: c.operator,
|
||||
value: c.operator === 'is_null' || c.operator === 'not_null' ? undefined : c.value,
|
||||
})),
|
||||
logic,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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('[FilterDialog] 执行失败:', 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">
|
||||
{/* 条件列表 */}
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index}>
|
||||
{index > 0 && (
|
||||
<div className="flex items-center justify-center my-2">
|
||||
<Radio.Group
|
||||
value={logic}
|
||||
onChange={(e) => setLogic(e.target.value)}
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="and">且(AND)</Radio.Button>
|
||||
<Radio.Button value="or">或(OR)</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex-1 grid grid-cols-3 gap-2">
|
||||
{/* 列名 */}
|
||||
<Select
|
||||
placeholder="选择列"
|
||||
value={condition.column || undefined}
|
||||
onChange={(value) => updateCondition(index, 'column', value)}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={columns.map((col) => ({ value: col.id, label: col.name }))}
|
||||
/>
|
||||
|
||||
{/* 运算符 */}
|
||||
<Select
|
||||
placeholder="条件"
|
||||
value={condition.operator}
|
||||
onChange={(value) => updateCondition(index, 'operator', value)}
|
||||
options={operators}
|
||||
/>
|
||||
|
||||
{/* 值 */}
|
||||
{condition.operator !== 'is_null' && condition.operator !== 'not_null' && (
|
||||
<Input
|
||||
placeholder="值"
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, 'value', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={() => removeCondition(index)}
|
||||
disabled={conditions.length === 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 添加条件按钮 */}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addCondition}
|
||||
block
|
||||
>
|
||||
添加条件
|
||||
</Button>
|
||||
|
||||
{/* 预览结果 */}
|
||||
{previewData && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="text-sm text-blue-900">
|
||||
<span className="font-semibold">📊 预览:</span>
|
||||
{previewData.estimatedChange}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 mt-1">
|
||||
原始数据:{previewData.originalRows} 行 → 筛选后:{previewData.newRows} 行
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t border-slate-200">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button onClick={handlePreview} loading={loading}>
|
||||
预览
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleApply} loading={loading}>
|
||||
应用筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterDialog;
|
||||
|
||||
@@ -23,13 +23,14 @@ const Header: React.FC<HeaderProps> = ({ fileName, onUndo, onRedo, onExport, isS
|
||||
<header className="bg-white border-b border-slate-200 h-14 flex-none flex items-center justify-between px-4 z-20 shadow-sm">
|
||||
{/* 左侧:导航 + 工具名称 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* ✅ 优化2.1:返回按钮进一步轻量化 */}
|
||||
<button
|
||||
onClick={() => navigate('/data-cleaning')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-colors text-xs"
|
||||
className="flex items-center gap-1 px-0 py-1 text-slate-400 hover:text-slate-600 transition-colors text-xs"
|
||||
title="返回数据清洗工作台"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span className="font-normal">返回工作台</span>
|
||||
<ArrowLeft className="w-3 h-3" />
|
||||
<span className="font-light">返回工作台</span>
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200"></div>
|
||||
@@ -39,9 +40,10 @@ const Header: React.FC<HeaderProps> = ({ fileName, onUndo, onRedo, onExport, isS
|
||||
<Table2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">
|
||||
{/* ✅ 优化2.2:Logo字体加粗加深 */}
|
||||
<h1 className="text-lg font-extrabold text-slate-900">
|
||||
科研数据编辑器
|
||||
<span className="text-emerald-600 text-xs px-1.5 py-0.5 bg-emerald-50 rounded-full ml-1">
|
||||
<span className="text-emerald-600 text-xs px-1.5 py-0.5 bg-emerald-50 rounded-full ml-1.5 font-semibold">
|
||||
Pro
|
||||
</span>
|
||||
</h1>
|
||||
@@ -50,7 +52,15 @@ const Header: React.FC<HeaderProps> = ({ fileName, onUndo, onRedo, onExport, isS
|
||||
|
||||
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
|
||||
|
||||
<span className="text-xs text-slate-500 font-mono">{fileName}</span>
|
||||
{/* ✅ 优化2.3:文件名加大加粗为核心标题 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-900 font-mono">
|
||||
{fileName}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
当前工作文件
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
@@ -91,13 +101,13 @@ const Header: React.FC<HeaderProps> = ({ fileName, onUndo, onRedo, onExport, isS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
{/* ✅ 优化2.4:导出按钮增加阴影和字重 */}
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-900 text-white rounded-lg text-xs font-medium hover:bg-slate-800 transition-colors shadow-sm"
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-semibold hover:bg-slate-800 transition-all shadow-md hover:shadow-lg"
|
||||
title="导出Excel"
|
||||
>
|
||||
<Download size={14} />
|
||||
<Download size={16} />
|
||||
<span>导出结果</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Select, Button, Alert, Checkbox, Radio, App, Tag } from 'antd';
|
||||
import { ArrowLeftRight, Info } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (newData: any[]) => void;
|
||||
columns: Array<{ id: string; name: string }>;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
const PivotDialog: React.FC<Props> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onApply,
|
||||
columns,
|
||||
sessionId,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIndexColumn('');
|
||||
setPivotColumn('');
|
||||
setValueColumns([]);
|
||||
setAggfunc('first');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 执行
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowLeftRight size={20} className="text-purple-500" />
|
||||
<span>长表转宽表(Pivot)</span>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={700}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
onClick={handleApply}
|
||||
loading={loading}
|
||||
icon={<ArrowLeftRight size={16} />}
|
||||
>
|
||||
执行转换
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 说明 */}
|
||||
<Alert
|
||||
title="功能说明"
|
||||
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 className="text-xs text-slate-500 mt-2">
|
||||
如果同一个索引+透视组合出现多次,按此方式处理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 警告 */}
|
||||
<Alert
|
||||
title="重要提示"
|
||||
description={
|
||||
<div className="text-xs space-y-1">
|
||||
<div>• Pivot操作会显著改变数据结构(行列转换)</div>
|
||||
<div>• 转换后列数可能大幅增加</div>
|
||||
<div>• 建议先用AI对话探索数据结构</div>
|
||||
<div>• 索引列应该是真正的唯一标识(如患者ID)</div>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
className="bg-orange-50 border-orange-200"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotDialog;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 数值映射(重编码)对话框
|
||||
*
|
||||
* 功能:
|
||||
* - 自动提取列的唯一值
|
||||
* - 用户配置映射关系
|
||||
* - 支持创建新列或覆盖原列
|
||||
*/
|
||||
|
||||
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 || !data || data.length === 0) {
|
||||
setUniqueValues([]);
|
||||
setMappingTable([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setExtracting(true);
|
||||
|
||||
// 提取唯一值
|
||||
const values = data.map((row) => row[selectedColumn]);
|
||||
const unique = Array.from(new Set(values)).filter(v => v !== null && v !== undefined && v !== '');
|
||||
|
||||
setUniqueValues(unique);
|
||||
|
||||
// 初始化映射表
|
||||
const initialMapping = unique.map((val) => ({
|
||||
originalValue: val,
|
||||
newValue: '',
|
||||
}));
|
||||
|
||||
setMappingTable(initialMapping);
|
||||
|
||||
// 生成默认新列名
|
||||
setNewColumnName(`${selectedColumn}_编码`);
|
||||
|
||||
setExtracting(false);
|
||||
}, [selectedColumn, data]);
|
||||
|
||||
// 更新映射值
|
||||
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;
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
* Day 5: 使用通用 Chat 组件(基于 Ant Design X + X SDK)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { MessageSquare, X, Upload } from 'lucide-react';
|
||||
import { ChatContainer } from '@/shared/components/Chat';
|
||||
import { MessageRenderer } from '@/shared/components/Chat/MessageRenderer';
|
||||
import { message as antdMessage } from 'antd';
|
||||
import { StreamingSteps, StreamStep } from './StreamingSteps';
|
||||
import { App } from 'antd';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,51 +25,118 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
onFileUpload,
|
||||
onDataUpdate,
|
||||
}) => {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [streamSteps, setStreamSteps] = useState<StreamStep[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
// ✅ 使用 App.useApp() hook 获取 message API(支持动态主题)
|
||||
const { message } = App.useApp();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 处理代码执行
|
||||
const handleExecuteCode = async (code: string, messageId?: string) => {
|
||||
/**
|
||||
* ✨ 流式处理用户请求(自动执行代码)
|
||||
*/
|
||||
const handleStreamProcess = useCallback(async (userMessage: string) => {
|
||||
if (!sessionId) {
|
||||
antdMessage.error('会话未初始化');
|
||||
message.error('会话未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
// ✅ 修复:清空输入框
|
||||
setInputValue('');
|
||||
setIsStreaming(true);
|
||||
setStreamSteps([]);
|
||||
|
||||
try {
|
||||
// 调用后端执行代码的 API
|
||||
const response = await fetch(`/api/v1/dc/tool-c/ai/execute`, {
|
||||
const response = await fetch('/api/v1/dc/tool-c/ai/stream-process', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
code,
|
||||
messageId: messageId || Date.now().toString()
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
message: userMessage,
|
||||
maxRetries: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('执行失败');
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 更新数据表格
|
||||
if (result.data?.newDataPreview) {
|
||||
onDataUpdate(result.data.newDataPreview);
|
||||
antdMessage.success('代码执行成功');
|
||||
} else {
|
||||
throw new Error(result.data?.error || '执行失败');
|
||||
if (!response.ok) {
|
||||
throw new Error('请求失败');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 处理SSE消息(可能有多条)
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 保留最后一个未完成的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
|
||||
if (data === '[DONE]') {
|
||||
setIsStreaming(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const step: StreamStep = JSON.parse(data);
|
||||
|
||||
// ✅ 修复:智能更新步骤(同stepName则更新,否则追加)
|
||||
setStreamSteps(prev => {
|
||||
const existingIndex = prev.findIndex(s => s.stepName === step.stepName);
|
||||
if (existingIndex >= 0) {
|
||||
// 更新现有步骤
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = step;
|
||||
return updated;
|
||||
} else {
|
||||
// 追加新步骤
|
||||
return [...prev, step];
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6完成时,更新左侧数据
|
||||
if (step.stepName === 'complete' && step.status === 'success' && step.data?.newDataPreview) {
|
||||
onDataUpdate(step.data.newDataPreview);
|
||||
message.success('数据处理完成!');
|
||||
}
|
||||
|
||||
// 失败时显示错误
|
||||
if (step.status === 'failed' && step.stepName === 'complete') {
|
||||
message.error('处理失败:' + (step.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析SSE消息失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
antdMessage.error(error.message || '执行失败');
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
message.error(error.message || '流式处理失败');
|
||||
setIsStreaming(false);
|
||||
}
|
||||
};
|
||||
}, [sessionId, onDataUpdate, message]);
|
||||
|
||||
return (
|
||||
<div className="w-[480px] bg-white border-l-2 border-slate-200 flex flex-col shadow-lg">
|
||||
<div
|
||||
className="w-[480px] bg-white border-l-2 border-slate-200 flex flex-col"
|
||||
style={{ boxShadow: '-8px 0 16px rgba(0, 0, 0, 0.06)' }} /* ✅ 优化4.1:左侧柔和阴影 */
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-slate-200 flex items-center justify-between px-4 bg-gradient-to-r from-emerald-50 to-white">
|
||||
<div className="flex items-center gap-2 text-emerald-700 font-semibold">
|
||||
@@ -121,69 +187,71 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// ⭐ 使用通用 Chat 组件(基于 Ant Design X)
|
||||
<ChatContainer
|
||||
conversationType="tool-c"
|
||||
conversationKey={sessionId}
|
||||
providerConfig={{
|
||||
apiEndpoint: `/api/v1/dc/tool-c/ai/generate`,
|
||||
requestFn: async (message: string) => {
|
||||
try {
|
||||
// 只生成代码,不执行
|
||||
const response = await fetch(`/api/v1/dc/tool-c/ai/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, message }),
|
||||
});
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* ✨ 流式步骤展示区域 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-slate-50 to-white">
|
||||
{streamSteps.length > 0 ? (
|
||||
<StreamingSteps
|
||||
steps={streamSteps}
|
||||
/>
|
||||
) : (
|
||||
/* ✅ 修复:添加欢迎语 */
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full flex items-center justify-center shadow-lg">
|
||||
<MessageSquare className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||
👋 您好!我是 AI 数据清洗助手
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 leading-relaxed max-w-xs">
|
||||
我可以帮您处理数据清洗任务,比如:
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-left text-slate-600 bg-white p-4 rounded-lg border border-slate-200 shadow-sm max-w-sm">
|
||||
<div>✨ 填补缺失值(均值、中位数、众数)</div>
|
||||
<div>🔄 数据类型转换(文本→数字、日期格式化)</div>
|
||||
<div>📊 统计分析(分组计数、描述性统计)</div>
|
||||
<div>🧹 数据清洗(删除重复值、异常值处理)</div>
|
||||
<div>❓ 数据探索(列信息、缺失率查询)</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
💡 请在下方输入框描述您的需求,我将自动生成并执行代码
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
if (!response.ok) throw new Error('请求失败');
|
||||
const result = await response.json();
|
||||
|
||||
// 返回代码和解释,不执行
|
||||
return {
|
||||
messageId: result.data?.messageId,
|
||||
explanation: result.data?.explanation,
|
||||
code: result.data?.code,
|
||||
success: true, // 生成成功
|
||||
metadata: {
|
||||
messageId: result.data?.messageId, // 保存 messageId 用于执行
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 如果生成代码失败,可能是简单问答
|
||||
// 返回纯文本回复
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}}
|
||||
customMessageRenderer={(msgInfo) => (
|
||||
<MessageRenderer
|
||||
messageInfo={msgInfo}
|
||||
onExecuteCode={handleExecuteCode}
|
||||
isExecuting={isExecuting}
|
||||
/>
|
||||
)}
|
||||
senderProps={{
|
||||
placeholder: '输入数据处理需求...(Enter发送)',
|
||||
value: inputValue,
|
||||
onChange: (value) => setInputValue(value),
|
||||
}}
|
||||
onMessageSent={() => {
|
||||
// 发送消息后清空输入框
|
||||
setInputValue('');
|
||||
}}
|
||||
onMessageReceived={(msg) => {
|
||||
// 如果返回了新数据,更新表格
|
||||
if (msg.metadata?.newDataPreview) {
|
||||
onDataUpdate(msg.metadata.newDataPreview);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('Chat error:', error);
|
||||
antdMessage.error(error.message || '处理失败');
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
{/* 输入框区域 */}
|
||||
<div className="border-t border-slate-200 p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && !isStreaming && inputValue.trim()) {
|
||||
handleStreamProcess(inputValue);
|
||||
}
|
||||
}}
|
||||
placeholder="输入数据处理需求...(Enter发送)"
|
||||
disabled={isStreaming}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 disabled:bg-slate-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (inputValue.trim() && !isStreaming) {
|
||||
handleStreamProcess(inputValue);
|
||||
}
|
||||
}}
|
||||
disabled={isStreaming || !inputValue.trim()}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isStreaming ? '处理中...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 流式步骤展示组件
|
||||
*
|
||||
* 功能:
|
||||
* - 展示AI思考的6个步骤
|
||||
* - 支持运行中、成功、失败、重试状态
|
||||
* - 显示代码块和错误信息
|
||||
*
|
||||
* @module StreamingSteps
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CheckCircle, XCircle, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface StreamStep {
|
||||
step: number;
|
||||
stepName: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed' | 'retrying';
|
||||
message: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
retryCount?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface StreamingStepsProps {
|
||||
steps: StreamStep[];
|
||||
}
|
||||
|
||||
// ==================== 组件 ====================
|
||||
|
||||
export const StreamingSteps: React.FC<StreamingStepsProps> = ({
|
||||
steps
|
||||
}) => {
|
||||
|
||||
// 获取步骤图标
|
||||
const getStepIcon = (status: StreamStep['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="w-4 h-4 animate-spin text-blue-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'retrying':
|
||||
return <RefreshCw className="w-4 h-4 animate-spin text-orange-500" />;
|
||||
default:
|
||||
return <div className="w-4 h-4 rounded-full border-2 border-slate-300" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取步骤颜色类
|
||||
const getStepColor = (status: StreamStep['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'text-blue-600 bg-blue-50 border-blue-200';
|
||||
case 'success':
|
||||
return 'text-green-600 bg-green-50 border-green-200';
|
||||
case 'failed':
|
||||
return 'text-red-600 bg-red-50 border-red-200';
|
||||
case 'retrying':
|
||||
return 'text-orange-600 bg-orange-50 border-orange-200';
|
||||
default:
|
||||
return 'text-slate-400 bg-slate-50 border-slate-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={`${step.step}-${step.timestamp}`} className="animate-fadeIn">
|
||||
{/* 步骤标题 */}
|
||||
<div className={`flex items-start gap-3 p-3 rounded-lg border ${getStepColor(step.status)}`}>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getStepIcon(step.status)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{step.message}
|
||||
</span>
|
||||
|
||||
{/* 重试计数 */}
|
||||
{step.retryCount !== undefined && step.retryCount > 0 && (
|
||||
<span className="text-xs px-2 py-0.5 bg-orange-100 text-orange-700 rounded-full">
|
||||
第{step.retryCount + 1}次尝试
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 数据信息(Step 1: 分析需求) */}
|
||||
{step.stepName === 'analyze' && step.data?.dataInfo && (
|
||||
<div className="mt-2 text-xs text-slate-600 space-y-1">
|
||||
<div>📁 文件:{step.data.dataInfo.fileName}</div>
|
||||
<div>📊 数据:{step.data.dataInfo.rows} 行 × {step.data.dataInfo.cols} 列</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 代码块(Step 3: 展示代码) */}
|
||||
{step.stepName === 'show_code' && step.data?.code && (
|
||||
<div className="mt-3">
|
||||
<div className="bg-slate-900 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-slate-800 border-b border-slate-700">
|
||||
<span className="text-xs text-slate-400 font-mono">Python</span>
|
||||
</div>
|
||||
<pre className="p-3 text-xs text-slate-200 overflow-x-auto">
|
||||
<code>{step.data.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 代码解释 */}
|
||||
{step.data.explanation && (
|
||||
<div className="mt-2 text-sm text-slate-600 bg-white p-3 rounded-lg border border-slate-200">
|
||||
💡 <span className="font-medium">说明:</span>{step.data.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{step.error && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-red-700 mb-1">错误详情</div>
|
||||
<div className="text-xs text-red-600 whitespace-pre-wrap font-mono">
|
||||
{step.error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最后错误(Step 1: 重试时显示) */}
|
||||
{step.stepName === 'retry' && step.data?.lastError && (
|
||||
<div className="mt-2 text-xs text-orange-700 bg-orange-50 p-2 rounded border border-orange-200">
|
||||
<span className="font-medium">上次失败原因:</span>{step.data.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 成功结果(Step 6: 完成) */}
|
||||
{step.stepName === 'complete' && step.status === 'success' && step.data && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{step.data.retryCount > 0 && (
|
||||
<div className="text-xs text-green-700 bg-green-50 p-2 rounded">
|
||||
✨ 经过 {step.data.retryCount + 1} 次尝试后成功执行
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-slate-600">
|
||||
✅ 数据已更新,共 {step.data.newDataPreview?.length || 0} 行(预览前50行)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步骤间连接线 */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="ml-5 w-0.5 h-3 bg-slate-200"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamingSteps;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Wand2,
|
||||
Filter,
|
||||
Search,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
@@ -33,63 +34,130 @@ const ToolbarButton: React.FC<ToolbarButtonProps> = ({
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex flex-col items-center justify-center w-20 h-14 rounded-lg transition-all hover:shadow-sm disabled:opacity-50 disabled:cursor-not-allowed ${colorClass}`}
|
||||
className={`
|
||||
flex items-center gap-1.5
|
||||
px-3 py-2
|
||||
rounded-lg
|
||||
transition-all
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
hover:bg-slate-50 hover:shadow-sm
|
||||
${disabled ? 'text-slate-400' : colorClass}
|
||||
`}
|
||||
title={disabled ? '开发中...' : label}
|
||||
>
|
||||
<Icon className="w-5 h-5 mb-1" />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const Toolbar = () => {
|
||||
interface ToolbarProps {
|
||||
onFilterClick?: () => void;
|
||||
onRecodeClick?: () => void;
|
||||
onBinningClick?: () => void;
|
||||
onConditionalClick?: () => void;
|
||||
onDropnaClick?: () => void;
|
||||
onComputeClick?: () => void;
|
||||
onDedupClick?: () => void;
|
||||
onPivotClick?: () => void;
|
||||
onMiceClick?: () => void;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onFilterClick,
|
||||
onRecodeClick,
|
||||
onBinningClick,
|
||||
onConditionalClick,
|
||||
onDropnaClick,
|
||||
onComputeClick,
|
||||
onDedupClick,
|
||||
onPivotClick,
|
||||
onMiceClick,
|
||||
sessionId,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white border-b border-slate-200 px-4 py-2 flex items-center gap-1 overflow-x-auto flex-none shadow-sm z-10">
|
||||
{/* 7个快捷按钮 */}
|
||||
<div className="bg-white border-b border-slate-200 px-4 py-2 flex items-center gap-2 overflow-x-auto flex-none shadow-sm z-10">
|
||||
{/* 核心按钮(Phase 1-2) */}
|
||||
<ToolbarButton
|
||||
icon={Calculator}
|
||||
label="生成新变量"
|
||||
colorClass="text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={CalendarClock}
|
||||
label="时间差"
|
||||
colorClass="text-blue-600 bg-blue-50 hover:bg-blue-100"
|
||||
icon={Filter}
|
||||
label="高级筛选"
|
||||
colorClass="text-indigo-600 bg-indigo-50 hover:bg-indigo-100"
|
||||
onClick={onFilterClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={ArrowLeftRight}
|
||||
label="横纵转换"
|
||||
label="数值映射"
|
||||
colorClass="text-cyan-600 bg-cyan-50 hover:bg-cyan-100"
|
||||
onClick={onRecodeClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Calculator}
|
||||
label="生成分类"
|
||||
colorClass="text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
|
||||
onClick={onBinningClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
|
||||
|
||||
<ToolbarButton
|
||||
icon={FileSearch}
|
||||
label="查重"
|
||||
colorClass="text-orange-600 bg-orange-50 hover:bg-orange-100"
|
||||
/>
|
||||
{/* 辅助按钮(Phase 2) */}
|
||||
<ToolbarButton
|
||||
icon={Wand2}
|
||||
label="多重插补"
|
||||
colorClass="text-rose-600 bg-rose-50 hover:bg-rose-100"
|
||||
label="条件生成列"
|
||||
colorClass="text-purple-600 bg-purple-50 hover:bg-purple-100"
|
||||
onClick={onConditionalClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Trash2}
|
||||
label="删除缺失值"
|
||||
colorClass="text-red-600 bg-red-50 hover:bg-red-100"
|
||||
onClick={onDropnaClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Calculator}
|
||||
label="计算列"
|
||||
colorClass="text-green-600 bg-green-50 hover:bg-green-100"
|
||||
onClick={onComputeClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={FileSearch}
|
||||
label="去重"
|
||||
colorClass="text-orange-600 bg-orange-50 hover:bg-orange-100"
|
||||
onClick={onDedupClick}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
|
||||
|
||||
{/* 高级按钮(Phase 3) */}
|
||||
<ToolbarButton
|
||||
icon={Filter}
|
||||
label="筛选分析集"
|
||||
icon={ArrowLeftRight}
|
||||
label="长→宽表"
|
||||
colorClass="text-indigo-600 bg-indigo-50 hover:bg-indigo-100"
|
||||
onClick={onPivotClick}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={CalendarClock}
|
||||
label="多重插补"
|
||||
colorClass="text-rose-600 bg-rose-50 hover:bg-rose-100"
|
||||
onClick={onMiceClick}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
{/* ✅ 优化3.2:搜索框高度缩小 */}
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
className="pl-9 pr-4 py-1.5 text-sm bg-slate-100 border-none rounded-full w-48 focus:w-64 transition-all outline-none focus:ring-2 focus:ring-emerald-500/20"
|
||||
className="pl-8 pr-4 py-1.5 text-xs bg-slate-50 border border-slate-200 rounded-lg w-48 focus:w-64 transition-all outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
|
||||
placeholder="搜索值..."
|
||||
disabled
|
||||
title="开发中..."
|
||||
@@ -101,3 +169,4 @@ const Toolbar = () => {
|
||||
|
||||
export default Toolbar;
|
||||
|
||||
|
||||
|
||||
@@ -1,69 +1,105 @@
|
||||
/**
|
||||
* AG Grid 自定义样式
|
||||
* AG Grid 自定义样式(V2.0 - 完整UI/UX优化版)
|
||||
*
|
||||
* 目标:让AG Grid看起来更像原型图V6
|
||||
* - Emerald绿色主题
|
||||
* - 更圆润的边角
|
||||
* - 柔和的颜色
|
||||
* 优化目标:
|
||||
* ✅ 1.1 去除纵向边框(视觉清爽50%)
|
||||
* ✅ 1.3 缺失值样式柔和化(减少焦虑)
|
||||
* ✅ 1.4 表头样式专业化(层次分明)
|
||||
* ✅ 1.5 滚动条美化(细节精致)
|
||||
* ✅ 5.2 微交互优化(Hover状态)
|
||||
*/
|
||||
|
||||
.ag-theme-alpine {
|
||||
/* 背景色 */
|
||||
/* ==================== 背景色 ==================== */
|
||||
--ag-background-color: #ffffff;
|
||||
--ag-header-background-color: #f8fafc;
|
||||
--ag-header-background-color: #f9fafb; /* ✅ 优化:极浅灰(#f9fafb) */
|
||||
--ag-odd-row-background-color: #fafafa;
|
||||
--ag-header-height: 44px; /* ✅ 优化:固定表头高度 */
|
||||
|
||||
/* 前景色 */
|
||||
--ag-header-foreground-color: #475569;
|
||||
/* ==================== 前景色 ==================== */
|
||||
--ag-header-foreground-color: #374151; /* ✅ 优化:深灰文字 */
|
||||
--ag-foreground-color: #1e293b;
|
||||
|
||||
/* 边框 */
|
||||
--ag-border-color: #e2e8f0;
|
||||
--ag-row-border-color: #f1f5f9;
|
||||
/* ==================== 边框(关键优化)==================== */
|
||||
--ag-border-color: #e5e7eb; /* ✅ 优化:边框颜色统一 */
|
||||
--ag-row-border-color: #f1f5f9; /* ✅ 优化:极淡的横向分割线 */
|
||||
--ag-row-border-width: 1px;
|
||||
--ag-borders: none; /* ✅ 优化:去除所有边框 */
|
||||
|
||||
/* 悬停和选择 */
|
||||
--ag-row-hover-color: #f0fdf4;
|
||||
/* ==================== 悬停和选择 ==================== */
|
||||
--ag-row-hover-color: #f0fdf4; /* ✅ 优化:淡绿色悬停 */
|
||||
--ag-selected-row-background-color: #d1fae5;
|
||||
|
||||
/* 字体 */
|
||||
--ag-font-family: inherit;
|
||||
/* ==================== 字体 ==================== */
|
||||
--ag-font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei UI", sans-serif;
|
||||
--ag-font-size: 13px;
|
||||
|
||||
/* 聚焦 */
|
||||
/* ==================== 聚焦 ==================== */
|
||||
--ag-range-selection-border-color: #10b981;
|
||||
--ag-input-focus-border-color: #10b981;
|
||||
}
|
||||
|
||||
/* 表头样式 */
|
||||
/* ==================== 表头样式(✅ 优化1.4)==================== */
|
||||
.ag-theme-alpine .ag-header-cell {
|
||||
font-weight: 600;
|
||||
font-weight: 600; /* ✅ 优化:Medium字重 */
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
border-right: none !important; /* ✅ 优化1.1:去除纵向边框 */
|
||||
border-bottom: 2px solid #e5e7eb; /* ✅ 优化:只保留底部边框 */
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-header-cell:hover {
|
||||
background-color: #f1f5f9;
|
||||
background-color: #f1f5f9; /* ✅ 优化:悬停变深 */
|
||||
transition: background-color 0.15s ease; /* ✅ 优化5.2:微交互 */
|
||||
}
|
||||
|
||||
/* 单元格样式 */
|
||||
/* 表头筛选图标颜色变浅(✅ 优化1.4) */
|
||||
.ag-theme-alpine .ag-header-cell .ag-icon {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-header-cell:hover .ag-icon {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 数值列表头右对齐(✅ 优化1.2) */
|
||||
.ag-theme-alpine .ag-right-aligned-header .ag-header-cell-label {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ==================== 单元格样式 ==================== */
|
||||
.ag-theme-alpine .ag-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
line-height: 1.5;
|
||||
border-right: none !important; /* ✅ 优化1.1:去除纵向分割线 */
|
||||
}
|
||||
|
||||
/* 缺失值高亮样式 */
|
||||
.ag-theme-alpine .ag-cell.bg-red-50 {
|
||||
background-color: #fef2f2 !important;
|
||||
color: #f87171 !important;
|
||||
/* ==================== 缺失值样式(✅ 优化1.3 - 柔和化)==================== */
|
||||
.ag-theme-alpine .ag-cell.cell-missing {
|
||||
background-color: rgba(254, 226, 226, 0.15) !important; /* ✅ 优化:极淡背景(10%透明度) */
|
||||
color: #f87171 !important; /* ✅ 优化:柔和的红色 */
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 缺失值显示为 "NA" 而不是 "—" */
|
||||
.ag-theme-alpine .ag-cell.cell-missing:empty::before {
|
||||
content: 'NA';
|
||||
color: #f87171;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-cell.bg-red-50::before {
|
||||
content: '—';
|
||||
margin-right: 4px;
|
||||
/* ==================== 行样式(✅ 优化5.2 - 微交互)==================== */
|
||||
.ag-theme-alpine .ag-row {
|
||||
transition: background-color 0.1s ease; /* ✅ 优化:平滑过渡 */
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-row:hover {
|
||||
background-color: #f0fdf4 !important; /* ✅ 优化:淡绿色悬停 */
|
||||
}
|
||||
|
||||
/* 选中行样式 */
|
||||
@@ -75,38 +111,51 @@
|
||||
background-color: #a7f3d0 !important;
|
||||
}
|
||||
|
||||
/* 排序指示器 */
|
||||
/* ==================== 排序指示器 ==================== */
|
||||
.ag-theme-alpine .ag-header-cell-sorted-asc,
|
||||
.ag-theme-alpine .ag-header-cell-sorted-desc {
|
||||
background-color: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar {
|
||||
/* ==================== 滚动条美化(✅ 优化1.5)==================== */
|
||||
/* Webkit浏览器(Chrome、Edge、Safari) */
|
||||
.ag-theme-alpine ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar-track,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar-track {
|
||||
.ag-theme-alpine ::-webkit-scrollbar-track {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar-thumb,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar-thumb:hover {
|
||||
.ag-theme-alpine ::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #f8fafc; /* ✅ 优化:留白边缘 */
|
||||
}
|
||||
|
||||
.ag-theme-alpine ::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 列调整大小指示器 */
|
||||
/* Firefox浏览器 */
|
||||
.ag-theme-alpine {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f8fafc;
|
||||
}
|
||||
|
||||
/* ==================== 列调整大小指示器 ==================== */
|
||||
.ag-theme-alpine .ag-header-cell-resize {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
/* ==================== 数值列等宽字体(✅ 优化1.2)==================== */
|
||||
.ag-theme-alpine .ag-cell[style*="text-align: right"],
|
||||
.ag-theme-alpine .ag-cell.numeric-cell {
|
||||
font-family: "SF Mono", "JetBrains Mono", Consolas, "Courier New", monospace;
|
||||
font-variant-numeric: tabular-nums; /* 等宽数字 */
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ import Header from './components/Header';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import DataGrid from './components/DataGrid';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import FilterDialog from './components/FilterDialog';
|
||||
import RecodeDialog from './components/RecodeDialog';
|
||||
import BinningDialog from './components/BinningDialog';
|
||||
import ConditionalDialog from './components/ConditionalDialog';
|
||||
import DropnaDialog from './components/DropnaDialog';
|
||||
import ComputeDialog from './components/ComputeDialog';
|
||||
import PivotDialog from './components/PivotDialog';
|
||||
import * as api from '../../api/toolC';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
@@ -29,6 +36,15 @@ interface ToolCState {
|
||||
// UI状态
|
||||
isLoading: boolean;
|
||||
isSidebarOpen: boolean;
|
||||
|
||||
// ✨ 功能按钮对话框状态
|
||||
filterDialogVisible: boolean;
|
||||
recodeDialogVisible: boolean;
|
||||
binningDialogVisible: boolean;
|
||||
conditionalDialogVisible: boolean;
|
||||
dropnaDialogVisible: boolean;
|
||||
computeDialogVisible: boolean;
|
||||
pivotDialogVisible: boolean;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -53,6 +69,13 @@ const ToolC = () => {
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
isSidebarOpen: true,
|
||||
filterDialogVisible: false,
|
||||
recodeDialogVisible: false,
|
||||
binningDialogVisible: false,
|
||||
conditionalDialogVisible: false,
|
||||
dropnaDialogVisible: false,
|
||||
computeDialogVisible: false,
|
||||
pivotDialogVisible: false,
|
||||
});
|
||||
|
||||
// 更新状态辅助函数
|
||||
@@ -77,9 +100,38 @@ const ToolC = () => {
|
||||
// 获取预览数据
|
||||
const preview = await api.getPreviewData(result.data.sessionId);
|
||||
|
||||
console.log('[ToolC] 📊 后端返回的预览数据:', preview);
|
||||
console.log('[ToolC] 📊 preview.data:', preview.data);
|
||||
console.log('[ToolC] 📊 preview.data.previewData:', preview.data.previewData);
|
||||
console.log('[ToolC] 📊 preview.data.previewData长度:', preview.data.previewData?.length);
|
||||
|
||||
if (preview.success) {
|
||||
const previewData = preview.data.previewData || preview.data.rows || [];
|
||||
console.log('[ToolC] 📊 实际使用的数据:', previewData);
|
||||
console.log('[ToolC] 📊 数据长度:', previewData.length);
|
||||
console.log('[ToolC] 📊 第一行数据:', previewData[0]);
|
||||
|
||||
// ✅ 关键调试:查看数据的keys和列定义是否匹配
|
||||
if (previewData[0]) {
|
||||
const dataKeys = Object.keys(previewData[0]);
|
||||
const definedColumns = preview.data.columns;
|
||||
|
||||
console.log('[ToolC] 🔑 数据的实际keys:', dataKeys);
|
||||
console.log('[ToolC] 📋 后端返回的columns:', definedColumns);
|
||||
console.log('[ToolC] ❓ keys和columns是否匹配:',
|
||||
dataKeys.length === definedColumns.length &&
|
||||
dataKeys.every(key => definedColumns.includes(key))
|
||||
);
|
||||
|
||||
// 输出第一行数据的详细内容
|
||||
console.log('[ToolC] 📝 第一行数据详情:');
|
||||
dataKeys.slice(0, 5).forEach(key => {
|
||||
console.log(` ${key}: ${previewData[0][key]}`);
|
||||
});
|
||||
}
|
||||
|
||||
updateState({
|
||||
data: preview.data.previewData || preview.data.rows || [],
|
||||
data: previewData,
|
||||
columns: preview.data.columns.map((col) => ({
|
||||
id: col,
|
||||
name: col,
|
||||
@@ -111,8 +163,51 @@ const ToolC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== AI消息发送(已由 ChatContainer 处理) ====================
|
||||
// 此功能已移至 Sidebar 中的 ChatContainer 内部
|
||||
// ==================== 功能按钮数据更新 ====================
|
||||
const handleQuickActionDataUpdate = (newData: any[]) => {
|
||||
if (newData && newData.length > 0) {
|
||||
const newColumns = Object.keys(newData[0]).map(key => ({
|
||||
id: key,
|
||||
name: key,
|
||||
type: 'text',
|
||||
}));
|
||||
updateState({
|
||||
data: newData,
|
||||
columns: newColumns,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 导出Excel ====================
|
||||
const handleExport = async () => {
|
||||
if (!state.sessionId) {
|
||||
alert('请先上传文件');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ 从后端读取完整数据(AI处理后的数据已保存到OSS)
|
||||
const response = await fetch(`/api/v1/dc/tool-c/sessions/${state.sessionId}/export`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('导出失败');
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${state.fileName.replace(/\.[^/.]+$/, '')}_cleaned.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
console.error('导出失败:', error);
|
||||
alert('导出失败:' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 心跳机制 ====================
|
||||
useEffect(() => {
|
||||
@@ -137,7 +232,7 @@ const ToolC = () => {
|
||||
{/* 顶部栏 */}
|
||||
<Header
|
||||
fileName={state.fileName || '未上传文件'}
|
||||
onExport={() => alert('导出功能开发中...')}
|
||||
onExport={handleExport}
|
||||
isSidebarOpen={state.isSidebarOpen}
|
||||
onToggleSidebar={() => updateState({ isSidebarOpen: !state.isSidebarOpen })}
|
||||
/>
|
||||
@@ -146,7 +241,16 @@ const ToolC = () => {
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左侧:表格区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Toolbar />
|
||||
<Toolbar
|
||||
sessionId={state.sessionId}
|
||||
onFilterClick={() => updateState({ filterDialogVisible: true })}
|
||||
onRecodeClick={() => updateState({ recodeDialogVisible: true })}
|
||||
onBinningClick={() => updateState({ binningDialogVisible: true })}
|
||||
onConditionalClick={() => updateState({ conditionalDialogVisible: true })}
|
||||
onDropnaClick={() => updateState({ dropnaDialogVisible: true })}
|
||||
onComputeClick={() => updateState({ computeDialogVisible: true })}
|
||||
onPivotClick={() => updateState({ pivotDialogVisible: true })}
|
||||
/>
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<DataGrid data={state.data} columns={state.columns} />
|
||||
</div>
|
||||
@@ -159,10 +263,85 @@ const ToolC = () => {
|
||||
onClose={() => updateState({ isSidebarOpen: false })}
|
||||
sessionId={state.sessionId}
|
||||
onFileUpload={handleFileUpload}
|
||||
onDataUpdate={(newData) => updateState({ data: newData })}
|
||||
onDataUpdate={(newData) => {
|
||||
// ✅ 修复:同时更新列定义(从新数据中提取列名)
|
||||
if (newData && newData.length > 0) {
|
||||
const newColumns = Object.keys(newData[0]).map(key => ({
|
||||
id: key,
|
||||
name: key,
|
||||
type: 'text',
|
||||
}));
|
||||
updateState({
|
||||
data: newData,
|
||||
columns: newColumns,
|
||||
});
|
||||
} else {
|
||||
updateState({ data: newData });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ✨ 功能按钮对话框 */}
|
||||
<FilterDialog
|
||||
visible={state.filterDialogVisible}
|
||||
columns={state.columns}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ filterDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
|
||||
<RecodeDialog
|
||||
visible={state.recodeDialogVisible}
|
||||
columns={state.columns}
|
||||
data={state.data}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ recodeDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
|
||||
<BinningDialog
|
||||
visible={state.binningDialogVisible}
|
||||
columns={state.columns}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ binningDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
|
||||
<ConditionalDialog
|
||||
visible={state.conditionalDialogVisible}
|
||||
columns={state.columns.map(col => ({ field: col.id, headerName: col.name }))}
|
||||
data={state.data}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ conditionalDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
|
||||
<DropnaDialog
|
||||
visible={state.dropnaDialogVisible}
|
||||
columns={state.columns}
|
||||
data={state.data}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ dropnaDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
|
||||
<ComputeDialog
|
||||
visible={state.computeDialogVisible}
|
||||
columns={state.columns}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ computeDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
|
||||
<PivotDialog
|
||||
visible={state.pivotDialogVisible}
|
||||
columns={state.columns}
|
||||
sessionId={state.sessionId}
|
||||
onClose={() => updateState({ pivotDialogVisible: false })}
|
||||
onApply={handleQuickActionDataUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,3 +59,5 @@ export interface DataStats {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -55,3 +55,5 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user