Files
VolunteerManage-for-byw/client/src/pages/admin/Export.tsx
T

469 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ReactNode, useRef, useState } from 'react';
import Button from '../../components/Button';
import { exportApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export';
import type { ExportResult } from '../../types';
import {
IconExport,
IconFileExcel,
IconFileImage,
IconUsers,
IconCalendar,
IconTimer,
IconClock,
IconEye,
} from '../../components/Icons';
type Opts = {
include_users: boolean;
include_activities: boolean;
include_hours: boolean;
include_user_totals: boolean;
include_user_activities: boolean;
include_activity_matrix: boolean;
};
const defaultOpts: Opts = {
include_users: true,
include_activities: true,
include_hours: true,
include_user_totals: true,
include_user_activities: false,
include_activity_matrix: false,
};
type Category = {
key: keyof Opts;
tag: string;
title: string;
subtitle: string;
desc: string;
icon: ReactNode;
};
const CATEGORIES: Category[] = [
{
key: 'include_users',
tag: '01',
title: '部员名册',
subtitle: 'Users',
desc: '导出所有部员的班级、姓名、义工号与所属工作组',
icon: <IconUsers size={28} />,
},
{
key: 'include_activities',
tag: '02',
title: '活动列表',
subtitle: 'Activities',
desc: '导出所有活动的标题、时间、参与工作组',
icon: <IconCalendar size={28} />,
},
{
key: 'include_hours',
tag: '03',
title: '义工时长明细',
subtitle: 'All Hours',
desc: '所有部员在所有活动中的时长登记记录(含备注)',
icon: <IconTimer size={28} />,
},
{
key: 'include_user_totals',
tag: '04',
title: '部员总时长',
subtitle: 'User Totals',
desc: '所有部员各自的累计总时长与活动数(按时长降序排名)',
icon: <IconClock size={28} />,
},
{
key: 'include_user_activities',
tag: '05',
title: '部员活动明细',
subtitle: 'User Activities',
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
icon: <IconEye size={28} />,
},
{
key: 'include_activity_matrix',
tag: '06',
title: '活动时长总表(矩阵)',
subtitle: 'Activity Matrix',
desc: '行是部员(姓名+义工号),列是所有活动;多人同活动在同一列,展示每位部员的对应时长',
icon: <IconExport size={28} />,
},
];
export default function Export() {
const [opts, setOpts] = useState<Opts>(defaultOpts);
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<ExportResult | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const toggle = (k: keyof Opts) => setOpts(o => ({ ...o, [k]: !o[k] }));
const anySelected = Object.values(opts).some(Boolean);
const count = Object.values(opts).filter(Boolean).length;
const load = async () => {
if (!anySelected) {
alert('请至少选择一项导出内容');
return;
}
setLoading(true);
setPreview(null);
(async () => {
try {
const d = await exportApi.data(opts);
setPreview(d);
} catch (e) {
alert(errMsg(e));
} finally {
setLoading(false);
}
})();
};
const doExcel = () => {
if (!preview) {
alert('请先生成预览');
return;
}
const sheets: Array<{ name: string; data: any[] }> = [];
if (preview.users?.length) {
sheets.push({
name: '部员名册',
data: preview.users.map((u, i) => ({
'序号': i + 1,
'班级': u.class,
'姓名': u.name,
'义工号': u.volunteer_no || '-',
'所属工作组': u.group_name || '-',
})),
});
}
if (preview.activities?.length) {
sheets.push({
name: '活动列表',
data: preview.activities.map((a, i) => ({
'序号': i + 1,
'活动标题': a.title,
'内容': a.content || '',
'开始时间': a.start_time,
'结束时间': a.end_time,
'参与工作组': a.groups_name || '',
'创建时间': (a.created_at || '').slice(0, 16),
})),
});
}
if (preview.hours?.length) {
sheets.push({
name: '全部时长明细',
data: preview.hours.map((h: any, i: number) => ({
'序号': 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),
})),
});
}
if (preview.user_totals?.length) {
sheets.push({
name: '部员总时长',
data: preview.user_totals.map((u, i) => ({
'排名': i + 1,
'班级': u.class,
'姓名': u.name,
'义工号': u.volunteer_no || '-',
'工作组': u.group_name || '-',
'参与活动数': u.activity_count,
'累计时长(小时)': Number(u.total_hours || 0).toFixed(2),
})),
});
}
if (preview.user_activities?.length) {
const flat: any[] = [];
preview.user_activities.forEach(u => {
if (u.activities.length === 0) {
flat.push({
'班级': u.class,
'姓名': u.name,
'义工号': u.volunteer_no || '-',
'工作组': u.group_name || '-',
'活动名称': '(未参与任何活动)',
'时间': '',
'时长(小时)': '',
'备注': '',
});
}
u.activities.forEach(a => {
flat.push({
'班级': u.class,
'姓名': u.name,
'义工号': u.volunteer_no || '-',
'工作组': u.group_name || '-',
'活动名称': a.title,
'时间': formatDateRange(a.start_time, a.end_time),
'时长(小时)': Number(a.hours || 0).toFixed(2),
'备注': a.remark || '-',
});
});
});
sheets.push({ name: '部员活动明细', data: flat });
}
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
const { activities, users } = preview.activity_matrix;
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);
}
row['总时长'] = Number(u._total || 0).toFixed(2);
return row;
});
sheets.push({ name: '活动时长总表(矩阵)', data });
}
exportToExcel(sheets, `义工数据-全部导出-${utc8DateStamp()}.xlsx`);
};
const doImage = async () => {
if (!previewRef.current) return;
await exportTableToImage(previewRef.current, `义工数据-全部导出-${utc8DateStamp()}.png`);
};
return (
<div className="space-y-6">
<header className="mb-2">
<div className="flex items-start gap-3">
<div className="shrink-0 p-3 bg-primary-50 text-primary-600 rounded-lg shadow-sm">
<IconExport size={28} />
</div>
<div className="flex-1">
<h1 className="section-title">数据导出中心</h1>
<p className="muted mt-1">
勾选需要的导出类别(已选 <span className="font-semibold text-primary-600">{count}</span> 项),
生成预览后一键导出为 Excel 表格或 PNG 图片
</p>
</div>
</div>
</header>
<section className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{CATEGORIES.map((c, idx) => {
const active = opts[c.key];
return (
<label
key={c.key}
className={`group relative block cursor-pointer transition-all duration-150 ${active ? '' : 'hover:shadow-md'}`}
style={{ animationDelay: `${idx * 40}ms` }}
>
<input type="checkbox" className="sr-only" checked={active} onChange={() => toggle(c.key)} />
<div className={`h-full card transition-all ${active ? 'border-primary-500 ring-1 ring-primary-500/20' : ''}`}>
<div className="relative overflow-hidden rounded-lg">
<div className={`flex items-start gap-4 p-4 pb-3 ${active ? 'bg-primary-50' : 'bg-gray-50/70 border-b border-gray-100'}`}>
<div className={`shrink-0 p-2.5 rounded-md border transition-all ${active ? 'border-primary-300 bg-white text-primary-600 shadow-sm' : 'border-gray-200 bg-white text-gray-700'}`}>
{c.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`chip-primary`}>{c.tag}</span>
</div>
<div className={`text-lg font-semibold tracking-tight leading-tight ${active ? 'text-gray-900' : 'text-gray-800'}`}>{c.title}</div>
<div className={`text-[11px] md:text-xs font-mono tracking-wider mt-0.5 ${active ? 'text-primary-600' : 'text-gray-500'}`}>
{c.subtitle}
</div>
</div>
<div className="ml-1 shrink-0">
<div className={`w-6 h-6 grid place-items-center rounded border transition-all ${active ? 'border-primary-500 text-white bg-primary-500' : 'border-gray-300 text-transparent bg-white'}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
</div>
</div>
<div className="p-4 pt-3 bg-white">
<div className="text-sm text-gray-600 leading-relaxed">{c.desc}</div>
</div>
</div>
</div>
</label>
);
})}
</section>
<section className="card p-4 md:p-5">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="muted">
操作:先勾选上方类别卡片,生成预览后导出文件。
</div>
<div className="flex gap-2 flex-wrap justify-end">
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>
{loading ? '生成中...' : <span className="inline-flex items-center gap-2">生成预览</span>}
</Button>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>
<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>
</Button>
</div>
</div>
</section>
{preview && (
<div ref={previewRef} className="card bg-white p-3 md:p-6 space-y-8">
<div className="border-b border-gray-200 pb-4 text-center">
<div className="text-xl md:text-2xl font-semibold tracking-tight text-gray-800">宣传部义工时长记录系统 · 数据导出报告</div>
<div className="muted mt-1.5">义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}</div>
</div>
{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">
<span className="chip-primary">01</span> 部员名册({preview.users.length} 人)
</h3>
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-'])} />
</section>
) : null}
{preview.activities?.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">02</span> 活动列表({preview.activities.length} 个)
</h3>
<MiniTable heads={['序号', '活动标题', '时间', '参与工作组']} rows={preview.activities.map((a, i) => [i + 1, a.title, formatDateRange(a.start_time, a.end_time), a.groups_name || '全部'])} />
</section>
) : 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">
<span className="chip-primary">03</span> 全部时长明细({preview.hours.length} 条)
</h3>
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any, i) => [i + 1, h.class, h.name, h.volunteer_no || '-', h.group_name || '-', h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '-'])} />
</section>
) : null}
{preview.user_totals?.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">04</span> 部员总时长排行
</h3>
<MiniTable heads={['排名', '班级', '姓名', '义工号', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} />
</section>
) : null}
{preview.user_activities?.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">05</span> 部员参加的活动
</h3>
<div className="space-y-4">
{preview.user_activities.map(u => (
<div key={u.id} className="card overflow-hidden">
<div className="bg-[#275354] text-white px-4 py-2.5 border-b border-gray-200 font-semibold flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3 flex-wrap">
<span>{u.class} {u.name}</span>
<span className="font-mono text-[12px] tracking-wider text-white/90 bg-white/10 rounded px-2 py-0.5">义工号:{u.volunteer_no || '—'}</span>
{u.group_name ? <span className="text-xs font-medium text-white/85">工作组:{u.group_name}</span> : null}
</div>
<span className="text-xs font-medium text-white/85 font-mono tracking-wider">共 {u.activities.length} 次活动</span>
</div>
{u.activities.length === 0 ? (
<div className="p-3 text-sm text-gray-500">未参与任何活动</div>
) : (
<MiniTable heads={['活动名称', '时间', '时长(h)', '备注']} rows={u.activities.map(a => [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(2), a.remark || '-'])} />
)}
</div>
))}
</div>
</section>
) : null}
{preview.activity_matrix?.activities?.length ? (
<section className="card p-5">
<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">06</span> 活动时长总表(矩阵)
<span className="chip ml-2">{preview.activity_matrix.users.length} 人 × {preview.activity_matrix.activities.length} 活动</span>
</h3>
<div className="overflow-x-auto">
<table className="min-w-full text-sm border-collapse border border-gray-200 rounded-lg bg-white">
<thead>
<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) => (
<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 }}
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">{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) => (
<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>
{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 (
<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' : ''}`}
>
{v > 0 ? v.toFixed(2) : '—'}
</td>
);
})}
<td className="px-3 py-2 whitespace-nowrap text-center border-l border-gray-200 font-semibold bg-primary-50/70 text-primary-700">
{Number(u._total || 0).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
</div>
)}
</div>
);
}
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
return (
<div className="w-full overflow-x-auto border border-gray-200 bg-white rounded-lg">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 border-b border-gray-200">
{heads.map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={ri} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
{r.map((c, ci) => (
<td key={ci} className="px-3 py-2 whitespace-nowrap text-gray-700">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}