perf(web): isolate Excel export worker
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 20301",
|
"dev": "vite --host 0.0.0.0 --port 20301",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && node scripts/verify-build.mjs",
|
||||||
"test": "vitest run --exclude src/test/App.test.tsx",
|
"test": "vitest run --exclude src/test/App.test.tsx",
|
||||||
"test:legacy": "vitest run src/test/App.test.tsx",
|
"test:legacy": "vitest run src/test/App.test.tsx",
|
||||||
"test:all": "vitest run"
|
"test:all": "vitest run"
|
||||||
|
|||||||
18
vehicle-data-platform/apps/web/scripts/verify-build.mjs
Normal file
18
vehicle-data-platform/apps/web/scripts/verify-build.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { readdirSync, statSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const assetsDirectory = resolve(process.cwd(), 'dist/assets');
|
||||||
|
const assets = readdirSync(assetsDirectory);
|
||||||
|
const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name));
|
||||||
|
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
|
||||||
|
|
||||||
|
if (excelAssets.length !== 1) {
|
||||||
|
throw new Error(`production build must contain exactly one ExcelJS asset, found ${excelAssets.length}: ${excelAssets.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
if (mileageWorkers.length !== 1) {
|
||||||
|
throw new Error(`production build must contain exactly one mileage export worker, found ${mileageWorkers.length}: ${mileageWorkers.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size;
|
||||||
|
const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size;
|
||||||
|
process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes}\n`);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import { createMileageWorkbook, downloadMileageWorkbook } from './mileageExport';
|
import { downloadMileageWorkbook } from './mileageExport';
|
||||||
|
import { createMileageWorkbook } from './mileageWorkbook';
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
@@ -52,6 +53,18 @@ test('does not build or download a workbook after cancellation', async () => {
|
|||||||
}, controller.signal)).rejects.toMatchObject({ name: 'AbortError' });
|
}, controller.signal)).rejects.toMatchObject({ name: 'AbortError' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refuses a main-thread workbook fallback when module workers are unavailable', async () => {
|
||||||
|
vi.stubGlobal('Worker', undefined);
|
||||||
|
await expect(downloadMileageWorkbook({
|
||||||
|
dateFrom: '2026-07-13',
|
||||||
|
dateTo: '2026-07-14',
|
||||||
|
dates: ['2026-07-13', '2026-07-14'],
|
||||||
|
vehicles: [],
|
||||||
|
mileageRows: [],
|
||||||
|
sources: []
|
||||||
|
})).rejects.toThrow('当前浏览器不支持后台 Excel 导出');
|
||||||
|
});
|
||||||
|
|
||||||
test('terminates the workbook worker when an active export is cancelled', async () => {
|
test('terminates the workbook worker when an active export is cancelled', async () => {
|
||||||
const terminate = vi.fn();
|
const terminate = vi.fn();
|
||||||
const postMessage = vi.fn();
|
const postMessage = vi.fn();
|
||||||
|
|||||||
@@ -23,147 +23,10 @@ function throwIfAborted(signal?: AbortSignal) {
|
|||||||
if (signal?.aborted) throw abortError();
|
if (signal?.aborted) throw abortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
function excelColumn(index: number) {
|
|
||||||
let value = index;
|
|
||||||
let result = '';
|
|
||||||
while (value > 0) {
|
|
||||||
value -= 1;
|
|
||||||
result = String.fromCharCode(65 + value % 26) + result;
|
|
||||||
value = Math.floor(value / 26);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function localDateTime(value: Date) {
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
|
||||||
}).format(value).split('/').join('-');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createMileageWorkbook(input: MileageExportInput) {
|
|
||||||
const { Workbook } = await import('exceljs');
|
|
||||||
const workbook = new Workbook();
|
|
||||||
workbook.creator = '灵牛车辆数据中台';
|
|
||||||
workbook.company = '灵牛科技';
|
|
||||||
workbook.created = input.exportedAt ?? new Date();
|
|
||||||
workbook.modified = input.exportedAt ?? new Date();
|
|
||||||
workbook.calcProperties.fullCalcOnLoad = true;
|
|
||||||
|
|
||||||
const sheet = workbook.addWorksheet('里程查询', {
|
|
||||||
views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }],
|
|
||||||
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
|
|
||||||
properties: { defaultRowHeight: 21 }
|
|
||||||
});
|
|
||||||
const firstDateColumn = 3;
|
|
||||||
const lastDateColumn = firstDateColumn + input.dates.length - 1;
|
|
||||||
const totalColumn = lastDateColumn + 1;
|
|
||||||
const lastColumnLetter = excelColumn(totalColumn);
|
|
||||||
const headerRowNumber = 6;
|
|
||||||
const firstDataRow = headerRowNumber + 1;
|
|
||||||
const lastDataRow = firstDataRow + input.vehicles.length - 1;
|
|
||||||
const exportedAt = input.exportedAt ?? new Date();
|
|
||||||
const sourceDescription = input.sources.map((source, index) => `${index + 1}. ${source.label}(${source.mileageType})`).join(' > ');
|
|
||||||
|
|
||||||
const title = sheet.getCell('A1');
|
|
||||||
title.value = '车辆里程查询明细';
|
|
||||||
title.font = { name: 'Microsoft YaHei', size: 18, bold: true, color: { argb: 'FF17345C' } };
|
|
||||||
title.alignment = { vertical: 'middle', horizontal: 'left' };
|
|
||||||
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
|
|
||||||
for (let column = 1; column <= totalColumn; column += 1) {
|
|
||||||
const cell = sheet.getCell(1, column);
|
|
||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
|
|
||||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFBDD0E8' } } };
|
|
||||||
}
|
|
||||||
sheet.getRow(1).height = 38;
|
|
||||||
|
|
||||||
sheet.getRow(2).values = ['日期范围', `${input.dateFrom} 至 ${input.dateTo}`, '车辆范围', `${input.vehicles.length} 辆`];
|
|
||||||
sheet.getRow(3).values = ['来源优先级', sourceDescription];
|
|
||||||
const periodTotal = input.mileageRows.reduce((sum, row) => sum + (Number.isFinite(row.dailyMileageKm) ? row.dailyMileageKm : 0), 0);
|
|
||||||
sheet.getRow(4).values = ['区间总里程', null, '有效车辆日', `${input.mileageRows.length} 条`];
|
|
||||||
sheet.getCell('B4').value = input.vehicles.length ? { formula: `SUM(${lastColumnLetter}${firstDataRow}:${lastColumnLetter}${lastDataRow})`, result: periodTotal } : 0;
|
|
||||||
sheet.getCell('B4').numFmt = '#,##0.0" km"';
|
|
||||||
for (let rowNumber = 2; rowNumber <= 4; rowNumber += 1) {
|
|
||||||
const row = sheet.getRow(rowNumber);
|
|
||||||
row.height = 25;
|
|
||||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
|
||||||
cell.font = { name: 'Microsoft YaHei', size: 10, bold: columnNumber === 1 || columnNumber === 3, color: { argb: columnNumber === 1 || columnNumber === 3 ? 'FF53657D' : 'FF213149' } };
|
|
||||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber === 1 || columnNumber === 3 ? 'left' : 'right' };
|
|
||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: rowNumber % 2 ? 'FFF8FAFD' : 'FFF2F6FB' } };
|
|
||||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE1E8F1' } } };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sheet.getRow(5).height = 8;
|
|
||||||
|
|
||||||
const header = sheet.getRow(headerRowNumber);
|
|
||||||
header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
|
|
||||||
header.height = 30;
|
|
||||||
header.eachCell((cell, columnNumber) => {
|
|
||||||
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
|
|
||||||
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
|
|
||||||
cell.alignment = {
|
|
||||||
vertical: 'middle',
|
|
||||||
horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right'
|
|
||||||
};
|
|
||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
|
|
||||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
|
||||||
if (isDateHeader) cell.numFmt = 'm/d';
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowsByVin = new Map<string, Map<string, DailyMileageRow>>();
|
|
||||||
for (const row of input.mileageRows) {
|
|
||||||
if (!rowsByVin.has(row.vin)) rowsByVin.set(row.vin, new Map());
|
|
||||||
rowsByVin.get(row.vin)!.set(row.date, row);
|
|
||||||
}
|
|
||||||
input.vehicles.forEach((vehicle, index) => {
|
|
||||||
const rowNumber = firstDataRow + index;
|
|
||||||
const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map<string, DailyMileageRow>();
|
|
||||||
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
|
|
||||||
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
|
||||||
const row = sheet.getRow(rowNumber);
|
|
||||||
row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null];
|
|
||||||
row.height = 25;
|
|
||||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
|
||||||
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
|
|
||||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' };
|
|
||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
|
||||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
|
||||||
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
|
|
||||||
});
|
|
||||||
const dateStart = excelColumn(firstDateColumn);
|
|
||||||
const dateEnd = excelColumn(lastDateColumn);
|
|
||||||
const totalCell = sheet.getCell(rowNumber, totalColumn);
|
|
||||||
totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total };
|
|
||||||
totalCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFEDF5FF' : 'FFF4F8FE' } };
|
|
||||||
row.commit();
|
|
||||||
});
|
|
||||||
|
|
||||||
sheet.getColumn(1).width = 15;
|
|
||||||
sheet.getColumn(2).width = 24;
|
|
||||||
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
|
|
||||||
sheet.getColumn(totalColumn).width = 17;
|
|
||||||
if (input.vehicles.length) {
|
|
||||||
sheet.autoFilter = { from: { row: headerRowNumber, column: 1 }, to: { row: lastDataRow, column: totalColumn } };
|
|
||||||
sheet.addConditionalFormatting({
|
|
||||||
ref: `${excelColumn(firstDateColumn)}${firstDataRow}:${excelColumn(lastDateColumn)}${lastDataRow}`,
|
|
||||||
rules: [{
|
|
||||||
type: 'colorScale', priority: 1,
|
|
||||||
cfvo: [{ type: 'min' }, { type: 'percentile', value: 50 }, { type: 'max' }],
|
|
||||||
color: [{ argb: 'FFFFFFFF' }, { argb: 'FFE8F2FF' }, { argb: 'FFB9D8FF' }]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
|
|
||||||
return workbook;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createMileageWorkbookBuffer(input: MileageExportInput, signal?: AbortSignal) {
|
async function createMileageWorkbookBuffer(input: MileageExportInput, signal?: AbortSignal) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
if (typeof Worker === 'undefined') {
|
if (typeof Worker === 'undefined') {
|
||||||
const workbook = await createMileageWorkbook(input);
|
throw new Error('当前浏览器不支持后台 Excel 导出,请升级到最新版 Chrome、Edge 或 Safari 后重试');
|
||||||
throwIfAborted(signal);
|
|
||||||
const output = await workbook.xlsx.writeBuffer();
|
|
||||||
throwIfAborted(signal);
|
|
||||||
return new Uint8Array(output as ArrayBuffer).slice().buffer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createMileageWorkbook, type MileageExportInput } from './mileageExport';
|
import type { MileageExportInput } from './mileageExport';
|
||||||
|
import { createMileageWorkbook } from './mileageWorkbook';
|
||||||
|
|
||||||
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
|
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
|
||||||
type WorkerScope = {
|
type WorkerScope = {
|
||||||
|
|||||||
135
vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts
Normal file
135
vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { DailyMileageRow } from '../../api/types';
|
||||||
|
import type { MileageExportInput } from './mileageExport';
|
||||||
|
|
||||||
|
function excelColumn(index: number) {
|
||||||
|
let value = index;
|
||||||
|
let result = '';
|
||||||
|
while (value > 0) {
|
||||||
|
value -= 1;
|
||||||
|
result = String.fromCharCode(65 + value % 26) + result;
|
||||||
|
value = Math.floor(value / 26);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateTime(value: Date) {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||||
|
}).format(value).split('/').join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMileageWorkbook(input: MileageExportInput) {
|
||||||
|
const { Workbook } = await import('exceljs');
|
||||||
|
const workbook = new Workbook();
|
||||||
|
workbook.creator = '灵牛车辆数据中台';
|
||||||
|
workbook.company = '灵牛科技';
|
||||||
|
workbook.created = input.exportedAt ?? new Date();
|
||||||
|
workbook.modified = input.exportedAt ?? new Date();
|
||||||
|
workbook.calcProperties.fullCalcOnLoad = true;
|
||||||
|
|
||||||
|
const sheet = workbook.addWorksheet('里程查询', {
|
||||||
|
views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }],
|
||||||
|
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
|
||||||
|
properties: { defaultRowHeight: 21 }
|
||||||
|
});
|
||||||
|
const firstDateColumn = 3;
|
||||||
|
const lastDateColumn = firstDateColumn + input.dates.length - 1;
|
||||||
|
const totalColumn = lastDateColumn + 1;
|
||||||
|
const lastColumnLetter = excelColumn(totalColumn);
|
||||||
|
const headerRowNumber = 6;
|
||||||
|
const firstDataRow = headerRowNumber + 1;
|
||||||
|
const lastDataRow = firstDataRow + input.vehicles.length - 1;
|
||||||
|
const exportedAt = input.exportedAt ?? new Date();
|
||||||
|
const sourceDescription = input.sources.map((source, index) => `${index + 1}. ${source.label}(${source.mileageType})`).join(' > ');
|
||||||
|
|
||||||
|
const title = sheet.getCell('A1');
|
||||||
|
title.value = '车辆里程查询明细';
|
||||||
|
title.font = { name: 'Microsoft YaHei', size: 18, bold: true, color: { argb: 'FF17345C' } };
|
||||||
|
title.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
|
||||||
|
for (let column = 1; column <= totalColumn; column += 1) {
|
||||||
|
const cell = sheet.getCell(1, column);
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
|
||||||
|
cell.border = { bottom: { style: 'medium', color: { argb: 'FFBDD0E8' } } };
|
||||||
|
}
|
||||||
|
sheet.getRow(1).height = 38;
|
||||||
|
|
||||||
|
sheet.getRow(2).values = ['日期范围', `${input.dateFrom} 至 ${input.dateTo}`, '车辆范围', `${input.vehicles.length} 辆`];
|
||||||
|
sheet.getRow(3).values = ['来源优先级', sourceDescription];
|
||||||
|
const periodTotal = input.mileageRows.reduce((sum, row) => sum + (Number.isFinite(row.dailyMileageKm) ? row.dailyMileageKm : 0), 0);
|
||||||
|
sheet.getRow(4).values = ['区间总里程', null, '有效车辆日', `${input.mileageRows.length} 条`];
|
||||||
|
sheet.getCell('B4').value = input.vehicles.length ? { formula: `SUM(${lastColumnLetter}${firstDataRow}:${lastColumnLetter}${lastDataRow})`, result: periodTotal } : 0;
|
||||||
|
sheet.getCell('B4').numFmt = '#,##0.0" km"';
|
||||||
|
for (let rowNumber = 2; rowNumber <= 4; rowNumber += 1) {
|
||||||
|
const row = sheet.getRow(rowNumber);
|
||||||
|
row.height = 25;
|
||||||
|
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||||
|
cell.font = { name: 'Microsoft YaHei', size: 10, bold: columnNumber === 1 || columnNumber === 3, color: { argb: columnNumber === 1 || columnNumber === 3 ? 'FF53657D' : 'FF213149' } };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: columnNumber === 1 || columnNumber === 3 ? 'left' : 'right' };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: rowNumber % 2 ? 'FFF8FAFD' : 'FFF2F6FB' } };
|
||||||
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE1E8F1' } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.getRow(5).height = 8;
|
||||||
|
|
||||||
|
const header = sheet.getRow(headerRowNumber);
|
||||||
|
header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
|
||||||
|
header.height = 30;
|
||||||
|
header.eachCell((cell, columnNumber) => {
|
||||||
|
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
|
||||||
|
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
|
||||||
|
cell.alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right'
|
||||||
|
};
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
|
||||||
|
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
||||||
|
if (isDateHeader) cell.numFmt = 'm/d';
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowsByVin = new Map<string, Map<string, DailyMileageRow>>();
|
||||||
|
for (const row of input.mileageRows) {
|
||||||
|
if (!rowsByVin.has(row.vin)) rowsByVin.set(row.vin, new Map());
|
||||||
|
rowsByVin.get(row.vin)!.set(row.date, row);
|
||||||
|
}
|
||||||
|
input.vehicles.forEach((vehicle, index) => {
|
||||||
|
const rowNumber = firstDataRow + index;
|
||||||
|
const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map<string, DailyMileageRow>();
|
||||||
|
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
|
||||||
|
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
||||||
|
const row = sheet.getRow(rowNumber);
|
||||||
|
row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null];
|
||||||
|
row.height = 25;
|
||||||
|
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||||
|
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||||
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||||
|
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
|
||||||
|
});
|
||||||
|
const dateStart = excelColumn(firstDateColumn);
|
||||||
|
const dateEnd = excelColumn(lastDateColumn);
|
||||||
|
const totalCell = sheet.getCell(rowNumber, totalColumn);
|
||||||
|
totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total };
|
||||||
|
totalCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFEDF5FF' : 'FFF4F8FE' } };
|
||||||
|
row.commit();
|
||||||
|
});
|
||||||
|
|
||||||
|
sheet.getColumn(1).width = 15;
|
||||||
|
sheet.getColumn(2).width = 24;
|
||||||
|
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
|
||||||
|
sheet.getColumn(totalColumn).width = 17;
|
||||||
|
if (input.vehicles.length) {
|
||||||
|
sheet.autoFilter = { from: { row: headerRowNumber, column: 1 }, to: { row: lastDataRow, column: totalColumn } };
|
||||||
|
sheet.addConditionalFormatting({
|
||||||
|
ref: `${excelColumn(firstDateColumn)}${firstDataRow}:${excelColumn(lastDateColumn)}${lastDataRow}`,
|
||||||
|
rules: [{
|
||||||
|
type: 'colorScale', priority: 1,
|
||||||
|
cfvo: [{ type: 'min' }, { type: 'percentile', value: 50 }, { type: 'max' }],
|
||||||
|
color: [{ argb: 'FFFFFFFF' }, { argb: 'FFE8F2FF' }, { argb: 'FFB9D8FF' }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
@@ -90,6 +90,8 @@ The first continuity pass only warmed Monitor, Tracks, History and Statistics in
|
|||||||
|
|
||||||
A production route-memory loop did not prove a retained application leak: after natural collection, JS heap returned to roughly its initial 31 MB range and old map documents/frames were released. It did expose an ineffective rendering hint: `content-visibility: auto` was attached to the always-visible vehicle scroll viewport instead of the 200 independently off-screen rows. The containment boundary now lives on every desktop rail row and mobile list card, paired with `contain-intrinsic-size` so skipped content keeps stable scroll geometry. This follows the CSS Containment specification's long-scroll-list use case while avoiding the documented scrollbar jump caused by missing intrinsic size: <https://www.w3.org/TR/css-contain-2/#content-visibility>.
|
A production route-memory loop did not prove a retained application leak: after natural collection, JS heap returned to roughly its initial 31 MB range and old map documents/frames were released. It did expose an ineffective rendering hint: `content-visibility: auto` was attached to the always-visible vehicle scroll viewport instead of the 200 independently off-screen rows. The containment boundary now lives on every desktop rail row and mobile list card, paired with `contain-intrinsic-size` so skipped content keeps stable scroll geometry. This follows the CSS Containment specification's long-scroll-list use case while avoiding the documented scrollbar jump caused by missing intrinsic size: <https://www.w3.org/TR/css-contain-2/#content-visibility>.
|
||||||
|
|
||||||
|
Excel export previously produced two independent 940 KB ExcelJS assets because the worker imported the browser download module while that module also retained a main-thread workbook fallback. Workbook construction now lives in a worker-only runtime module; unsupported legacy browsers receive an upgrade message instead of freezing the page with a main-thread build. The production output contains one ExcelJS asset, the Statistics route chunk is smaller, and `scripts/verify-build.mjs` makes exactly one ExcelJS asset plus one mileage worker a mandatory `pnpm build` gate.
|
||||||
|
|
||||||
The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: <https://www.elastic.co/docs/explore-analyze/dashboards/using> and <https://www.elastic.co/docs/explore-analyze/dashboards/arrange-panels>.
|
The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: <https://www.elastic.co/docs/explore-analyze/dashboards/using> and <https://www.elastic.co/docs/explore-analyze/dashboards/arrange-panels>.
|
||||||
|
|
||||||
### Remaining audit queue
|
### Remaining audit queue
|
||||||
|
|||||||
Reference in New Issue
Block a user