diff --git a/client/src/pages/admin/Export.tsx b/client/src/pages/admin/Export.tsx index 8451a49..7c2a61b 100644 --- a/client/src/pages/admin/Export.tsx +++ b/client/src/pages/admin/Export.tsx @@ -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>((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 = {}; + 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>((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 = {}; + 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() {
义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}
- {kpis.length > 0 && ( -
-

- 00 关键数据总览(KPI) -

-
- {kpis.map((k, i) => ( -
-
{k.label}
-
{k.value}
- {k.hint ?
{k.hint}
: null} -
- ))} -
-
- )} - + {kpis.length > 0 && null} {preview.hours?.length ? (

@@ -572,44 +530,67 @@ export default function Export() { 部员 义工号 - {preview.activity_matrix.activities.map((a, i) => ( - -
{a.title}
-
{String(a.start_time || '').slice(0, 10)}
- - ))} + {preview.activity_matrix.activities.map((a, i) => { + const lastAct = i < preview.activity_matrix!.activities!.length - 1; + return ( + +
{a.title}
+
时长 / 备注
+
{String(a.start_time || '').slice(0, 10)}
+ + ); + })} 总时长 - {preview.activity_matrix.users.map((u, ri) => ( - - {u.name} - {u.volunteer_no || '-'} - {preview.activity_matrix!.activities!.map((a, i) => { - const v = Number(u[String(a.id)] || 0); - const cls = v > 0 - ? 'text-primary-700 font-semibold bg-primary-50/40' - : 'text-gray-400'; - return ( - - {v > 0 ? v.toFixed(2) : '—'} + {(() => { + 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) : '—'} +
+ {r ? ( +
+ {r} +
+ ) : null} + + ); + })} + + {Number(u._total || 0).toFixed(2)} - ); - })} - - {Number(u._total || 0).toFixed(2)} - - - ))} + + ); + }); + })()}