- imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志 - ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志 - admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复
89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
||
import * as ExcelJS from 'exceljs';
|
||
import { Readable } from 'node:stream';
|
||
import { cellValue, textValue } from './imports.helpers';
|
||
import type { CellValue } from './imports.types';
|
||
|
||
const MAX_SHEETS = 30;
|
||
const MAX_ROWS_PER_SHEET = 3000;
|
||
const MAX_COLS_PER_SHEET = 60;
|
||
|
||
export interface ImportSheetData {
|
||
name: string;
|
||
headers: string[];
|
||
rows: CellValue[][];
|
||
}
|
||
|
||
export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] {
|
||
const sheets: ImportSheetData[] = [];
|
||
for (const worksheet of workbook.worksheets) {
|
||
if (sheets.length >= MAX_SHEETS) break;
|
||
const headers: string[] = [];
|
||
const rows: CellValue[][] = [];
|
||
const firstRow = worksheet.getRow(1);
|
||
for (let col = 1; col <= Math.min(firstRow.cellCount, MAX_COLS_PER_SHEET); col += 1) {
|
||
const header = textValue(cellValue(firstRow.getCell(col)));
|
||
headers.push(header);
|
||
}
|
||
if (!headers.some(Boolean)) continue;
|
||
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||
if (rowNumber === 1 || rows.length >= MAX_ROWS_PER_SHEET) return;
|
||
const values: CellValue[] = [];
|
||
for (let col = 1; col <= headers.length; col += 1) {
|
||
values.push(cellValue(row.getCell(col)));
|
||
}
|
||
if (values.every((v) => v === null || textValue(v) === '')) return;
|
||
rows.push(values);
|
||
});
|
||
if (rows.length > 0) sheets.push({ name: worksheet.name, headers, rows });
|
||
}
|
||
return sheets;
|
||
}
|
||
|
||
export type WorkbookKind = 'csv' | 'xlsx';
|
||
|
||
export function detectWorkbookKind(originalName: string, mimeType: string): WorkbookKind | null {
|
||
const isCsv =
|
||
/\.csv$/i.test(originalName) ||
|
||
/csv/i.test(mimeType) ||
|
||
/text\/(csv|plain)/i.test(mimeType);
|
||
const isXlsx =
|
||
/\.xlsx$/i.test(originalName) ||
|
||
/spreadsheetml/i.test(mimeType) ||
|
||
/excel/i.test(mimeType);
|
||
if (isCsv) return 'csv';
|
||
if (isXlsx) return 'xlsx';
|
||
return null;
|
||
}
|
||
|
||
/** 校验文件类型并解析为工作表数据;解析失败抛出可读错误。 */
|
||
export async function parseSheets(
|
||
buffer: Buffer,
|
||
originalName: string,
|
||
mimeType: string,
|
||
): Promise<ImportSheetData[]> {
|
||
const kind = detectWorkbookKind(originalName, mimeType);
|
||
if (!kind) {
|
||
throw new BadRequestException('仅支持 .xlsx / .csv 文件');
|
||
}
|
||
if (/\.xls$/i.test(originalName) && !/\.xlsx$/i.test(originalName)) {
|
||
throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试');
|
||
}
|
||
try {
|
||
const workbook = new ExcelJS.Workbook();
|
||
if (kind === 'csv') {
|
||
await workbook.csv.read(Readable.from(Buffer.from(buffer)));
|
||
} else {
|
||
await workbook.xlsx.load(buffer.buffer as ArrayBuffer);
|
||
}
|
||
const sheets = extractSheets(workbook);
|
||
if (sheets.length === 0) {
|
||
throw new BadRequestException('文件中没有可用的工作表数据');
|
||
}
|
||
return sheets;
|
||
} catch (error) {
|
||
if (error instanceof BadRequestException) throw error;
|
||
throw new BadRequestException('Excel 文件解析失败,请检查文件格式');
|
||
}
|
||
}
|