cangku/frontend/src/utils/tableExport.js
lunanting b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

稳定性:
- 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500
- 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML
- 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗
- 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00

149 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import JSZip from 'jszip';
// 通用表格导出:CSV(带 BOM,Excel 直接打开中文不乱码)与 XLSX(基于 jszip 生成最小 OOXML 工作簿)。
// columns 复用 antd Table 列定义(title/dataIndex),可选 exportValue(value, row) 提供导出值格式化(与 antd render 的 value 语义一致)。
export const escapeXmlText = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
export const spreadsheetColumnName = (index) => {
let name = '';
let value = index + 1;
while (value > 0) {
const remainder = (value - 1) % 26;
name = String.fromCharCode(65 + remainder) + name;
value = Math.floor((value - 1) / 26);
}
return name;
};
const resolveCell = (column, row) => {
const value = row?.[column?.dataIndex];
return typeof column?.exportValue === 'function' ? column.exportValue(value, row) : value;
};
export const resolveExportRows = (columns, rows) => (
(rows || []).map((row) => columns.map((column) => resolveCell(column, row)))
);
const saveBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
// 服务端直接返回文件的场景(/export 接口,responseType: 'blob')在这里统一落盘
export const downloadBlobAsFile = (blob, fileName) => saveBlob(blob, fileName);
// 导出接口出错时,错误响应体同样是 Blob(内含 JSON),这里统一解出可读文案
export const readBlobErrorMessage = async (error, fallback = '导出失败') => {
const data = error?.response?.data;
if (typeof Blob !== 'undefined' && data instanceof Blob) {
try {
const text = await data.text();
try {
const parsed = JSON.parse(text);
return parsed?.error || parsed?.message || fallback;
} catch {
return text.slice(0, 300) || fallback;
}
} catch {
return fallback;
}
}
return error?.response?.data?.error || error?.message || fallback;
};
const quoteCsvCell = (value) => {
const text = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};
export const buildCsvContent = (columns, rows) => {
const header = columns.map((column) => quoteCsvCell(column?.title ?? '')).join(',');
const body = resolveExportRows(columns, rows)
.map((cells) => cells.map((cell) => quoteCsvCell(cell)).join(','))
.join('\r\n');
return body ? `${header}\r\n${body}` : header;
};
export const exportTableToCsv = (fileName, columns, rows) => {
const content = buildCsvContent(columns, rows);
saveBlob(new Blob([`\ufeff${content}`], { type: 'text/csv;charset=utf-8;' }), `${fileName}.csv`);
};
const CONTENT_TYPES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
+ '<Default Extension="xml" ContentType="application/xml"/>'
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
+ '</Types>';
const ROOT_RELS_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
+ '</Relationships>';
const WORKBOOK_RELS_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
+ '</Relationships>';
const workbookXml = (sheetName) => '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
+ `<sheets><sheet name="${escapeXmlText(sheetName)}" sheetId="1" r:id="rId1"/></sheets>`
+ '</workbook>';
const cellXml = (value, rowIndex, columnIndex) => {
const ref = `${spreadsheetColumnName(columnIndex)}${rowIndex + 1}`;
if (value === null || value === undefined || value === '') {
return `<c r="${ref}"/>`;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return `<c r="${ref}"><v>${value}</v></c>`;
}
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXmlText(value)}</t></is></c>`;
};
export const buildWorksheetXml = (columns, rows) => {
const lines = [columns.map((column, columnIndex) => cellXml(column?.title ?? '', 0, columnIndex)).join('')];
resolveExportRows(columns, rows).forEach((cells, rowIndex) => {
lines.push(cells.map((cell, columnIndex) => cellXml(cell, rowIndex + 1, columnIndex)).join(''));
});
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
+ `<sheetData>${lines.map((row) => `<row>${row}</row>`).join('')}</sheetData>`
+ '</worksheet>';
};
export const exportTableToXlsx = async (fileName, columns, rows, sheetName = '数据') => {
const zip = new JSZip();
zip.file('[Content_Types].xml', CONTENT_TYPES_XML);
zip.file('_rels/.rels', ROOT_RELS_XML);
zip.file('xl/workbook.xml', workbookXml(sheetName));
zip.file('xl/_rels/workbook.xml.rels', WORKBOOK_RELS_XML);
zip.file('xl/worksheets/sheet1.xml', buildWorksheetXml(columns, rows));
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
saveBlob(blob, `${fileName}.xlsx`);
};
export const exportTableFile = async (format, fileName, columns, rows, sheetName) => {
if (format === 'xlsx') {
await exportTableToXlsx(fileName, columns, rows, sheetName);
return;
}
exportTableToCsv(fileName, columns, rows);
};