fix: 活动矩阵备注修复 + 时长统一保留 1 位小数 + Excel 导出自定义样式
Deploy to Server / deploy (push) Successful in 1m37s

- 后端 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 可选字段)
This commit is contained in:
Trae Assistant
2026-10-05 10:12:46 +08:00
parent 4df87b4576
commit b145cbd167
6 changed files with 307 additions and 90 deletions
+43 -43
View File
@@ -126,7 +126,7 @@ export default function Export() {
alert('请先生成预览');
return;
}
const sheets: Array<{ name: string; data: any[] }> = [];
const sheets: Parameters<typeof exportToExcel>[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<string, string> = {};
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<string, string> = {};
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<KpiCard['tone'], string> = {
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() {
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
<span className="chip-primary">03</span> 全部时长明细({preview.hours.length} 条)
</h3>
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any, i) => [i + 1, h.class, h.name, h.volunteer_no || '-', h.group_name || '-', h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '-'])} />
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any, i) => [i + 1, h.class, h.name, h.volunteer_no || '-', h.group_name || '-', h.activity_title, Number(h.hours || 0).toFixed(1), h.remark || '-'])} />
</section>
) : null}
{preview.user_totals?.length ? (
@@ -488,7 +494,7 @@ export default function Export() {
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
<span className="chip-primary">04</span> 部员总时长排行
</h3>
<MiniTable heads={['排名', '班级', '姓名', '义工号', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} />
<MiniTable heads={['排名', '班级', '姓名', '义工号', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-', u.activity_count, Number(u.total_hours || 0).toFixed(1)])} />
</section>
) : null}
{preview.user_activities?.length ? (
@@ -510,7 +516,7 @@ export default function Export() {
{u.activities.length === 0 ? (
<div className="p-3 text-sm text-gray-500">未参与任何活动</div>
) : (
<MiniTable heads={['活动名称', '时间', '时长(h)', '备注']} rows={u.activities.map(a => [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(2), a.remark || '-'])} />
<MiniTable heads={['活动名称', '时间', '时长(h)', '备注']} rows={u.activities.map(a => [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(1), a.remark || '-'])} />
)}
</div>
))}
@@ -549,12 +555,7 @@ export default function Export() {
</tr>
</thead>
<tbody>
{(() => {
const rem: Record<string, string> = {};
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) => {
{preview.activity_matrix.users.map((u: any, ri) => {
const bg = ri % 2 ? '#f9fafb' : '#fff';
return (
<tr key={u.id} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
@@ -563,7 +564,7 @@ export default function Export() {
{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 r = (u as any)?._remarks?.[String(a.id)];
const cls = v > 0
? 'text-primary-700 font-semibold bg-primary-50/40'
: 'text-gray-400';
@@ -574,7 +575,7 @@ export default function Export() {
style={{ minWidth: 180, verticalAlign: 'top' }}
>
<div className={`text-sm font-semibold ${v > 0 ? 'text-primary-700' : 'text-gray-400'}`}>
{v > 0 ? v.toFixed(2) : '—'}
{v > 0 ? v.toFixed(1) : '—'}
</div>
{r ? (
<div className="mt-1 text-[11px] font-normal text-gray-500 text-left bg-white/70 rounded border border-gray-100 px-1.5 py-1 leading-snug break-all" title={r}>
@@ -585,12 +586,11 @@ export default function Export() {
);
})}
<td className="px-3 py-2 whitespace-nowrap text-center border-l border-gray-200 font-semibold bg-primary-50/70 text-primary-700">
{Number(u._total || 0).toFixed(2)}
{Number(u._total || 0).toFixed(1)}
</td>
</tr>
);
});
})()}
})}
</tbody>
</table>
</div>
+4 -4
View File
@@ -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() {
<div><div className="muted mb-1">活动名称</div><div className="font-semibold truncate">{activityInfo.title}</div></div>
<div><div className="muted mb-1">活动时间</div><div className="font-medium text-sm">{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div></div>
<div><div className="muted mb-1">已登记/总人数</div><div className="font-semibold">{summary.people} / {participants.length}</div></div>
<div><div className="muted mb-1">总时长</div><div className="font-semibold text-primary-600">{summary.hours.toFixed(2)} 小时</div></div>
<div><div className="muted mb-1">总时长</div><div className="font-semibold text-primary-600">{summary.hours.toFixed(1)} 小时</div></div>
</div>
)}
{msg && <div className="mt-3 text-sm">{msg.includes('✓') ? <span className="text-green-600 font-medium">{msg}</span> : <span className="text-red-600">{msg}</span>}</div>}
+3 -3
View File
@@ -459,7 +459,7 @@ export default function Users() {
{
key: 'total_hours',
title: '累计时长(小时)',
render: (r) => <span className="font-semibold text-primary-600">{Number(r.total_hours || 0).toFixed(2)}</span>,
render: (r) => <span className="font-semibold text-primary-600">{Number(r.total_hours || 0).toFixed(1)}</span>,
},
{
key: 'op',
@@ -660,7 +660,7 @@ export default function Users() {
<div className="card p-3 bg-primary-50/60">
<div className="muted mb-1">累计时长</div>
<div className="font-semibold text-lg text-primary-600">
{Number(detail.total_hours || 0).toFixed(2)}h
{Number(detail.total_hours || 0).toFixed(1)}h
</div>
</div>
</div>
@@ -680,7 +680,7 @@ export default function Users() {
key: 'hours',
title: '时长',
render: (r: any) => (
<span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(2)}h</span>
<span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(1)}h</span>
),
},
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
+2 -2
View File
@@ -68,7 +68,7 @@ export default function UserDashboard() {
<div className="mt-5 pt-5 border-t border-gray-200 grid grid-cols-2 gap-4">
<div className="card p-4 bg-primary-50/50">
<div className="muted mb-1">累计义工时长</div>
<div className="text-2xl md:text-3xl font-semibold text-primary-600">{Number(data.total_hours || 0).toFixed(2)} <span className="text-base font-medium text-gray-600">小时</span></div>
<div className="text-2xl md:text-3xl font-semibold text-primary-600">{Number(data.total_hours || 0).toFixed(1)} <span className="text-base font-medium text-gray-600">小时</span></div>
</div>
<div className="card p-4">
<div className="muted mb-1">参与活动数</div>
@@ -83,7 +83,7 @@ export default function UserDashboard() {
columns={[
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium text-gray-800">{r.title}</span> },
{ key: 'time', title: '活动时间', render: (r: any) => formatDateRange(r.start_time, r.end_time) },
{ key: 'hours', title: '时长(小时)', render: (r: any) => <span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(2)}</span> },
{ key: 'hours', title: '时长(小时)', render: (r: any) => <span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(1)}</span> },
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
]}
data={data.activities}
+214 -5
View File
@@ -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<string, any> = {};
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<number>();
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<number>();
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<string, number> = {};
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) {
+11 -3
View File
@@ -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<string, number> = {};
const remarkMatrix: Record<string, string> = {};
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<string, string>,
};
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;
}),