From b145cbd167f637086a195e9af9d56f440ae80968 Mon Sep 17 00:00:00 2001 From: Trae Assistant Date: Mon, 5 Oct 2026 10:12:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B4=BB=E5=8A=A8=E7=9F=A9=E9=98=B5?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E4=BF=AE=E5=A4=8D=20+=20=E6=97=B6=E9=95=BF?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BF=9D=E7=95=99=201=20=E4=BD=8D=E5=B0=8F?= =?UTF-8?q?=E6=95=B0=20+=20Excel=20=E5=AF=BC=E5=87=BA=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 activity_matrix 新增 _remarks 映射,不再依赖 hours 数组独立哈希 (避免 include_hours=false 时矩阵备注全丢) - 前端预览矩阵 & Excel 矩阵 & 一键总表统一直读 u._remarks[activityId] 单元格格式:时长(1位) + 换行 + 「备注:xxx」 - 全局 toFixed(2) → toFixed(1): · Export.tsx 明细/MiniTable/矩阵/KPI · Hours.tsx 批量设置提示/Excel 导出/卡片汇总 · Users.tsx 列表累计时长/详情卡片/活动明细 · user/Dashboard.tsx 累计时长卡片/活动明细表 - Excel 导出自定义样式(utils/export.ts): · 表头主题色 #275354 填充 + 白字 + 加粗 + 居中 · 首行冻结(姓名+义工号两列粘性 xSplit=2)+ 整表 autofilter · 全列列宽自适应(按表头+前 2000 行内容最大换行宽度,关键字段保底) · 矩阵单元格居中 + 数值格式 0.0 + wrapText 备注换行 · 交替行底色 ALT_ROW=#F7FAFA,总时长列字加粗主题色 + 浅底色 #EDF7F7 · 行高按每行最大换行数量线性放大(16+(n-1)*14,最低 18) · 所有实体单元格补空值保留边框,避免边角缺失 · XLSX.write 启用 cellStyles=true;MIME 改为标准 openxmlformats-officedocument - 矩阵 sheet 通过 hint.matrixColStart/End 精确识别矩阵单元格区间启用数值格式 - 修复 doExcel() sheets 数组类型声明过窄(增加 hint 可选字段) --- client/src/pages/admin/Export.tsx | 146 +++++++++---------- client/src/pages/admin/Hours.tsx | 8 +- client/src/pages/admin/Users.tsx | 6 +- client/src/pages/user/Dashboard.tsx | 4 +- client/src/utils/export.ts | 219 +++++++++++++++++++++++++++- server/src/routes/export.ts | 14 +- 6 files changed, 307 insertions(+), 90 deletions(-) diff --git a/client/src/pages/admin/Export.tsx b/client/src/pages/admin/Export.tsx index 7c2a61b..e16335a 100644 --- a/client/src/pages/admin/Export.tsx +++ b/client/src/pages/admin/Export.tsx @@ -126,7 +126,7 @@ export default function Export() { alert('请先生成预览'); return; } - const sheets: Array<{ name: string; data: any[] }> = []; + const sheets: Parameters[0] = []; const overviewRows = (preview.hours || []).map((h: any, i: number) => ({ '序号': i + 1, '班级': h.class, @@ -136,7 +136,7 @@ export default function Export() { '活动名称': h.activity_title, '活动开始': h.start_time ? String(h.start_time).replace('T', ' ').slice(0, 16) : '', '活动结束': h.end_time ? String(h.end_time).replace('T', ' ').slice(0, 16) : '', - '时长(小时)': Number(h.hours || 0).toFixed(2), + '时长(小时)': Number(h.hours || 0).toFixed(1), '备注': h.remark || '-', '登记时间': (h.created_at || '').slice(0, 16), })); @@ -178,7 +178,7 @@ export default function Export() { '工作组': h.group_name || '-', '活动名称': h.activity_title, '活动时间': formatDateRange(h.start_time, h.end_time), - '时长(小时)': Number(h.hours || 0).toFixed(2), + '时长(小时)': Number(h.hours || 0).toFixed(1), '备注': h.remark || '-', '登记时间': (h.created_at || '').slice(0, 16), })), @@ -194,7 +194,7 @@ export default function Export() { '义工号': u.volunteer_no || '-', '工作组': u.group_name || '-', '参与活动数': u.activity_count, - '累计时长(小时)': Number(u.total_hours || 0).toFixed(2), + '累计时长(小时)': Number(u.total_hours || 0).toFixed(1), })), }); } @@ -221,7 +221,7 @@ export default function Export() { '工作组': u.group_name || '-', '活动名称': a.title, '时间': formatDateRange(a.start_time, a.end_time), - '时长(小时)': Number(a.hours || 0).toFixed(2), + '时长(小时)': Number(a.hours || 0).toFixed(1), '备注': a.remark || '-', }); }); @@ -230,24 +230,27 @@ export default function Export() { } if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) { const { activities, users } = preview.activity_matrix; - const remarkMap: Record = {}; - for (const h of (preview.hours || []) as any[]) { - const k = `${h.user_id}_${h.activity_id}`; - if (h.remark) remarkMap[k] = String(h.remark); - } - const data = users.map((u) => { + const data = users.map((u: any) => { const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' }; for (const a of activities) { const h = Number(u[String(a.id)] || 0); - const rmk = remarkMap[`${u.id}_${a.id}`]; - let cell = h > 0 ? h.toFixed(2) : '—'; + const rmk = (u as any)?._remarks?.[String(a.id)] as string | undefined; + let cell = h > 0 ? h.toFixed(1) : '—'; if (rmk) cell = cell + '\n备注:' + rmk; row[a.title] = cell; } - row['总时长'] = Number(u._total || 0).toFixed(2); + row['总时长'] = Number(u._total || 0).toFixed(1); return row; }); - sheets.push({ name: '活动时长总表(矩阵)', data }); + const firstAct = preview.activity_matrix.activities[0]; + sheets.push({ + name: '活动时长总表(矩阵)', + data, + hint: firstAct ? { + matrixColStart: String(firstAct.title), + matrixColEnd: '总时长', + } : undefined, + }); } exportToExcel(sheets, `义工数据-全部导出-${utc8DateStamp()}.xlsx`); }; @@ -278,31 +281,34 @@ export default function Export() { '活动名称': h.activity_title, '活动开始': h.start_time ? String(h.start_time).replace('T', ' ').slice(0, 16) : '', '活动结束': h.end_time ? String(h.end_time).replace('T', ' ').slice(0, 16) : '', - '时长(小时)': Number(h.hours || 0).toFixed(2), + '时长(小时)': Number(h.hours || 0).toFixed(1), '备注': h.remark || '-', '登记时间': (h.created_at || '').slice(0, 16), })); const matrixSheets: any[] = []; if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) { const { activities, users } = preview.activity_matrix; - const remarkMap: Record = {}; - for (const h of (preview.hours || []) as any[]) { - const k = `${h.user_id}_${h.activity_id}`; - if (h.remark) remarkMap[k] = String(h.remark); - } - const data = users.map((u) => { + const data = users.map((u: any) => { const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' }; for (const a of activities) { const h = Number(u[String(a.id)] || 0); - const rmk = remarkMap[`${u.id}_${a.id}`]; - let cell = h > 0 ? h.toFixed(2) : '—'; + const rmk = (u as any)?._remarks?.[String(a.id)] as string | undefined; + let cell = h > 0 ? h.toFixed(1) : '—'; if (rmk) cell = cell + '\n备注:' + rmk; row[a.title] = cell; } - row['总时长'] = Number(u._total || 0).toFixed(2); + row['总时长'] = Number(u._total || 0).toFixed(1); return row; }); - matrixSheets.push({ name: '活动时长总表(矩阵+备注)', data }); + const firstAct = preview.activity_matrix.activities[0]; + matrixSheets.push({ + name: '活动时长总表(矩阵+备注)', + data, + hint: firstAct ? { + matrixColStart: String(firstAct.title), + matrixColEnd: '总时长', + } : undefined, + }); } exportToExcel([ { name: '总合明细(平铺 · 全部字段)', data: overviewRows }, @@ -329,9 +335,9 @@ export default function Export() { const kpis: KpiCard[] = overviewCalc ? [ { label: '部员总数', value: String(overviewCalc.userCount), hint: '在册义工', tone: 'primary' }, { label: '活动总数', value: String(overviewCalc.activityCount), hint: '已创建活动', tone: 'emerald' }, - { label: '累计总时长', value: Number(overviewCalc.totalHours).toFixed(2) + ' h', hint: '所有登记合计', tone: 'amber' }, + { label: '累计总时长', value: Number(overviewCalc.totalHours).toFixed(1) + ' h', hint: '所有登记合计', tone: 'amber' }, { label: '登记人次', value: String(overviewCalc.hourCount), hint: '成功保存条数', tone: 'rose' }, - { label: '人均时长', value: Number(overviewCalc.avgHours).toFixed(2) + ' h', hint: '总时长 ÷ 部员数', tone: 'slate' }, + { label: '人均时长', value: Number(overviewCalc.avgHours).toFixed(1) + ' h', hint: '总时长 ÷ 部员数', tone: 'slate' }, ] : []; const toneBg: Record = { primary: 'from-primary-50 to-primary-100/60 border-primary-200 text-primary-700', @@ -451,7 +457,7 @@ export default function Export() { h.group_name || '-', h.activity_title, formatDateRange(h.start_time, h.end_time), - Number(h.hours || 0).toFixed(2), + Number(h.hours || 0).toFixed(1), h.remark || '-', (h.created_at || '').slice(0, 16), ])} @@ -480,7 +486,7 @@ export default function Export() {

03 全部时长明细({preview.hours.length} 条)

- [i + 1, h.class, h.name, h.volunteer_no || '-', h.group_name || '-', h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '-'])} /> + [i + 1, h.class, h.name, h.volunteer_no || '-', h.group_name || '-', h.activity_title, Number(h.hours || 0).toFixed(1), h.remark || '-'])} /> ) : null} {preview.user_totals?.length ? ( @@ -488,7 +494,7 @@ export default function Export() {

04 部员总时长排行

- [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} /> + [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-', u.activity_count, Number(u.total_hours || 0).toFixed(1)])} /> ) : null} {preview.user_activities?.length ? ( @@ -510,7 +516,7 @@ export default function Export() { {u.activities.length === 0 ? (
未参与任何活动
) : ( - [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(2), a.remark || '-'])} /> + [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(1), a.remark || '-'])} /> )} ))} @@ -549,48 +555,42 @@ export default function Export() { - {(() => { - const rem: Record = {}; - for (const h of (preview.hours || []) as any[]) { - if (h.remark) rem[`${h.user_id}_${h.activity_id}`] = String(h.remark); - } - return preview.activity_matrix.users.map((u, ri) => { - const bg = ri % 2 ? '#f9fafb' : '#fff'; - return ( - - {u.name} - {u.volunteer_no || '-'} - {preview.activity_matrix!.activities!.map((a, i) => { - const lastAct = i < preview.activity_matrix!.activities!.length - 1; - const v = Number(u[String(a.id)] || 0); - const r = rem[`${u.id}_${a.id}`]; - const cls = v > 0 - ? 'text-primary-700 font-semibold bg-primary-50/40' - : 'text-gray-400'; - return ( - -
0 ? 'text-primary-700' : 'text-gray-400'}`}> - {v > 0 ? v.toFixed(2) : '—'} + {preview.activity_matrix.users.map((u: any, ri) => { + const bg = ri % 2 ? '#f9fafb' : '#fff'; + return ( + + {u.name} + {u.volunteer_no || '-'} + {preview.activity_matrix!.activities!.map((a, i) => { + const lastAct = i < preview.activity_matrix!.activities!.length - 1; + const v = Number(u[String(a.id)] || 0); + const r = (u as any)?._remarks?.[String(a.id)]; + const cls = v > 0 + ? 'text-primary-700 font-semibold bg-primary-50/40' + : 'text-gray-400'; + return ( + +
0 ? 'text-primary-700' : 'text-gray-400'}`}> + {v > 0 ? v.toFixed(1) : '—'} +
+ {r ? ( +
+ {r}
- {r ? ( -
- {r} -
- ) : null} - - ); - })} - - {Number(u._total || 0).toFixed(2)} - - - ); - }); - })()} + ) : null} + + ); + })} + + {Number(u._total || 0).toFixed(1)} + + + ); + })}
diff --git a/client/src/pages/admin/Hours.tsx b/client/src/pages/admin/Hours.tsx index 1c24887..85b081d 100644 --- a/client/src/pages/admin/Hours.tsx +++ b/client/src/pages/admin/Hours.tsx @@ -90,7 +90,7 @@ export default function Hours() { return out; })); setBatchModalOpen(false); - setMsg(`✓ 已为 ${selectedCount} 名部员设置时长 ${h.toFixed(2)} 小时,请点击「保存登记」写入`); + setMsg(`✓ 已为 ${selectedCount} 名部员设置时长 ${h.toFixed(1)} 小时,请点击「保存登记」写入`); }; useEffect(() => { @@ -141,10 +141,10 @@ export default function Hours() { '姓名': p.name, '义工号': p.volunteer_no || '—', '工作组': p.group_name || '—', - '义工时长(小时)': Number(p.hours || 0).toFixed(2), + '义工时长(小时)': Number(p.hours || 0).toFixed(1), '备注': p.remark || '—', })); - const meta = [{ '活动名称': title, '活动时间': formatDateRange(activityInfo?.start_time || '', activityInfo?.end_time || ''), '登记人数': summary.people, '总时长(小时)': summary.hours.toFixed(2) }]; + const meta = [{ '活动名称': title, '活动时间': formatDateRange(activityInfo?.start_time || '', activityInfo?.end_time || ''), '登记人数': summary.people, '总时长(小时)': summary.hours.toFixed(1) }]; exportToExcel([{ name: '汇总', data: meta }, { name: '时长明细', data }], `义工时长-${title}-${utc8DateStamp()}.xlsx`); }; @@ -188,7 +188,7 @@ export default function Hours() {
活动名称
{activityInfo.title}
活动时间
{formatDateRange(activityInfo.start_time, activityInfo.end_time)}
已登记/总人数
{summary.people} / {participants.length}
-
总时长
{summary.hours.toFixed(2)} 小时
+
总时长
{summary.hours.toFixed(1)} 小时
)} {msg &&
{msg.includes('✓') ? {msg} : {msg}}
} diff --git a/client/src/pages/admin/Users.tsx b/client/src/pages/admin/Users.tsx index 9b265e3..fb65350 100644 --- a/client/src/pages/admin/Users.tsx +++ b/client/src/pages/admin/Users.tsx @@ -459,7 +459,7 @@ export default function Users() { { key: 'total_hours', title: '累计时长(小时)', - render: (r) => {Number(r.total_hours || 0).toFixed(2)}, + render: (r) => {Number(r.total_hours || 0).toFixed(1)}, }, { key: 'op', @@ -660,7 +660,7 @@ export default function Users() {
累计时长
- {Number(detail.total_hours || 0).toFixed(2)}h + {Number(detail.total_hours || 0).toFixed(1)}h
@@ -680,7 +680,7 @@ export default function Users() { key: 'hours', title: '时长', render: (r: any) => ( - {Number(r.hours || 0).toFixed(2)}h + {Number(r.hours || 0).toFixed(1)}h ), }, { key: 'remark', title: '备注', render: (r: any) => r.remark || '—' }, diff --git a/client/src/pages/user/Dashboard.tsx b/client/src/pages/user/Dashboard.tsx index 052e96d..8707fb5 100644 --- a/client/src/pages/user/Dashboard.tsx +++ b/client/src/pages/user/Dashboard.tsx @@ -68,7 +68,7 @@ export default function UserDashboard() {
累计义工时长
-
{Number(data.total_hours || 0).toFixed(2)} 小时
+
{Number(data.total_hours || 0).toFixed(1)} 小时
参与活动数
@@ -83,7 +83,7 @@ export default function UserDashboard() { columns={[ { key: 'title', title: '活动名称', render: (r: any) => {r.title} }, { key: 'time', title: '活动时间', render: (r: any) => formatDateRange(r.start_time, r.end_time) }, - { key: 'hours', title: '时长(小时)', render: (r: any) => {Number(r.hours || 0).toFixed(2)} }, + { key: 'hours', title: '时长(小时)', render: (r: any) => {Number(r.hours || 0).toFixed(1)} }, { key: 'remark', title: '备注', render: (r: any) => r.remark || '—' }, ]} data={data.activities} diff --git a/client/src/utils/export.ts b/client/src/utils/export.ts index 4764d90..72db3cb 100644 --- a/client/src/utils/export.ts +++ b/client/src/utils/export.ts @@ -34,16 +34,225 @@ function downloadBlob(blob: Blob, filename: string) { setTimeout(() => URL.revokeObjectURL(url), 1000); } -export function exportToExcel(sheets: Array<{ name: string; data: any[] }>, filename: string) { +const THEME_BG_FG = 'FF275354'; +const THEME_FG_FG = 'FFFFFFFF'; +const HEADER_BORDER = 'FFCBD5E1'; +const ALT_ROW = 'FFF7FAFA'; + +type StyleCellOpts = { + bold?: boolean; + align?: 'left' | 'center' | 'right'; + fill?: string; + fg?: string; + wrap?: boolean; + numFmt?: string; + top?: boolean; + bottom?: boolean; + left?: boolean; + right?: boolean; + borderAll?: boolean; + fontSize?: number; +}; + +function applyCellStyle(ws: any, addr: string, opts: StyleCellOpts) { + const cell = ws[addr]; + if (!cell) return; + const borderSides: Record = {}; + if (opts.top) borderSides.top = { style: 'thin', color: { rgb: HEADER_BORDER } }; + if (opts.bottom) borderSides.bottom = { style: 'thin', color: { rgb: HEADER_BORDER } }; + if (opts.left) borderSides.left = { style: 'thin', color: { rgb: HEADER_BORDER } }; + if (opts.right) borderSides.right = { style: 'thin', color: { rgb: HEADER_BORDER } }; + if (opts.borderAll) { + borderSides.top = borderSides.bottom = borderSides.left = borderSides.right = { style: 'thin', color: { rgb: HEADER_BORDER } }; + } + cell.s = { + font: { + bold: !!opts.bold, + sz: opts.fontSize ?? 11, + color: opts.fg ? { rgb: opts.fg } : undefined, + }, + alignment: { + horizontal: opts.align || 'left', + vertical: 'center', + wrapText: !!opts.wrap, + }, + fill: opts.fill + ? { patternType: 'solid', fgColor: { rgb: opts.fill }, bgColor: { rgb: opts.fill } } + : undefined, + border: Object.keys(borderSides).length ? borderSides : undefined, + numFmt: opts.numFmt, + }; +} + +export function exportToExcel( + sheets: Array<{ + name: string; + data: any[]; + hint?: { + numericCols?: string[]; + numericStartRow?: number; + centerCols?: string[]; + matrixColStart?: string; + matrixColEnd?: string; + /** row index (0-based in data array) of header */ + headerRow?: number; + }; + }>, + filename: string +) { const wb = XLSX.utils.book_new(); for (const s of sheets) { const ws = XLSX.utils.json_to_sheet(s.data || []); - const colWidths = Object.keys(s.data[0] || {}).map(k => ({ wch: Math.max(8, String(k).length + 2) })); - ws['!cols'] = colWidths; + const rangeRef = ws['!ref'] || 'A1'; + const range = XLSX.utils.decode_range(rangeRef); + 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 cell = ws[addr]; + const headerName = cell ? String(cell.v ?? '') : ''; + headers.push(headerName); + cols.push(XLSX.utils.encode_col(c)); + } + 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); + 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]; + if (!v || v.v === undefined || v.v === null) continue; + const str = String(v.v); + const lines = str.split(/\r?\n/); + const lm = lines.reduce((m, ln) => Math.max(m, Array.from(ln).length), 0); + if (lm > max) max = lm; + } + // 特殊列宽:矩阵活动列适当收窄但不小于 16 + if (/^时长|小时|累计时长|总时长/.test(name)) { max = Math.max(max, 12); } + if (/^备注|活动名称|活动标题|内容$/.test(name)) { max = Math.max(max, 32); } + if (/^姓名|班级|工作/.test(name)) { max = Math.max(max, 14); } + if (/^义工号$/.test(name)) { max = Math.max(max, 18); } + if (/^活动时间|时间$/.test(name)) { max = Math.max(max, 32); } + // 矩阵活动列(活动名较长)给大一点 + if (!/序号|排名|部员|姓名|班级|义工号|工作|总时长|时长|累计|活动|备注|登记|创建|开始|结束|内容|人数/.test(name)) { + max = Math.max(max, 22); + } + return { wch: Math.min(max, 60) }; + // 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, + }); + } + // 首行冻结(表头行下一行) + ws['!freeze'] = { xSplit: 0, ySplit: Math.min(headerR + 1, range.e.r + 1) }; + ws['!autofilter'] = { ref: XLSX.utils.encode_range(range) }; + // 2. body 样式 + 数字列 + 居中等 + 交替底色 + const numericColIdx = new Set(); + for (let c = range.s.c; c <= range.e.c; c++) { + const headerName = headers[c - range.s.c] || ''; + const colLetter = cols[c - range.s.c]; + const isNum = + /时长|小时|累计时长|累计|活动数|人数|人次|人均|数值/.test(headerName) || + s.hint?.numericCols?.includes(colLetter) || + s.hint?.numericCols?.includes(headerName); + if (isNum) numericColIdx.add(c); + } + const centerColIdx = new Set(); + for (let c = range.s.c; c <= range.e.c; c++) { + const headerName = headers[c - range.s.c] || ''; + const colLetter = cols[c - range.s.c]; + const needCenter = + /序号|排名|义工号|工作|时长|小时|累计|总时长|人数|人次|人均|创建|开始|结束|登记|数值/.test(headerName) || + s.hint?.centerCols?.includes(colLetter) || + s.hint?.centerCols?.includes(headerName); + if (needCenter) centerColIdx.add(c); + } + // 矩阵列(姓名之后、总时长之前的全部活动列)—— 居中、数值格式,多备注换行 + let matrixCS = -1; + let matrixCE = -1; + if (s.hint?.matrixColStart && s.hint?.matrixColEnd) { + const headersMap: Record = {}; + headers.forEach((n, i) => { headersMap[n] = range.s.c + i; }); + const idxS = headersMap[s.hint.matrixColStart]; + const idxE = headersMap[s.hint.matrixColEnd]; + if (idxS !== undefined) matrixCS = idxS; + if (idxE !== undefined) matrixCE = idxE; + // 兼容中文括号差异 + if (matrixCS < 0) { + const alt = headers.find(h => h.replace(/\s/g, '') === s.hint?.matrixColStart?.replace(/\s/g, '')); + if (alt) matrixCS = headersMap[alt]; + } + if (matrixCE < 0) { + const alt = headers.find(h => h.replace(/\s/g, '') === s.hint?.matrixColEnd?.replace(/\s/g, '')); + if (alt) matrixCE = headersMap[alt]; + } + } + const dataStartR = headerR + 1; + for (let r = dataStartR; r <= range.e.r; r++) { + const altFill = ((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]) { + // 避免 null/undef 单元格边框丢失 + ws[addr] = { t: 's', v: '' }; + } + const isNum = numericColIdx.has(c); + 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' }, + }; + } + } + // 行高:根据每个单元格换行估算(备注行或矩阵有备注行高放大) + let lines = 1; + for (let c = range.s.c; c <= range.e.c; c++) { + const addr = XLSX.utils.encode_cell({ r, c }); + const v = ws[addr]?.v; + if (v === undefined || v === null) continue; + const ln = String(v).split(/\r?\n/).length; + if (ln > lines) lines = ln; + } + if (!ws['!rows']) ws['!rows'] = []; + ws['!rows'][r] = { hpt: Math.max(18, 16 + (lines - 1) * 14) }; + } + // 矩阵名两列粘性(姓名 + 义工号) + const stickyCol = headers.includes('义工号') ? 2 : (headers.includes('姓名') ? 1 : 0); + if (stickyCol > 0) { + ws['!freeze'] = { xSplit: stickyCol, ySplit: Math.min(headerR + 1, range.e.r + 1) }; + } + // 总时长列(最后一列):右框线加厚 XLSX.utils.book_append_sheet(wb, ws, s.name.slice(0, 31)); } - const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); - downloadBlob(new Blob([buf], { type: 'application/octet-stream' }), filename); + const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array', cellStyles: true }); + downloadBlob(new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename); } export async function exportTableToImage(el: HTMLElement, filename: string) { diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts index 36362bc..6390391 100644 --- a/server/src/routes/export.ts +++ b/server/src/routes/export.ts @@ -112,12 +112,17 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => { `).all() as any[]; const users = withGroupNames(userRows); const hoursRows = db.prepareCompat(` - SELECT user_id, activity_id, hours FROM hours + SELECT user_id, activity_id, hours, remark FROM hours `).all() as any[]; const matrix: Record = {}; + const remarkMatrix: Record = {}; for (const r of hoursRows) { const key = `${r.user_id}|${r.activity_id}`; matrix[key] = (matrix[key] || 0) + Number(r.hours || 0); + if (r.remark) { + const exist = remarkMatrix[key]; + remarkMatrix[key] = exist ? `${exist};${String(r.remark)}` : String(r.remark); + } } result.activity_matrix = { activities, @@ -128,12 +133,15 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => { name: u.name, volunteer_no: u.volunteer_no, group_name: u.group_name, + _remarks: {} as Record, }; for (const a of activities) { - row[String(a.id)] = Number((matrix[`${u.id}|${a.id}`] || 0).toFixed(2)); + row[String(a.id)] = Number((matrix[`${u.id}|${a.id}`] || 0).toFixed(1)); + const rmk = remarkMatrix[`${u.id}|${a.id}`]; + if (rmk) (row._remarks as any)[String(a.id)] = rmk; } row._total = Number( - activities.reduce((s: number, a: any) => s + (Number(row[String(a.id)]) || 0), 0).toFixed(2) + activities.reduce((s: number, a: any) => s + (Number(row[String(a.id)]) || 0), 0).toFixed(1) ); return row; }),