From 635a21d8115ffed0f7d2899c1f12e6a3abc36379 Mon Sep 17 00:00:00 2001 From: Trae Assistant Date: Sun, 11 Oct 2026 16:22:32 +0800 Subject: [PATCH] =?UTF-8?q?style(export):=20=E5=9B=9B=E5=B0=8F=E6=97=B6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=87=87=E7=94=A8=E7=AE=80=E6=B4=81=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E6=A0=B7=E5=BC=8F=EF=BC=8C=E5=8C=B9=E9=85=8D=E5=8F=82?= =?UTF-8?q?=E8=80=83=E8=A1=A8=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 cleanMatrix 样式:仅上下横线边框、无填充底色、全列居中+自动换行 - 部员列 16pt、义工号/合计列 14pt 加粗、活动列 12pt 宋体 - 新增标题行支持:首列左对齐大标题,末列居中日戳 - 四小时导出移除班级/工作组列,列结构对齐参考表 - 表头行高与数据行高均按内容行数自适应 - 自动筛选范围修正为从表头行开始 --- client/src/pages/admin/FourHourExport.tsx | 11 +- client/src/utils/export.ts | 138 +++++++++++++++++----- 2 files changed, 112 insertions(+), 37 deletions(-) diff --git a/client/src/pages/admin/FourHourExport.tsx b/client/src/pages/admin/FourHourExport.tsx index 78e2f4f..3bc109d 100644 --- a/client/src/pages/admin/FourHourExport.tsx +++ b/client/src/pages/admin/FourHourExport.tsx @@ -203,9 +203,7 @@ export default function FourHourExport() { const data = users.map((u: any) => { const row: any = { '部员': u.name, - '班级': u.class || '-', '义工号': u.volunteer_no || '-', - '工作组': u.group_name || '-', }; for (const a of activities) { const h = Number(u[String(a.id)] || 0); @@ -224,10 +222,13 @@ export default function FourHourExport() { sheets.push({ name: '活动矩阵总表(四小时核销)', data, - hint: firstColTitle ? { - matrixColStart: firstColTitle, + hint: { + style: 'cleanMatrix', + title: '宣传部义工时长记录系统 · 四小时导出核销', + subtitle: utc8DateStamp(), + matrixColStart: firstColTitle || undefined, matrixColEnd: '本次核销合计(h)', - } : undefined, + }, }); exportToExcel(sheets, `义工时长-四小时导出-活动矩阵总表-${utc8DateStamp()}.xlsx`); }; diff --git a/client/src/utils/export.ts b/client/src/utils/export.ts index b7e6dc9..86a3ab8 100644 --- a/client/src/utils/export.ts +++ b/client/src/utils/export.ts @@ -96,19 +96,39 @@ export function exportToExcel( matrixColEnd?: string; /** row index (0-based in data array) of header */ headerRow?: number; + /** cleanMatrix: 仅上下横线、无填充、全居中换行 */ + style?: 'default' | 'cleanMatrix'; + /** 标题行文本(首列),提供时会自动在数据前插入标题行 */ + title?: string; + /** 标题行右侧副文本(末列),如日期 */ + subtitle?: string; }; }>, filename: string ) { const wb = XLSX.utils.book_new(); for (const s of sheets) { - const ws = XLSX.utils.json_to_sheet(s.data || []); + const isClean = s.hint?.style === 'cleanMatrix'; + const hasTitle = !!s.hint?.title; + let sheetData = s.data || []; + if (hasTitle && sheetData.length > 0) { + const keys = Object.keys(sheetData[0]); + const titleRow: any = {}; + keys.forEach((k, i) => { + if (i === 0) titleRow[k] = s.hint!.title || ''; + else if (i === keys.length - 1) titleRow[k] = s.hint!.subtitle || ''; + else titleRow[k] = ''; + }); + sheetData = [titleRow, ...sheetData]; + } + const ws = XLSX.utils.json_to_sheet(sheetData); const rangeRef = ws['!ref'] || 'A1'; const range = XLSX.utils.decode_range(rangeRef); + const headerR = s.hint?.headerRow ?? (hasTitle ? range.s.r + 1 : range.s.r); const cols: string[] = []; const headers: string[] = []; for (let c = range.s.c; c <= range.e.c; c++) { - const addr = XLSX.utils.encode_cell({ r: range.s.r, c }); + const addr = XLSX.utils.encode_cell({ r: headerR, c }); const cell = ws[addr]; const headerName = cell ? String(cell.v ?? '') : ''; headers.push(headerName); @@ -117,7 +137,7 @@ export function exportToExcel( ws['!cols'] = headers.map((name, idx) => { const colLetter = cols[idx]; let max = Math.max(8, String(name ?? '').length + 2); - const startRow = s.hint?.numericStartRow ?? (range.s.r + 1); + const startRow = s.hint?.numericStartRow ?? (headerR + 1); for (let r = startRow; r <= Math.min(range.e.r, startRow + 1999); r++) { const a = XLSX.utils.encode_cell({ r, c: range.s.c + idx }); const v = ws[a]; @@ -141,23 +161,60 @@ export function exportToExcel( // eslint-disable-next-line @typescript-eslint/no-unused-vars void colLetter; }); - const headerR = s.hint?.headerRow ?? range.s.r; // 1. Header 样式 for (let c = range.s.c; c <= range.e.c; c++) { const addr = XLSX.utils.encode_cell({ r: headerR, c }); - applyCellStyle(ws, addr, { - bold: true, - align: 'center', - fill: THEME_BG_FG, - fg: THEME_FG_FG, - fontSize: 12, - wrap: true, - borderAll: true, - }); + if (isClean) { + applyCellStyle(ws, addr, { + bold: true, + align: 'center', + fontSize: 12, + wrap: true, + top: true, + bottom: true, + }); + } else { + applyCellStyle(ws, addr, { + bold: true, + align: 'center', + fill: THEME_BG_FG, + fg: THEME_FG_FG, + fontSize: 12, + wrap: true, + borderAll: true, + }); + } + } + // 标题行样式(首列左对齐大字号加粗,末列居中) + if (hasTitle) { + for (let c = range.s.c; c <= range.e.c; c++) { + const addr = XLSX.utils.encode_cell({ r: range.s.r, c }); + if (!ws[addr]) continue; + if (c === range.s.c) { + applyCellStyle(ws, addr, { + bold: true, + align: 'left', + fontSize: 16, + wrap: false, + }); + } else if (c === range.e.c) { + applyCellStyle(ws, addr, { + align: 'center', + fontSize: 12, + wrap: false, + }); + } + } + if (!ws['!rows']) ws['!rows'] = []; + ws['!rows'][range.s.r] = { hpt: 28 }; } // 首行冻结(表头行下一行) ws['!freeze'] = { xSplit: 0, ySplit: Math.min(headerR + 1, range.e.r + 1) }; - ws['!autofilter'] = { ref: XLSX.utils.encode_range(range) }; + const filterRange = XLSX.utils.encode_range({ + s: { r: headerR, c: range.s.c }, + e: { r: range.e.r, c: range.e.c }, + }); + ws['!autofilter'] = { ref: filterRange }; // 2. body 样式 + 数字列 + 居中等 + 交替底色 const numericColIdx = new Set(); for (let c = range.s.c; c <= range.e.c; c++) { @@ -200,8 +257,11 @@ export function exportToExcel( } } const dataStartR = headerR + 1; + const firstCol = range.s.c; + const lastCol = range.e.c; + const volNoColIdx = headers.indexOf('义工号'); for (let r = dataStartR; r <= range.e.r; r++) { - const altFill = ((r - dataStartR) % 2 === 1) ? ALT_ROW : undefined; + const altFill = isClean ? undefined : (((r - dataStartR) % 2 === 1) ? ALT_ROW : undefined); for (let c = range.s.c; c <= range.e.c; c++) { const addr = XLSX.utils.encode_cell({ r, c }); if (!ws[addr]) { @@ -212,23 +272,37 @@ export function exportToExcel( const isCenter = centerColIdx.has(c); const inMatrix = (matrixCS >= 0 && matrixCE >= 0) && (c >= matrixCS && c <= matrixCE); const isTotalCol = (headers[c - range.s.c] === '总时长'); - applyCellStyle(ws, addr, { - align: (inMatrix || isCenter || isNum) ? 'center' : 'left', - numFmt: (isNum || inMatrix) ? '0.0' : undefined, - wrap: inMatrix || /备注|内容|活动名称|活动标题|说明/.test(headers[c - range.s.c] || ''), - fill: altFill, - borderAll: true, - bold: isTotalCol, - fg: isTotalCol ? THEME_BG_FG : undefined, - }); - // 总时长列额外加底色 - if (isTotalCol) { - const existing = (ws[addr].s as any) || {}; - ws[addr].s = { - ...existing, - fill: { patternType: 'solid', fgColor: { rgb: 'FFEDF7F7' }, bgColor: { rgb: 'FFEDF7F7' } }, - alignment: existing.alignment || { horizontal: 'center', vertical: 'center' }, - }; + if (isClean) { + const isVolNo = c - range.s.c === volNoColIdx; + const isFirst = c === firstCol; + const isLast = c === lastCol; + applyCellStyle(ws, addr, { + align: 'center', + wrap: true, + top: true, + bottom: true, + bold: isVolNo || isLast, + fontSize: isFirst ? 16 : (isVolNo || isLast ? 14 : 12), + }); + } else { + applyCellStyle(ws, addr, { + align: (inMatrix || isCenter || isNum) ? 'center' : 'left', + numFmt: (isNum || inMatrix) ? '0.0' : undefined, + wrap: inMatrix || /备注|内容|活动名称|活动标题|说明/.test(headers[c - range.s.c] || ''), + fill: altFill, + borderAll: true, + bold: isTotalCol, + fg: isTotalCol ? THEME_BG_FG : undefined, + }); + // 总时长列额外加底色 + if (isTotalCol) { + const existing = (ws[addr].s as any) || {}; + ws[addr].s = { + ...existing, + fill: { patternType: 'solid', fgColor: { rgb: 'FFEDF7F7' }, bgColor: { rgb: 'FFEDF7F7' } }, + alignment: existing.alignment || { horizontal: 'center', vertical: 'center' }, + }; + } } } // 行高:根据每个单元格换行估算(备注行或矩阵有备注行高放大)