feat: 新增总合汇总导出 + 预览 KPI 卡片 + 独立总表 xlsx 按钮 + 管理台返回首页
Deploy to Server / deploy (push) Successful in 1m35s

- 管理台侧边栏 Layout:最后新增「返回首页」按钮(Home 图标 + navigate(/)),与 NavLink 菜单项视觉保持一致
- 数据导出 Excel(全部):
  * 新首 sheet「📌总合汇总」:顶行 6 条 KPI(部员/活动/累计时长/人次/人均/工作组分布)+ 分隔 + 全部时长明细汇总压缩版(每行一行登记以指标|数值|说明三列合并展示)
  * 新增第二 sheet「总合明细(平铺)」:最细粒度 11 列(序号/班级/姓名/义工号/工作组/活动名/活动开始/活动结束/时长/备注/登记时间)
- 独立导出按钮「一键总表 xlsx」:产出精简版总表 xlsx(文件名:义工时长-总合汇总-YYYY-MM-DD.xlsx),包含 4 个 sheet:📌数据总览 KPI + 工作组分布 Top 排行 + 总合明细(平铺全字段) + 部员名册 + 活动列表
- 预览区最上方新增 2 个模块:
  * 00 关键数据总览(KPI):5 张渐变色 KPI 卡(部员/活动/累计时长/人次/人均),响应式 2/3/5 栅格(手机 2 列平板 3 列桌面 5 列)
  * 00+ 总合总表:10 列 MiniTable(含义工号、时长、登记时间),顶部加灰色小字说明可筛选/溯源/核对
- 工具栏按钮扩容:生成预览 / 一键总表 xlsx / 导出 Excel(全部) / 导出为图片,四个按钮按语义分层
- 移动端适配:KPI 栅格 grid-cols-2 → sm:cols-3 → md:cols-5,间距分档 gap-2.5/3.5;卡片 padding p-3 md:p-4;字号 text-xl md:text-2xl 分档
- build 通过
This commit is contained in:
Trae Assistant
2026-10-05 09:42:23 +08:00
parent 7b5f7d7226
commit 6250edb639
2 changed files with 196 additions and 1 deletions
+184 -1
View File
@@ -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<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,
'姓名': 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<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,
'姓名': 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<KpiCard['tone'], string> = {
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<KpiCard['tone'], string> = {
primary: 'text-primary-700', emerald: 'text-emerald-700',
amber: 'text-amber-700', rose: 'text-rose-700', slate: 'text-slate-700',
};
return (
<div className="space-y-6">
<header className="mb-2">
@@ -301,8 +438,11 @@ export default function Export() {
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>
{loading ? '生成中...' : <span className="inline-flex items-center gap-2">生成预览</span>}
</Button>
<Button variant="secondary" onClick={doSummaryXlsx} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileExcel size={18} />一键总表 xlsx</span>
</Button>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span>
<span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel(全部)</span>
</Button>
<Button onClick={doImage} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出为图片</span>
@@ -318,6 +458,49 @@ 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>
)}
{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">
<span className="chip-primary">00+</span> 总合总表 · 最细粒度时长明细平铺({preview.hours.length} 条)
</h3>
<div className="text-xs text-gray-500 mb-2 leading-relaxed">
下表为一次时长登记一行,包含「班级/姓名/义工号/工作组/活动/时间/时长/备注/登记时间」全部关键字段,可直接筛选、溯源、核对。
</div>
<MiniTable
heads={['序号', '班级', '姓名', '义工号', '工作组', '活动名称', '活动时间', '时长(h)', '备注', '登记时间']}
rows={preview.hours.map((h: any, i) => [
i + 1,
h.class,
h.name,
<span key="vn" className="font-mono text-[12px] tracking-wider text-gray-700">{h.volunteer_no || '-'}</span>,
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),
])}
/>
</section>
) : null}
{preview.users?.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">
+12
View File
@@ -85,6 +85,18 @@ export default function Layout() {
<span>{m.label}</span>
</NavLink>
))}
<button
type="button"
onClick={() => navigate('/')}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm font-medium rounded-md transition-colors text-gray-100 hover:bg-white/10 hover:text-white"
>
<span className="inline-flex opacity-90">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l9-8 9 8" /><path d="M5 10v10h14V10" /><path d="M10 20v-6h4v6" />
</svg>
</span>
<span>返回首页</span>
</button>
</nav>
<div className="p-3 border-t border-white/10 bg-[#1e4041]/40 backdrop-blur">
<div className="text-xs text-gray-300 mb-2">当前管理员:<span className="font-semibold text-white">{username}</span></div>