469 lines
21 KiB
TypeScript
469 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|