/** * CSV 生成与浏览器下载。 * * 抽成公共实现的直接原因:签署记录查询与报表分析都要「按当前条件导出全量」, * 两边各写一份 CSV 转义迟早会分叉 —— 一边处理了换行一边没处理, * 导出的文件里就会多出一行被 Excel 解析成错位的数据,而且很难一眼看出来。 * * ⚠️ 前端导出是**临时手段**:导出行为、导出人、导出范围都该写入审计日志, * 浏览器里做不到。后端提供导出任务接口后,这里应整体替换为服务端导出。 */ export interface CsvColumn { label: string value: (row: T) => string } /** 含逗号、引号或换行的字段必须整体加引号,内部引号翻倍 */ export function escapeCsvField(value: string): string { return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value } /** 文件名时间戳:`YYYYMMDDHHmm` */ export function fileStamp(date = new Date()): string { const pad = (value: number) => String(value).padStart(2, '0') return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}` } /** * 触发浏览器下载。 * * 用 `setTimeout(revoke, 0)` 而不是立刻 revoke:立刻释放会让部分浏览器 * 来不及读取 blob 就中断下载。 */ export function saveBlob(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = fileName link.click() window.setTimeout(() => URL.revokeObjectURL(url), 0) } /** 按列定义把行数据拼成 CSV 文本(含表头,行分隔用 CRLF) */ export function toCsv(columns: readonly CsvColumn[], rows: readonly T[]): string { return [ columns.map((column) => escapeCsvField(column.label)).join(','), ...rows.map((row) => columns.map((column) => escapeCsvField(column.value(row))).join(',')), ].join('\r\n') } /** * 生成并下载 CSV。 * * 带 BOM:Excel 直接打开 UTF-8 CSV 时,没有 BOM 会把中文显示成乱码。 */ export function downloadCsv( columns: readonly CsvColumn[], rows: readonly T[], fileName: string, ): void { saveBlob( new Blob([`\uFEFF${toCsv(columns, rows)}`], { type: 'text/csv;charset=utf-8' }), fileName, ) }