Major features: 1. Pivot transformation enhancements: - Add option to keep unselected columns with 3 aggregation methods - Maintain original column order after pivot (aligned with source file) - Preserve pivot value order (first appearance order) 2. NA handling across 4 core functions: - Recode: Support keep/map/drop for NA values - Filter: Already supports is_null/not_null operators - Binning: Support keep/label/assign for NA values (fix nan display) - Conditional: Add is_null/not_null operators 3. UI improvements: - Enable column header tooltips with custom header component - Add closeable alert for 50-row preview - Fix page scrollbar issues Modified files: Python: pivot.py, recode.py, binning.py, conditional.py, main.py Backend: SessionController, QuickActionController, QuickActionService Frontend: PivotDialog, RecodeDialog, BinningDialog, ConditionalDialog, DataGrid, index Status: Ready for testing
450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
/**
|
||
* Session控制器
|
||
*
|
||
* API端点:
|
||
* - POST /sessions/upload 上传Excel文件创建Session
|
||
* - GET /sessions/:id 获取Session信息
|
||
* - GET /sessions/:id/preview 获取预览数据(前100行)
|
||
* - GET /sessions/:id/full 获取完整数据
|
||
* - DELETE /sessions/:id 删除Session
|
||
* - POST /sessions/:id/heartbeat 更新心跳
|
||
*
|
||
* @module SessionController
|
||
*/
|
||
|
||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||
import { MultipartFile } from '@fastify/multipart';
|
||
import { logger } from '../../../../common/logging/index.js';
|
||
import { sessionService } from '../services/SessionService.js';
|
||
import { dataProcessService } from '../services/DataProcessService.js';
|
||
import * as xlsx from 'xlsx';
|
||
|
||
// ==================== 请求参数类型定义 ====================
|
||
|
||
interface SessionIdParams {
|
||
id: string;
|
||
}
|
||
|
||
interface GetUniqueValuesQuery {
|
||
column: string;
|
||
}
|
||
|
||
// ==================== 控制器 ====================
|
||
|
||
export class SessionController {
|
||
/**
|
||
* 上传Excel文件创建Session
|
||
*
|
||
* POST /api/v1/dc/tool-c/sessions/upload
|
||
*/
|
||
async upload(request: FastifyRequest, reply: FastifyReply) {
|
||
try {
|
||
logger.info('[SessionController] 收到文件上传请求');
|
||
|
||
// 1. 获取multipart数据
|
||
const data = await request.file();
|
||
|
||
if (!data) {
|
||
return reply.code(400).send({
|
||
success: false,
|
||
error: '未找到上传的文件',
|
||
});
|
||
}
|
||
|
||
const file = data as MultipartFile;
|
||
const fileName = file.filename;
|
||
|
||
logger.info(`[SessionController] 文件名: ${fileName}`);
|
||
|
||
// 2. 读取文件到Buffer
|
||
const fileBuffer = await file.toBuffer();
|
||
|
||
// 3. 验证文件
|
||
const validation = dataProcessService.validateFile(fileBuffer, fileName);
|
||
if (!validation.valid) {
|
||
return reply.code(400).send({
|
||
success: false,
|
||
error: validation.error,
|
||
});
|
||
}
|
||
|
||
// 4. 获取用户ID(从请求中提取,实际部署时从JWT获取)
|
||
// TODO: 从JWT token中获取userId
|
||
const userId = (request as any).userId || 'test-user-001';
|
||
|
||
// 5. 创建Session
|
||
const session = await sessionService.createSession(
|
||
userId,
|
||
fileName,
|
||
fileBuffer
|
||
);
|
||
|
||
logger.info(`[SessionController] Session创建成功: ${session.id}`);
|
||
|
||
// 6. 返回Session信息
|
||
return reply.code(201).send({
|
||
success: true,
|
||
message: 'Session创建成功',
|
||
data: {
|
||
sessionId: session.id,
|
||
fileName: session.fileName,
|
||
fileSize: dataProcessService.formatFileSize(session.fileSize),
|
||
totalRows: session.totalRows,
|
||
totalCols: session.totalCols,
|
||
columns: session.columns,
|
||
expiresAt: session.expiresAt,
|
||
createdAt: session.createdAt,
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 文件上传失败: ${error.message}`);
|
||
return reply.code(500).send({
|
||
success: false,
|
||
error: error.message || '文件上传失败,请重试',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取Session信息(只含元数据)
|
||
*
|
||
* GET /api/v1/dc/tool-c/sessions/:id
|
||
*/
|
||
async getSession(
|
||
request: FastifyRequest<{ Params: SessionIdParams }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
|
||
logger.info(`[SessionController] 获取Session: ${id}`);
|
||
|
||
const session = await sessionService.getSession(id);
|
||
|
||
return reply.code(200).send({
|
||
success: true,
|
||
data: {
|
||
sessionId: session.id,
|
||
fileName: session.fileName,
|
||
fileSize: dataProcessService.formatFileSize(session.fileSize),
|
||
totalRows: session.totalRows,
|
||
totalCols: session.totalCols,
|
||
columns: session.columns,
|
||
encoding: session.encoding,
|
||
expiresAt: session.expiresAt,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 获取Session失败: ${error.message}`);
|
||
|
||
const statusCode = error.message.includes('不存在') || error.message.includes('过期')
|
||
? 404
|
||
: 500;
|
||
|
||
return reply.code(statusCode).send({
|
||
success: false,
|
||
error: error.message || '获取Session失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取预览数据(前100行)
|
||
*
|
||
* GET /api/v1/dc/tool-c/sessions/:id/preview
|
||
*/
|
||
async getPreviewData(
|
||
request: FastifyRequest<{ Params: SessionIdParams }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
|
||
logger.info(`[SessionController] 获取预览数据: ${id}`);
|
||
|
||
const result = await sessionService.getPreviewData(id);
|
||
|
||
return reply.code(200).send({
|
||
success: true,
|
||
data: {
|
||
sessionId: result.id,
|
||
fileName: result.fileName,
|
||
totalRows: result.totalRows,
|
||
totalCols: result.totalCols,
|
||
columns: result.columns,
|
||
previewRows: result.previewData.length,
|
||
previewData: result.previewData,
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 获取预览数据失败: ${error.message}`);
|
||
|
||
const statusCode = error.message.includes('不存在') || error.message.includes('过期')
|
||
? 404
|
||
: 500;
|
||
|
||
return reply.code(statusCode).send({
|
||
success: false,
|
||
error: error.message || '获取预览数据失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取完整数据
|
||
*
|
||
* GET /api/v1/dc/tool-c/sessions/:id/full
|
||
*/
|
||
async getFullData(
|
||
request: FastifyRequest<{ Params: SessionIdParams }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
|
||
logger.info(`[SessionController] 获取完整数据: ${id}`);
|
||
|
||
const data = await sessionService.getFullData(id);
|
||
|
||
return reply.code(200).send({
|
||
success: true,
|
||
data: {
|
||
sessionId: id,
|
||
totalRows: data.length,
|
||
data,
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 获取完整数据失败: ${error.message}`);
|
||
|
||
const statusCode = error.message.includes('不存在') || error.message.includes('过期')
|
||
? 404
|
||
: 500;
|
||
|
||
return reply.code(statusCode).send({
|
||
success: false,
|
||
error: error.message || '获取完整数据失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除Session
|
||
*
|
||
* DELETE /api/v1/dc/tool-c/sessions/:id
|
||
*/
|
||
async deleteSession(
|
||
request: FastifyRequest<{ Params: SessionIdParams }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
|
||
logger.info(`[SessionController] 删除Session: ${id}`);
|
||
|
||
await sessionService.deleteSession(id);
|
||
|
||
return reply.code(200).send({
|
||
success: true,
|
||
message: 'Session删除成功',
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 删除Session失败: ${error.message}`);
|
||
return reply.code(500).send({
|
||
success: false,
|
||
error: error.message || '删除Session失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新心跳(延长过期时间)
|
||
*
|
||
* POST /api/v1/dc/tool-c/sessions/:id/heartbeat
|
||
*/
|
||
async updateHeartbeat(
|
||
request: FastifyRequest<{ Params: SessionIdParams }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
|
||
logger.info(`[SessionController] 更新心跳: ${id}`);
|
||
|
||
const newExpiresAt = await sessionService.updateHeartbeat(id);
|
||
|
||
return reply.code(200).send({
|
||
success: true,
|
||
message: '心跳更新成功',
|
||
data: {
|
||
sessionId: id,
|
||
expiresAt: newExpiresAt,
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 更新心跳失败: ${error.message}`);
|
||
|
||
const statusCode = error.message.includes('不存在')
|
||
? 404
|
||
: 500;
|
||
|
||
return reply.code(statusCode).send({
|
||
success: false,
|
||
error: error.message || '更新心跳失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ✨ 导出Excel文件(新增)
|
||
*
|
||
* GET /api/v1/dc/tool-c/sessions/:id/export
|
||
*/
|
||
async exportData(
|
||
request: FastifyRequest<{ Params: SessionIdParams }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
|
||
logger.info(`[SessionController] 导出Excel: ${id}`);
|
||
|
||
// 1. 获取Session信息
|
||
const session = await sessionService.getSession(id);
|
||
|
||
// 2. 获取完整数据
|
||
const data = await sessionService.getFullData(id);
|
||
|
||
// 3. 生成Excel
|
||
const workbook = xlsx.utils.book_new();
|
||
const worksheet = xlsx.utils.json_to_sheet(data);
|
||
|
||
// 设置列宽(自动调整)
|
||
const colWidths = session.columns.map(col => {
|
||
const maxLength = Math.max(
|
||
col.length,
|
||
...data.slice(0, 100).map(row => String(row[col] || '').length)
|
||
);
|
||
return { wch: Math.min(maxLength + 2, 50) };
|
||
});
|
||
worksheet['!cols'] = colWidths;
|
||
|
||
xlsx.utils.book_append_sheet(workbook, worksheet, 'Data');
|
||
|
||
// 4. 生成Buffer
|
||
const buffer = xlsx.write(workbook, {
|
||
type: 'buffer',
|
||
bookType: 'xlsx',
|
||
compression: true,
|
||
});
|
||
|
||
// 5. 生成文件名(加上_cleaned后缀和时间戳)
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
const baseFileName = session.fileName.replace(/\.[^/.]+$/, ''); // 去除扩展名
|
||
const exportFileName = `${baseFileName}_cleaned_${timestamp}.xlsx`;
|
||
|
||
logger.info(`[SessionController] 导出成功: ${exportFileName}, 大小: ${(buffer.length / 1024).toFixed(2)}KB`);
|
||
|
||
// 6. 返回文件
|
||
reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(exportFileName)}"`);
|
||
reply.header('Content-Length', buffer.length);
|
||
|
||
return reply.send(buffer);
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 导出Excel失败: ${error.message}`);
|
||
|
||
const statusCode = error.message.includes('不存在') || error.message.includes('过期')
|
||
? 404
|
||
: 500;
|
||
|
||
return reply.code(statusCode).send({
|
||
success: false,
|
||
error: error.message || '导出Excel失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ✨ 获取列的唯一值(用于数值映射)
|
||
*
|
||
* GET /api/v1/dc/tool-c/sessions/:id/unique-values?column=xxx
|
||
*/
|
||
async getUniqueValues(
|
||
request: FastifyRequest<{ Params: SessionIdParams; Querystring: GetUniqueValuesQuery }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const { id } = request.params;
|
||
const { column } = request.query;
|
||
|
||
if (!column) {
|
||
return reply.code(400).send({
|
||
success: false,
|
||
error: '缺少column参数',
|
||
});
|
||
}
|
||
|
||
logger.info(`[SessionController] 获取唯一值: session=${id}, column=${column}`);
|
||
|
||
// 1. 获取完整数据
|
||
const data = await sessionService.getFullData(id);
|
||
|
||
// 2. 提取唯一值(保留NA值,但清理字符串)
|
||
const values = data.map((row) => row[column]);
|
||
const cleanedValues = values.map((val) => {
|
||
if (val === null || val === undefined || val === '') return null;
|
||
// 如果是字符串,去除首尾空格
|
||
return typeof val === 'string' ? val.trim() : val;
|
||
});
|
||
|
||
// 3. 去重(✨ 保留null值,但用特殊标记表示)
|
||
const uniqueSet = Array.from(new Set(cleanedValues));
|
||
const hasNA = uniqueSet.includes(null);
|
||
const naCount = cleanedValues.filter(v => v === null).length; // ✨ 统计NA数量
|
||
|
||
// 过滤掉null和空白,然后排序
|
||
const nonNAValues = uniqueSet
|
||
.filter((v) => v !== null && v !== '' && v !== '(空白)')
|
||
.sort(); // 排序,方便查看
|
||
|
||
// ✨ 如果有NA,添加到数组末尾(用特殊字符串标记)
|
||
const uniqueValues = hasNA
|
||
? [...nonNAValues, '<空值/NA>']
|
||
: nonNAValues;
|
||
|
||
logger.info(`[SessionController] 唯一值数量: ${uniqueValues.length} (含NA: ${hasNA}, NA数量: ${naCount})`);
|
||
|
||
// 4. 返回结果
|
||
return reply.send({
|
||
success: true,
|
||
data: {
|
||
column,
|
||
uniqueValues,
|
||
count: uniqueValues.length,
|
||
naCount: hasNA ? naCount : 0, // ✨ 返回NA数量
|
||
totalCount: data.length, // ✨ 总行数
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
logger.error(`[SessionController] 获取唯一值失败: ${error.message}`);
|
||
|
||
const statusCode = error.message.includes('不存在') || error.message.includes('过期')
|
||
? 404
|
||
: 500;
|
||
|
||
return reply.code(statusCode).send({
|
||
success: false,
|
||
error: error.message || '获取唯一值失败',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 导出单例实例 ====================
|
||
|
||
export const sessionController = new SessionController();
|
||
|