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

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

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

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

View File

@@ -107,3 +107,5 @@ export const useAssets = (activeTab: AssetTabType) => {

View File

@@ -97,3 +97,5 @@ export const useRecentTasks = () => {

View File

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

View File

@@ -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&lt;1818-60&gt;60</div>
<div> <strong></strong> <code className="bg-white px-1">, , </code></div>
<div> <strong></strong>-1 = 23</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;

View File

@@ -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> logsqrt等函数对负数/NaN</div>
<div> 使</div>
</div>
</div>
}
type="warning"
showIcon={false}
className="bg-yellow-50 border-yellow-200"
/>
</div>
</Modal>
);
};
export default ComputeDialog;

View File

@@ -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 &gt;= 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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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.2Logo字体加粗加深 */}
<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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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; /* 等宽数字 */
}

View File

@@ -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>
);
};

View File

@@ -59,3 +59,5 @@ export interface DataStats {
};
}

View File

@@ -55,3 +55,5 @@ export type AssetTabType = 'all' | 'processed' | 'raw';