fix: 活动矩阵导出每单元格追加备注 + 移除 KPI
Deploy to Server / deploy (push) Successful in 1m36s

This commit is contained in:
Trae Assistant
2026-10-05 09:49:58 +08:00
parent 6250edb639
commit 4df87b4576
+67 -86
View File
@@ -127,16 +127,6 @@ export default function Export() {
return;
}
const sheets: Array<{ name: string; data: any[] }> = [];
const totalHours = (preview.hours || []).reduce((s: number, h: any) => s + Number(h.hours || 0), 0);
const userCount = (preview.users || []).length;
const activityCount = (preview.activities || []).length;
const hourCount = (preview.hours || []).length;
const avgHours = userCount > 0 ? totalHours / userCount : 0;
const groupDist = (preview.users || []).reduce<Record<string, number>>((m, u: any) => {
const key = (u.group_name || '未分组').toString();
key.split(/[、,,\/\s]+/).filter(Boolean).forEach((k: string) => { m[k] = (m[k] || 0) + 1; });
return m;
}, {});
const overviewRows = (preview.hours || []).map((h: any, i: number) => ({
'序号': i + 1,
'班级': h.class,
@@ -150,27 +140,7 @@ export default function Export() {
'备注': h.remark || '-',
'登记时间': (h.created_at || '').slice(0, 16),
}));
sheets.push({
name: '📌总合汇总',
data: [
{ '指标': '部员总数', '数值': userCount, '说明': '当前在册义工' },
{ '指标': '活动总数', '数值': activityCount, '说明': '已创建的活动' },
{ '指标': '累计总时长', '数值': Number(totalHours).toFixed(2), '说明': '所有登记记录合计(小时)' },
{ '指标': '登记人次', '数值': hourCount, '说明': '成功保存的时长条数' },
{ '指标': '人均时长', '数值': Number(avgHours).toFixed(2), '说明': '累计总时长 ÷ 部员总数' },
{ '指标': '工作组分布', '数值': Object.entries(groupDist).map(([k, v]) => `${k}:${v}人`).join(';'), '说明': '按所属工作组计数' },
{},
{ '指标': '—— 明细(最细粒度,一次登记一行) ——', '数值': '', '说明': '' },
].concat(overviewRows.map(r => ({
'指标': `L${r['序号']}`,
'数值': `${r['班级']} ${r['姓名']} (${r['义工号']})`,
'说明': `活动:${r['活动名称']} ${r['时长(小时)']}h ${r['备注'] !== '-' ? '| ' + r['备注'] : ''} 登记:${r['登记时间']}`,
})))
});
sheets.push({
name: '总合明细(平铺)',
data: overviewRows,
});
sheets.push({ name: '总合明细(平铺)', data: overviewRows });
if (preview.users?.length) {
sheets.push({
name: '部员名册',
@@ -260,10 +230,19 @@ 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 row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
for (const a of activities) {
row[a.title] = Number(u[String(a.id)] || 0).toFixed(2);
const h = Number(u[String(a.id)] || 0);
const rmk = remarkMap[`${u.id}_${a.id}`];
let cell = h > 0 ? h.toFixed(2) : '—';
if (rmk) cell = cell + '\n备注:' + rmk;
row[a.title] = cell;
}
row['总时长'] = Number(u._total || 0).toFixed(2);
return row;
@@ -290,16 +269,6 @@ export default function Export() {
const doSummaryXlsx = () => {
if (!preview) { alert('请先生成预览'); return; }
const totalHours = (preview.hours || []).reduce((s: number, h: any) => s + Number(h.hours || 0), 0);
const userCount = (preview.users || []).length;
const activityCount = (preview.activities || []).length;
const hourCount = (preview.hours || []).length;
const avgHours = userCount > 0 ? totalHours / userCount : 0;
const groupDist = (preview.users || []).reduce<Record<string, number>>((m, u: any) => {
const key = (u.group_name || '未分组').toString();
key.split(/[、,,\/\s]+/).filter(Boolean).forEach((k: string) => { m[k] = (m[k] || 0) + 1; });
return m;
}, {});
const overviewRows = (preview.hours || []).map((h: any, i: number) => ({
'序号': i + 1,
'班级': h.class,
@@ -313,26 +282,31 @@ export default function Export() {
'备注': 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 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) : '—';
if (rmk) cell = cell + '\n备注:' + rmk;
row[a.title] = cell;
}
row['总时长'] = Number(u._total || 0).toFixed(2);
return row;
});
matrixSheets.push({ name: '活动时长总表(矩阵+备注)', data });
}
exportToExcel([
{
name: '📌数据总览',
data: [
{ '宣传部义工时长记录系统 · 数据总览 KPI': '', '': '', ' 生成时间': utc8DateTimeStamp() },
{},
{ '指标': '部员总数', '数值': userCount, '说明': '在册义工(含未参与活动)' },
{ '指标': '活动总数', '数值': activityCount, '说明': '活动管理页创建过的活动数' },
{ '指标': '累计总时长', '数值': Number(totalHours).toFixed(2), '说明': '所有登记记录累加(小时)' },
{ '指标': '登记人次', '数值': hourCount, '说明': '成功保存的时长登记条数' },
{ '指标': '人均时长', '数值': Number(avgHours).toFixed(2), '说明': '累计 ÷ 部员总数' },
{ '指标': '工作组分布', '数值': Object.entries(groupDist).map(([k, v]) => `${k}:${v} 人`).join(';') || '—', '说明': '按所属工作组计数(一部员多组则分别计 1)' },
{},
{ '指标': '工作组分排行(Top)', '数值': '', '说明': '' },
...Object.entries(groupDist)
.sort((x, y) => y[1] - x[1])
.map(([k, v], i) => ({ '指标': `T${i + 1} ${k}`, '数值': `${v} 人`, '说明': `占部员 ${userCount > 0 ? ((v / userCount) * 100).toFixed(1) : '0.0'}%` })),
]
},
{ name: '总合明细(平铺 · 全部字段)', data: overviewRows },
...matrixSheets,
...(preview.users?.length ? [{
name: '部员名册',
data: preview.users.map((u: any, i: number) => ({
@@ -458,23 +432,7 @@ export default function Export() {
<div className="muted mt-1.5">义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}</div>
</div>
{kpis.length > 0 && (
<section className="animate-fade-in-up">
<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">00</span> 关键数据总览(KPI)
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2.5 md:gap-3.5">
{kpis.map((k, i) => (
<div key={i} className={`relative overflow-hidden rounded-lg border bg-gradient-to-br ${toneBg[k.tone]} p-3 md:p-4 shadow-sm`}>
<div className="text-[12px] md:text-xs font-semibold opacity-80 leading-tight">{k.label}</div>
<div className={`mt-2 text-xl md:text-2xl font-bold tracking-tight leading-none ${toneText[k.tone]}`}>{k.value}</div>
{k.hint ? <div className="mt-2 text-[11px] md:text-[12px] opacity-70 leading-tight">{k.hint}</div> : null}
</div>
))}
</div>
</section>
)}
{kpis.length > 0 && null}
{preview.hours?.length ? (
<section className="animate-fade-in-up">
<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">
@@ -572,36 +530,57 @@ export default function Export() {
<tr className="bg-gray-100 border-b border-gray-200">
<th className="px-3 py-2.5 text-left font-semibold whitespace-nowrap border-r border-gray-200 text-gray-800 sticky left-0 bg-gray-100 z-10">部员</th>
<th className="px-3 py-2.5 text-left font-semibold whitespace-nowrap border-r border-gray-200 text-gray-800 sticky left-[96px] bg-gray-100 z-10">义工号</th>
{preview.activity_matrix.activities.map((a, i) => (
{preview.activity_matrix.activities.map((a, i) => {
const lastAct = i < preview.activity_matrix!.activities!.length - 1;
return (
<th
key={a.id}
className={`px-3 py-2.5 text-center font-semibold whitespace-nowrap text-gray-800 ${i < preview.activity_matrix!.activities!.length - 1 ? 'border-r border-gray-200' : ''}`}
style={{ minWidth: 160 }}
className={`px-3 py-2.5 text-center font-semibold whitespace-nowrap text-gray-800 ${lastAct ? 'border-r border-gray-200' : ''}`}
style={{ minWidth: 180 }}
title={`${a.title}\n${String(a.start_time || '').slice(0, 16)}`}
>
<div className="font-semibold">{a.title}</div>
<div className="mt-0.5 text-[11px] text-gray-500 font-normal">时长 / 备注</div>
<div className="mt-0.5 text-[11px] text-gray-500 font-normal">{String(a.start_time || '').slice(0, 10)}</div>
</th>
))}
);
})}
<th className="px-3 py-2.5 text-center font-semibold whitespace-nowrap border-l border-gray-200 text-gray-800 bg-primary-50/60">总时长</th>
</tr>
</thead>
<tbody>
{preview.activity_matrix.users.map((u, ri) => (
{(() => {
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: ri % 2 ? '#f9fafb' : '#fff' }}>{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: ri % 2 ? '#f9fafb' : '#fff' }}>{u.volunteer_no || '-'}</td>
<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-3 py-2 text-center whitespace-nowrap ${cls} ${i < preview.activity_matrix!.activities!.length - 1 ? 'border-r border-gray-200/70' : ''}`}
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) : '—'}
</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>
);
})}
@@ -609,7 +588,9 @@ export default function Export() {
{Number(u._total || 0).toFixed(2)}
</td>
</tr>
))}
);
});
})()}
</tbody>
</table>
</div>