feat(dc-tool-c): Tool C UX重大改进 - 列头筛选/行号/滚动条/全量数据

新功能
- 列头筛选:Excel风格筛选功能(Community版本,中文本地化,显示唯一值及计数)
- 行号列:添加固定行号列(#列头,灰色背景,左侧固定)
- 全量数据加载:不再限制50行预览,Session加载全量数据
- 全量数据返回:所有快速操作(筛选/映射/分箱/条件/删NA/计算/Pivot)全量返回结果

 Bug修复
- 滚动条终极修复:修改MainLayout为固定高度(h-screen + overflow-hidden),整个浏览器窗口无滚动条,只有AG Grid内部滚动
- 计算列全角字符修复:自动转换中文括号等全角字符为半角
- 计算列特殊字符列名修复:完善列别名机制,支持任意特殊字符列名

 UI优化
- 删除'表格仅展示前50行'提示条,减少干扰
- 筛选对话框美化:白色背景,圆角,阴影
- 列头筛选图标优化:清晰可见,易于点击

 文档更新
- 工具C_功能按钮开发计划_V1.0.md:添加V1.5版本记录
- 工具C_MVP开发_TODO清单.md:添加Day 8 UX优化内容
- 00-工具C当前状态与开发指南.md:更新进度为98%
- 00-模块当前状态与开发指南.md:更新DC模块状态
- 00-系统当前状态与开发指南.md:更新系统整体状态

 影响范围
- Python微服务:无修改
- Node.js后端:5处代码修改(SessionService + QuickActionController + AICodeService)
- 前端:MainLayout + DataGrid + ag-grid-custom.css + index.tsx
- 完成度:Tool C整体完成度提升至98%

 代码统计
- 修改文件:~15个文件
- 新增行数:~200行
- 修改行数:~150行

Co-authored-by: AI Assistant <assistant@example.com>
This commit is contained in:
2025-12-10 18:02:42 +08:00
parent 74cf346453
commit 200eab5c2e
120 changed files with 640 additions and 249 deletions

View File

@@ -13,7 +13,7 @@ from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from loguru import logger
from pathlib import Path
import os
@@ -168,6 +168,7 @@ class FillnaMiceRequest(BaseModel):
"""MICE多重插补请求模型"""
data: List[Dict[str, Any]]
columns: List[str]
reference_columns: Optional[List[str]] = None # ⭐ 新增:参考列
n_iterations: int = 10
random_state: int = 42
@@ -1434,6 +1435,7 @@ async def operation_fillna_mice(request: FillnaMiceRequest):
result = fillna_mice(
df,
request.columns,
request.reference_columns, # ⭐ 新增:传递参考列
request.n_iterations,
request.random_state
)

View File

@@ -9,6 +9,58 @@ import re
from typing import Dict, Any
def normalize_formula(formula: str) -> str:
"""
规范化公式字符串:将全角字符转换为半角字符
解决用户使用中文输入法导致的全角字符问题
Args:
formula: 原始公式字符串
Returns:
规范化后的公式字符串
示例:
normalize_formula("体重/(身高/100)**2")
-> "体重/(身高/100)**2"
"""
# 全角到半角的映射
full_to_half = {
# 括号
'': '(',
'': ')',
'': '[',
'': ']',
'': '{',
'': '}',
# 运算符
'': '+',
'': '-',
'': '*',
'': '/',
'': '%',
# 数字
'': '0', '': '1', '': '2', '': '3', '': '4',
'': '5', '': '6', '': '7', '': '8', '': '9',
# 其他符号
'': '.',
'': ',',
'': ':',
'': ';',
'': '=',
'': '<',
'': '>',
' ': ' ', # 全角空格
}
result = formula
for full_char, half_char in full_to_half.items():
result = result.replace(full_char, half_char)
return result
# 允许的函数(安全白名单)
ALLOWED_FUNCTIONS = {
'abs': abs,
@@ -73,7 +125,8 @@ def validate_formula(formula: str, available_columns: list) -> tuple[bool, str]:
def compute_column(
df: pd.DataFrame,
new_column_name: str,
formula: str
formula: str,
column_mapping: list = None # ⭐ 新增参数(兼容旧版本调用)
) -> pd.DataFrame:
"""
基于公式计算新列
@@ -85,6 +138,8 @@ def compute_column(
- 支持列名引用(如:身高, 体重)
- 支持运算符(+, -, *, /, **, %
- 支持函数abs, round, sqrt, log, exp等
column_mapping: 列名映射(可选)
- 用于处理带空格/特殊字符的列名
Returns:
添加了新列的数据框
@@ -102,60 +157,116 @@ def compute_column(
result = df.copy()
print(f'计算新列: {new_column_name}')
print(f'公式: {formula}')
print(f'原始公式: {formula}')
# ⭐ 步骤1规范化公式转换全角字符
formula = normalize_formula(formula)
print(f'规范化后: {formula}')
print(f'收到列名映射: {len(column_mapping or [])} 个列')
print('')
# 验证公式
is_valid, error_msg = validate_formula(formula, list(result.columns))
if not is_valid:
raise ValueError(f'公式验证失败: {error_msg}')
# 准备执行环境
# 1. 添加数据框的列作为变量(自动转换数值类型)
# ⭐ 步骤2彻底解决方案 - 使用Node.js传来的column_mapping
env = {}
# ✨ 增强:处理列名中的特殊字符
# 创建列名映射:将公式中的列名替换为安全的变量名
col_mapping = {}
formula_safe = formula
normalized_mapping = [] # ⭐ 在外面定义,供后续使用
for i, col in enumerate(result.columns):
# 为每个列创建一个安全的变量名
safe_var = f'col_{i}'
col_mapping[col] = safe_var
if column_mapping and len(column_mapping) > 0:
print('使用传入的列名映射(支持任意特殊字符):')
# 在公式中替换列名(完整匹配,避免部分替换)
# 使用正则表达式确保只替换完整的列名
import re
# 转义列名中的特殊字符
col_escaped = re.escape(col)
# 替换公式中的列名(前后必须是边界
formula_safe = re.sub(rf'\b{col_escaped}\b', safe_var, formula_safe)
# ⭐ 关键修复对originalName也做normalize确保匹配成功
# 原因:用户输入"身高(cm)",但列名是"身高Cm"normalize后才能匹配
for mapping in column_mapping:
original_name = mapping.get('originalName', '')
safe_name = mapping.get('safeName', '')
# ⭐ 对列名也做normalize全角→半角
normalized_name = normalize_formula(original_name)
normalized_mapping.append({
'originalName': original_name, # 保留原始名用于访问DataFrame
'normalizedName': normalized_name, # 标准化名(用于匹配)
'safeName': safe_name
})
# 尝试将列转换为数值类型
try:
# 如果列可以转换为数值,就转换
numeric_col = pd.to_numeric(result[col], errors='coerce')
# 如果转换后不全是NaN说明是数值列
if not numeric_col.isna().all():
env[safe_var] = numeric_col
print(f'"{col}" -> {safe_var} (数值类型)')
else:
# 否则保持原样
# ⭐ 关键:按标准化后的列名长度排序(从长到短),避免部分匹配
sorted_mapping = sorted(
normalized_mapping,
key=lambda x: len(x['normalizedName']),
reverse=True
)
# ⭐ 使用简单字符串replace不用正则彻底解决特殊字符问题
for mapping in sorted_mapping:
normalized_name = mapping['normalizedName']
safe_name = mapping['safeName']
original_name = mapping['originalName']
if normalized_name and safe_name:
# ⭐ 在标准化后的空间匹配(全角→半角后)
if normalized_name in formula_safe:
formula_safe = formula_safe.replace(normalized_name, safe_name)
print(f' "{original_name}" (标准化: "{normalized_name}") -> {safe_name}')
# ⭐ 准备执行环境使用safeName作为变量名
print('')
print('准备执行环境:')
for mapping in normalized_mapping:
original_name = mapping['originalName'] # 使用原始列名访问DataFrame
safe_name = mapping['safeName']
if original_name and safe_name and original_name in result.columns:
# 尝试转换为数值类型
try:
numeric_col = pd.to_numeric(result[original_name], errors='coerce')
if not numeric_col.isna().all():
env[safe_name] = numeric_col
print(f' {safe_name} = DataFrame["{original_name}"] (数值)')
else:
env[safe_name] = result[original_name]
print(f' {safe_name} = DataFrame["{original_name}"]')
except Exception:
env[safe_name] = result[original_name]
print(f' {safe_name} = DataFrame["{original_name}"]')
else:
# ⭐ Fallback兼容旧版本没有column_mapping时
print('⚠️ 未传入列名映射,使用自动生成(可能不支持特殊字符):')
for i, col in enumerate(result.columns):
safe_var = f'col_{i}'
# 简单替换(尽力而为)
if col in formula_safe:
formula_safe = formula_safe.replace(col, safe_var)
print(f' "{col}" -> {safe_var}')
# 准备执行环境
try:
numeric_col = pd.to_numeric(result[col], errors='coerce')
if not numeric_col.isna().all():
env[safe_var] = numeric_col
else:
env[safe_var] = result[col]
except Exception:
env[safe_var] = result[col]
print(f'"{col}" -> {safe_var}')
except Exception:
# 转换失败,保持原样
env[safe_var] = result[col]
print(f'"{col}" -> {safe_var}')
# 2. 添加允许的函数
# 验证公式(使用转换后的安全公式)
# 注意validate_formula现在检查的是别名后的公式所以会失败
# 我们跳过验证,或者只做基本的安全检查
print('')
print(f'最终公式: {formula_safe}')
# 基本安全检查(不依赖列名)
dangerous_patterns = ['__', 'import', 'exec', 'eval', 'open', 'compile', 'globals', 'locals', '__builtins__']
for pattern in dangerous_patterns:
if pattern in formula_safe.lower():
raise ValueError(f'公式包含不允许的操作: {pattern}')
# 2. 添加允许的函数到执行环境
env.update(ALLOWED_FUNCTIONS)
# 3. 添加numpy用于数学运算
env['np'] = np
print(f' 使用安全公式: {formula_safe}')
print('')
print(f'准备执行公式: {formula_safe}')
print('')
try:
@@ -165,20 +276,31 @@ def compute_column(
# ✨ 优化:将新列插入到第一个引用列的旁边
# 找到公式中引用的第一个列
first_ref_col = None
for col in result.columns:
safe_var = col_mapping.get(col)
if safe_var and safe_var in formula_safe:
first_ref_col = col
break
if normalized_mapping and len(normalized_mapping) > 0:
# 使用传入的映射查找
for mapping in normalized_mapping:
safe_name = mapping['safeName']
original_name = mapping['originalName']
if safe_name in formula_safe and original_name in result.columns:
first_ref_col = original_name
break
else:
# Fallback遍历所有列查找
for i, col in enumerate(result.columns):
safe_var = f'col_{i}'
if safe_var in formula_safe:
first_ref_col = col
break
if first_ref_col:
ref_col_index = result.columns.get_loc(first_ref_col)
result.insert(ref_col_index + 1, new_column_name, computed_values)
print(f'计算成功!新列插入在 {first_ref_col} 旁边')
print(f'计算成功!新列插入在 "{first_ref_col}" 旁边')
else:
# 如果找不到引用列,添加到最后
result[new_column_name] = computed_values
print(f'计算成功!')
print(f'计算成功!新列添加到最后')
print(f'新列类型: {result[new_column_name].dtype}')
print(f'新列前5个值:')
# 安全打印避免NaN/inf导致序列化错误

View File

@@ -311,15 +311,17 @@ def fillna_simple(
def fillna_mice(
df: pd.DataFrame,
columns: List[str],
reference_columns: Optional[List[str]] = None,
n_iterations: int = 10,
random_state: int = 42
) -> Dict[str, Any]:
"""
MICE多重插补创建新列必须实现
MICE多重插补创建新列支持参考列
Args:
df: 输入数据框
columns: 要填补的列名列表(如["体重kg", "收缩压mmHg"]
columns: 要填补的列名列表(如["体重kg", "收缩压mmHg"]- 会创建新列
reference_columns: 参考列名列表(用于预测,不创建新列)⭐ 新增
n_iterations: 迭代次数默认10范围5-50
random_state: 随机种子默认42确保结果可重复
@@ -350,11 +352,16 @@ def fillna_mice(
4. 返回包含所有新列的完整数据框
示例:
原列:体重kg、收缩压mmHg
新列体重kg_MICE、收缩压mmHg_MICE
结果顺序体重kg、体重kg_MICE、收缩压mmHg、收缩压mmHg_MICE、...
target: 体重kg、收缩压mmHg
reference: 年龄、身高、性别
MICE计算使用5列2个target + 3个reference
新列体重kg_MICE、收缩压mmHg_MICE只创建2个
"""
print(f"[fillna_mice] 开始MICE填补: 列={columns}, 迭代次数={n_iterations}", flush=True)
# 处理参考列默认值
if reference_columns is None:
reference_columns = []
print(f"[fillna_mice] 开始MICE填补: 列={columns}, 参考列={reference_columns}, 迭代次数={n_iterations}", flush=True)
try:
from sklearn.experimental import enable_iterative_imputer
@@ -431,11 +438,45 @@ def fillna_mice(
f" 对于分类变量(如:婚姻状况、性别、职业),请使用'众数填补'"
)
# 提取有效的数值列进行填补
df_subset = result[valid_numeric_columns].copy()
# ⭐ 处理参考列(用于预测,不创建新列)
valid_reference_columns = []
skipped_reference_columns = []
# 将所有列转换为数值(现在这些都是数值型列了)
for col in valid_numeric_columns:
if reference_columns:
print(f"[fillna_mice] 开始处理参考列...", flush=True)
for ref_col in reference_columns:
if ref_col not in result.columns:
print(f"[fillna_mice] ⚠️ 参考列 '{ref_col}' 不存在,已跳过", flush=True)
continue
# 检查是否为数值型
ref_col_data = result[ref_col]
numeric_col = pd.to_numeric(ref_col_data, errors='coerce')
valid_count = int(ref_col_data.notna().sum())
numeric_valid_count = int(numeric_col.notna().sum())
if valid_count == 0:
print(f"[fillna_mice] ⚠️ 参考列 '{ref_col}' 100%缺失,已跳过", flush=True)
skipped_reference_columns.append(ref_col)
elif numeric_valid_count == 0:
print(f"[fillna_mice] ⚠️ 参考列 '{ref_col}' 是分类变量,已跳过", flush=True)
skipped_reference_columns.append(ref_col)
elif numeric_valid_count < valid_count * 0.5:
print(f"[fillna_mice] ⚠️ 参考列 '{ref_col}' 数据类型混乱,已跳过", flush=True)
skipped_reference_columns.append(ref_col)
else:
valid_reference_columns.append(ref_col)
print(f"[fillna_mice] ✓ 参考列 '{ref_col}' 检测为数值列将用于MICE预测", flush=True)
# ⭐ 合并target列和reference列进行MICE计算
all_mice_columns = valid_numeric_columns + valid_reference_columns
print(f"[fillna_mice] MICE将使用 {len(all_mice_columns)} 列进行计算: {len(valid_numeric_columns)}个目标列 + {len(valid_reference_columns)}个参考列", flush=True)
# 提取所有MICE计算需要的列
df_subset = result[all_mice_columns].copy()
# 将所有列转换为数值
for col in all_mice_columns:
df_subset[col] = pd.to_numeric(df_subset[col], errors='coerce')
# 检查是否至少有一列有缺失值
@@ -476,7 +517,8 @@ def fillna_mice(
try:
imputed_array = imputer.fit_transform(df_subset)
df_imputed = pd.DataFrame(imputed_array, columns=columns, index=df_subset.index)
# ⭐ 修复使用all_mice_columns包含target列和reference列
df_imputed = pd.DataFrame(imputed_array, columns=all_mice_columns, index=df_subset.index)
print(f"[fillna_mice] MICE填补完成", flush=True)
@@ -535,12 +577,20 @@ def fillna_mice(
result_json = result.replace({np.nan: None, np.inf: None, -np.inf: None}).to_dict('records')
total_filled = sum(s['filled_count'] for s in stats_dict.values())
# 构建消息
message_parts = []
message_parts.append(f"MICE填补完成共填补 {total_filled} 个缺失值")
message_parts.append(f"创建了 {len(valid_numeric_columns)} 个新列")
if len(valid_reference_columns) > 0:
message_parts.append(f"使用了 {len(valid_reference_columns)} 个参考列进行预测")
if len(columns_to_skip) > 0:
skip_summary = ", ".join([f"{col}({skip_reasons[col]})" for col in columns_to_skip])
skip_info = f"跳过{len(columns_to_skip)}列:{skip_summary}请使用众数填补)"
else:
skip_info = ""
message = f"MICE填补完成共填补 {total_filled} 个缺失值,创建了 {len(columns)} 个新列{skip_info}"
message_parts.append(f"跳过{len(columns_to_skip)}列:{skip_summary}请使用众数填补)")
message = "".join(message_parts)
return {
'success': True,