feat: 新增总合汇总导出 + 预览 KPI 卡片 + 独立总表 xlsx 按钮 + 管理台返回首页
Deploy to Server / deploy (push) Successful in 1m35s
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:
@@ -127,6 +127,50 @@ export default function Export() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sheets: Array<{ name: string; data: any[] }> = [];
|
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) {
|
if (preview.users?.length) {
|
||||||
sheets.push({
|
sheets.push({
|
||||||
name: '部员名册',
|
name: '部员名册',
|
||||||
@@ -234,6 +278,99 @@ export default function Export() {
|
|||||||
await exportTableToImage(previewRef.current, `义工数据-全部导出-${utc8DateStamp()}.png`);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="mb-2">
|
<header className="mb-2">
|
||||||
@@ -301,8 +438,11 @@ export default function Export() {
|
|||||||
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>
|
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>
|
||||||
{loading ? '生成中...' : <span className="inline-flex items-center gap-2">生成预览</span>}
|
{loading ? '生成中...' : <span className="inline-flex items-center gap-2">生成预览</span>}
|
||||||
</Button>
|
</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}>
|
<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>
|
||||||
<Button onClick={doImage} disabled={!preview}>
|
<Button onClick={doImage} disabled={!preview}>
|
||||||
<span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出为图片</span>
|
<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 className="muted mt-1.5">义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}</div>
|
||||||
</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 ? (
|
{preview.users?.length ? (
|
||||||
<section className="animate-fade-in-up">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -85,6 +85,18 @@ export default function Layout() {
|
|||||||
<span>{m.label}</span>
|
<span>{m.label}</span>
|
||||||
</NavLink>
|
</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>
|
</nav>
|
||||||
<div className="p-3 border-t border-white/10 bg-[#1e4041]/40 backdrop-blur">
|
<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>
|
<div className="text-xs text-gray-300 mb-2">当前管理员:<span className="font-semibold text-white">{username}</span></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user