diff --git a/client/src/pages/admin/Export.tsx b/client/src/pages/admin/Export.tsx index e05e04b..8451a49 100644 --- a/client/src/pages/admin/Export.tsx +++ b/client/src/pages/admin/Export.tsx @@ -127,6 +127,50 @@ 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, + '姓名': h.name, + '义工号': h.volunteer_no || '-', + '工作组': h.group_name || '-', + '活动名称': 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), + '备注': 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, + }); if (preview.users?.length) { sheets.push({ name: '部员名册', @@ -234,6 +278,99 @@ export default function Export() { await exportTableToImage(previewRef.current, `义工数据-全部导出-${utc8DateStamp()}.png`); }; + const overviewCalc = (() => { + if (!preview) return null; + 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; + return { totalHours, userCount, activityCount, hourCount, avgHours }; + })(); + + 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, + '姓名': h.name, + '义工号': h.volunteer_no || '-', + '工作组': h.group_name || '-', + '活动名称': 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), + '备注': h.remark || '-', + '登记时间': (h.created_at || '').slice(0, 16), + })); + 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 }, + ...(preview.users?.length ? [{ + name: '部员名册', + data: preview.users.map((u: any, i: number) => ({ + '序号': i + 1, '班级': u.class, '姓名': u.name, + '义工号': u.volunteer_no || '-', '所属工作组': u.group_name || '-' + })) + }] : []), + ...(preview.activities?.length ? [{ + name: '活动列表', + data: preview.activities.map((a: any, i: number) => ({ + '序号': i + 1, '活动标题': a.title, '内容': a.content || '', + '开始时间': a.start_time, '结束时间': a.end_time, + '参与工作组': a.groups_name || '全部', '创建时间': (a.created_at || '').slice(0, 16) + })) + }] : []), + ], `义工时长-总合汇总-${utc8DateStamp()}.xlsx`); + }; + + type KpiCard = { label: string; value: string; hint?: string; tone: 'primary' | 'emerald' | 'amber' | 'rose' | 'slate' }; + 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: String(overviewCalc.hourCount), hint: '成功保存条数', tone: 'rose' }, + { label: '人均时长', value: Number(overviewCalc.avgHours).toFixed(2) + ' h', hint: '总时长 ÷ 部员数', tone: 'slate' }, + ] : []; + const toneBg: Record = { + primary: 'from-primary-50 to-primary-100/60 border-primary-200 text-primary-700', + emerald: 'from-emerald-50 to-emerald-100/60 border-emerald-200 text-emerald-700', + amber: 'from-amber-50 to-amber-100/60 border-amber-200 text-amber-700', + rose: 'from-rose-50 to-rose-100/60 border-rose-200 text-rose-700', + slate: 'from-slate-50 to-slate-100/60 border-slate-200 text-slate-700', + }; + const toneText: Record = { + primary: 'text-primary-700', emerald: 'text-emerald-700', + amber: 'text-amber-700', rose: 'text-rose-700', slate: 'text-slate-700', + }; + return (
@@ -301,8 +438,11 @@ export default function Export() { +
+ {kpis.length > 0 && ( +
+

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

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

+ 00+ 总合总表 · 最细粒度时长明细平铺({preview.hours.length} 条) +

+
+ 下表为一次时长登记一行,包含「班级/姓名/义工号/工作组/活动/时间/时长/备注/登记时间」全部关键字段,可直接筛选、溯源、核对。 +
+ [ + i + 1, + h.class, + h.name, + {h.volunteer_no || '-'}, + h.group_name || '-', + h.activity_title, + formatDateRange(h.start_time, h.end_time), + Number(h.hours || 0).toFixed(2), + h.remark || '-', + (h.created_at || '').slice(0, 16), + ])} + /> +
+ ) : null} + {preview.users?.length ? (

diff --git a/client/src/pages/admin/Layout.tsx b/client/src/pages/admin/Layout.tsx index e172d8b..5eeb55d 100644 --- a/client/src/pages/admin/Layout.tsx +++ b/client/src/pages/admin/Layout.tsx @@ -85,6 +85,18 @@ export default function Layout() { {m.label} ))} +
当前管理员:{username}