feat(ui): SVG图标全替换 + Neo-Brutalist主题升级 + 数据导出卡片类别勾选
Deploy to Server / deploy (push) Failing after 14m55s

This commit is contained in:
Trae Assistant
2026-10-04 22:12:17 +08:00
parent 0169f19cf8
commit 9c7d6c1455
18 changed files with 657 additions and 178 deletions
+302 -86
View File
@@ -1,8 +1,18 @@
import { useRef, useState } from 'react';
import { ReactNode, useRef, useState } from 'react';
import Button from '../../components/Button';
import { exportApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } 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;
@@ -20,6 +30,64 @@ const defaultOpts: Opts = {
include_user_activities: false,
};
type Category = {
key: keyof Opts;
tag: string;
title: string;
subtitle: string;
desc: string;
icon: ReactNode;
accent: string;
};
const CATEGORIES: Category[] = [
{
key: 'include_users',
tag: '01',
title: '部员名册',
subtitle: 'Users',
desc: '导出所有部员的班级、姓名、义工号与所属工作组',
icon: <IconUsers size={32} />,
accent: 'from-blue-500 to-blue-700',
},
{
key: 'include_activities',
tag: '02',
title: '活动列表',
subtitle: 'Activities',
desc: '导出所有活动的标题、时间、参与工作组',
icon: <IconCalendar size={32} />,
accent: 'from-amber-500 to-amber-700',
},
{
key: 'include_hours',
tag: '03',
title: '义工时长明细',
subtitle: 'All Hours',
desc: '所有部员在所有活动中的时长登记记录(含备注)',
icon: <IconTimer size={32} />,
accent: 'from-black to-gray-800',
},
{
key: 'include_user_totals',
tag: '04',
title: '部员总时长',
subtitle: 'User Totals',
desc: '所有部员各自的累计总时长与活动数(按时长降序排名)',
icon: <IconClock size={32} />,
accent: 'from-green-500 to-green-700',
},
{
key: 'include_user_activities',
tag: '05',
title: '部员活动明细',
subtitle: 'User Activities',
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
icon: <IconEye size={32} />,
accent: 'from-red-500 to-red-700',
},
];
export default function Export() {
const [opts, setOpts] = useState<Opts>(defaultOpts);
const [loading, setLoading] = useState(false);
@@ -28,124 +96,264 @@ export default function Export() {
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);
try {
const d = await exportApi.data(opts);
setPreview(d);
} catch (e) {
alert(errMsg(e));
} finally { setLoading(false); }
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) return alert('请先生成预览');
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.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 || '—' }));
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 });
}
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0,10)}.xlsx`);
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const doImage = async () => {
if (!previewRef.current) return;
await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0,10)}.png`);
await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.png`);
};
const Row = ({ title, desc, value, onChange }: { title: string; desc: string; value: boolean; onChange: () => void }) => (
<label className={`block p-4 border-2 cursor-pointer transition ${value ? 'border-black bg-gray-50' : 'border-gray-200 bg-white hover:border-gray-400'}`}>
<div className="flex items-start gap-3">
<input type="checkbox" className="mt-1 w-4 h-4" checked={value} onChange={onChange} />
<div className="flex-1">
<div className="font-bold text-black">{title}</div>
<div className="text-sm text-gray-600 mt-0.5">{desc}</div>
</div>
</div>
</label>
);
return (
<div>
<div className="mb-5">
<h1 className="text-xl md:text-2xl font-black mb-1">📤 数据导出</h1>
<p className="text-sm text-gray-500">勾选需要导出的内容,预览后一键导出为 Excel 表格或 PNG 图片</p>
</div>
<div className="space-y-6">
<header className="mb-2">
<div className="flex items-start gap-3">
<div className="shrink-0 p-3 bg-black text-white shadow-lg shadow">
<IconExport size={32} />
</div>
<div className="flex-1">
<h1 className="text-2xl md:text-3xl font-black tracking-tight">数据导出中心</h1>
<p className="text-sm text-gray-600 mt-1">
勾选需要的导出类别(已选 <span className="font-black text-blue-700">{count}</span> 项),
生成预览后一键导出为 Excel 表格或 PNG 图片
</p>
</div>
</div>
</header>
<div className="bg-white border-2 border-black p-4 md:p-5 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<Row title="① 部员名册" desc="导出所有部员的班级、姓名、义工号与所属工作组" value={opts.include_users} onChange={() => toggle('include_users')} />
<Row title="② 活动列表" desc="导出所有活动的标题、时间、参与工作组" value={opts.include_activities} onChange={() => toggle('include_activities')} />
<Row title="③ 全部时长明细" desc="所有部员在所有活动中的时长登记记录(含备注)" value={opts.include_hours} onChange={() => toggle('include_hours')} />
<Row title="④ 部员总义工时长" desc="所有部员各自的累计总时长与活动数(按时长降序排名)" value={opts.include_user_totals} onChange={() => toggle('include_user_totals')} />
<Row title="⑤ 部员参加的活动" desc="每位部员参与过的所有活动明细(活动较多时文件较大)" value={opts.include_user_activities} onChange={() => toggle('include_user_activities')} />
<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 ? '-translate-x-0.5 -translate-y-0.5' : 'hover:-translate-x-px hover:-translate-y-px'}`}
style={{ animationDelay: `${idx * 40}ms` }}
>
<input type="checkbox" className="sr-only" checked={active} onChange={() => toggle(c.key)} />
<div className={`h-full border-2 ${active ? 'border-black shadow-lg shadow bg-white' : 'border-black bg-white shadow-sm hover:shadow'}`}>
<div className="relative overflow-hidden">
<div className={`flex items-start gap-4 p-4 pb-3 bg-gradient-to-br ${active ? c.accent : 'from-gray-50 to-gray-100'}`}>
<div className={`shrink-0 p-3 border-2 ${active ? 'border-white text-white' : 'border-black bg-white text-black shadow-sm'}`}>
{c.icon}
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-mono font-bold tracking-wider ${active ? 'text-white/85' : 'text-gray-500'}`}>- {c.tag} -</div>
<div className={`text-lg md:text-xl font-black tracking-tight leading-tight ${active ? 'text-white' : 'text-black'}`}>{c.title}</div>
<div className={`text-[11px] md:text-xs uppercase font-bold tracking-wider mt-0.5 ${active ? 'text-white/85' : 'text-gray-500'} font-display italic`}>
{c.subtitle}
</div>
</div>
<div className="ml-1 shrink-0">
<div className={`w-7 h-7 grid place-items-center border-2 transition-all ${active ? 'border-white text-black bg-white' : 'border-black text-white'}`}>
{active ? 'OK' : ''}
</div>
</div>
</div>
<div className="p-4 pt-3 bg-white">
<div className="text-sm text-gray-700 leading-relaxed">{c.desc}</div>
</div>
</div>
</div>
</label>
);
})}
</section>
<section className="paper p-4 md:p-5 bg-white">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div className="text-sm text-gray-600">
操作:先勾选上方类别卡片,生成预览后导出文件。
</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>
<div className="flex gap-2 flex-wrap justify-end">
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>{loading ? '生成中...' : '生成预览'}</Button>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>📊 导出 Excel</Button>
<Button onClick={doImage} disabled={!preview}>🖼 导出为图片</Button>
</div>
</div>
</section>
{preview && (
<div ref={previewRef} className="bg-white border-2 border-gray-200 p-3 md:p-5 space-y-6">
<div className="border-b-2 border-black pb-3 text-center hidden md:block">
<div className="text-xl font-black">义工数据汇总导出</div>
<div className="text-sm text-gray-600 mt-1">生成时间:{new Date().toLocaleString('zh-CN')}</div>
<div ref={previewRef} className="paper bg-white p-3 md:p-6 space-y-8">
<div className="border-b-2 border-black pb-4 text-center">
<div className="font-display text-3xl md:text-4xl font-black tracking-tight italic">Volunteer Data Report</div>
<div className="text-sm text-gray-600 mt-1">义工数据汇总导出 | 生成时间:{new Date().toLocaleString('zh-CN')}</div>
</div>
{preview.users?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">① 部员名册({preview.users.length} 人)</h3>
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '—', u.group_name || '—'])} />
{preview.users?.length ? (
<section className="animate-stagger-in">
<h3 className="font-black text-xl mb-3 border-l-4 border-blue-600 pl-3 inline-flex items-center gap-2">
<span className="chip">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>
)}
{preview.activities?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">② 活动列表({preview.activities.length} 个)</h3>
) : null}
{preview.activities?.length ? (
<section className="animate-stagger-in">
<h3 className="font-black text-xl mb-3 border-l-4 border-amber-600 pl-3 inline-flex items-center gap-2">
<span className="chip">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>
)}
{preview.hours?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">③ 全部时长明细({preview.hours.length} 条)</h3>
<MiniTable heads={['班级', '姓名', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any) => [h.class, h.name, h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '—'])} />
) : null}
{preview.hours?.length ? (
<section className="animate-stagger-in">
<h3 className="font-black text-xl mb-3 border-l-4 border-black pl-3 inline-flex items-center gap-2">
<span className="chip">03</span> 全部时长明细({preview.hours.length} 条)
</h3>
<MiniTable heads={['班级', '姓名', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any) => [h.class, h.name, h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '-'])} />
</section>
)}
{preview.user_totals?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">④ 部员总时长排行</h3>
<MiniTable heads={['排名', '班级', '姓名', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.group_name || '—', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} />
) : null}
{preview.user_totals?.length ? (
<section className="animate-stagger-in">
<h3 className="font-black text-xl mb-3 border-l-4 border-green-600 pl-3 inline-flex items-center gap-2">
<span className="chip">04</span> 部员总时长排行
</h3>
<MiniTable heads={['排名', '班级', '姓名', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.group_name || '-', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} />
</section>
)}
{preview.user_activities?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">⑤ 部员参加的活动</h3>
) : null}
{preview.user_activities?.length ? (
<section className="animate-stagger-in">
<h3 className="font-black text-xl mb-3 border-l-4 border-red-600 pl-3 inline-flex items-center gap-2">
<span className="chip">05</span> 部员参加的活动
</h3>
<div className="space-y-4">
{preview.user_activities.map(u => (
<div key={u.id} className="border-2 border-gray-200">
<div className="bg-gray-50 px-3 py-2 border-b-2 border-gray-200 font-bold">
{u.class} {u.name} {u.group_name ? `(${u.group_name})` : ''}
<span className="text-gray-500 font-normal text-sm ml-3">共 {u.activities.length} 次活动</span>
<div key={u.id} className="border-2 border-black bg-gray-50 shadow-sm">
<div className="bg-black text-white px-4 py-2 border-b-2 border-black font-black flex items-center justify-between flex-wrap gap-2">
<span className="font-black">{u.class} {u.name}{u.group_name ? ` (${u.group_name})` : ''}</span>
<span className="text-xs font-bold text-gray-300 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 || '—'])} />}
{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}
</div>
)}
</div>
@@ -154,17 +362,25 @@ export default function Export() {
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
return (
<div className="w-full overflow-x-auto">
<div className="w-full overflow-x-auto border-2 border-black bg-white">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 border-b-2 border-black">
{heads.map((h, i) => <th key={i} className="px-3 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">{h}</th>)}
{heads.map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left font-black border border-gray-200 whitespace-nowrap bg-gray-900 text-white">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={ri} className="border-b border-gray-200">
{r.map((c, ci) => <td key={ci} className="px-3 py-1.5 border border-gray-200 whitespace-nowrap">{c}</td>)}
<tr key={ri} className={ri % 2 ? 'bg-gray-50/70 border-b border-gray-200 hover:bg-amber-50 transition-colors' : 'bg-white border-b border-gray-200 hover:bg-amber-50 transition-colors'}>
{r.map((c, ci) => (
<td key={ci} className="px-3 py-2 border border-gray-200 whitespace-nowrap">
{c}
</td>
))}
</tr>
))}
</tbody>