- 后端 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:
@@ -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,48 +555,42 @@ 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) => {
|
||||
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`}>
|
||||
<td className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-medium text-gray-800 sticky left-0 z-10" style={{ background: bg }}>{u.name}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-mono text-xs text-gray-600 sticky left-[96px] z-10" style={{ background: bg }}>{u.volunteer_no || '-'}</td>
|
||||
{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 (
|
||||
<td
|
||||
key={a.id}
|
||||
className={`px-2 py-2 whitespace-normal text-xs leading-snug text-center align-top ${cls} ${lastAct ? 'border-r border-gray-200/70' : ''}`}
|
||||
style={{ minWidth: 180, verticalAlign: 'top' }}
|
||||
>
|
||||
<div className={`text-sm font-semibold ${v > 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 (
|
||||
<tr key={u.id} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||||
<td className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-medium text-gray-800 sticky left-0 z-10" style={{ background: bg }}>{u.name}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-mono text-xs text-gray-600 sticky left-[96px] z-10" style={{ background: bg }}>{u.volunteer_no || '-'}</td>
|
||||
{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 (
|
||||
<td
|
||||
key={a.id}
|
||||
className={`px-2 py-2 whitespace-normal text-xs leading-snug text-center align-top ${cls} ${lastAct ? 'border-r border-gray-200/70' : ''}`}
|
||||
style={{ minWidth: 180, verticalAlign: 'top' }}
|
||||
>
|
||||
<div className={`text-sm font-semibold ${v > 0 ? 'text-primary-700' : 'text-gray-400'}`}>
|
||||
{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}>
|
||||
{r}
|
||||
</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}>
|
||||
{r}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<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)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<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(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 || '—' },
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user