This commit is contained in:
@@ -127,16 +127,6 @@ 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,
|
||||
@@ -150,27 +140,7 @@ export default function Export() {
|
||||
'备注': 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,
|
||||
});
|
||||
sheets.push({ name: '总合明细(平铺)', data: overviewRows });
|
||||
if (preview.users?.length) {
|
||||
sheets.push({
|
||||
name: '部员名册',
|
||||
@@ -260,10 +230,19 @@ export default function Export() {
|
||||
}
|
||||
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
|
||||
const { activities, users } = preview.activity_matrix;
|
||||
const remarkMap: Record<string, string> = {};
|
||||
for (const h of (preview.hours || []) as any[]) {
|
||||
const k = `${h.user_id}_${h.activity_id}`;
|
||||
if (h.remark) remarkMap[k] = String(h.remark);
|
||||
}
|
||||
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);
|
||||
const h = Number(u[String(a.id)] || 0);
|
||||
const rmk = remarkMap[`${u.id}_${a.id}`];
|
||||
let cell = h > 0 ? h.toFixed(2) : '—';
|
||||
if (rmk) cell = cell + '\n备注:' + rmk;
|
||||
row[a.title] = cell;
|
||||
}
|
||||
row['总时长'] = Number(u._total || 0).toFixed(2);
|
||||
return row;
|
||||
@@ -290,16 +269,6 @@ export default function Export() {
|
||||
|
||||
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,
|
||||
@@ -313,26 +282,31 @@ export default function Export() {
|
||||
'备注': h.remark || '-',
|
||||
'登记时间': (h.created_at || '').slice(0, 16),
|
||||
}));
|
||||
const matrixSheets: any[] = [];
|
||||
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
|
||||
const { activities, users } = preview.activity_matrix;
|
||||
const remarkMap: Record<string, string> = {};
|
||||
for (const h of (preview.hours || []) as any[]) {
|
||||
const k = `${h.user_id}_${h.activity_id}`;
|
||||
if (h.remark) remarkMap[k] = String(h.remark);
|
||||
}
|
||||
const data = users.map((u) => {
|
||||
const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
|
||||
for (const a of activities) {
|
||||
const h = Number(u[String(a.id)] || 0);
|
||||
const rmk = remarkMap[`${u.id}_${a.id}`];
|
||||
let cell = h > 0 ? h.toFixed(2) : '—';
|
||||
if (rmk) cell = cell + '\n备注:' + rmk;
|
||||
row[a.title] = cell;
|
||||
}
|
||||
row['总时长'] = Number(u._total || 0).toFixed(2);
|
||||
return row;
|
||||
});
|
||||
matrixSheets.push({ name: '活动时长总表(矩阵+备注)', data });
|
||||
}
|
||||
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 },
|
||||
...matrixSheets,
|
||||
...(preview.users?.length ? [{
|
||||
name: '部员名册',
|
||||
data: preview.users.map((u: any, i: number) => ({
|
||||
@@ -458,23 +432,7 @@ 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>
|
||||
)}
|
||||
|
||||
{kpis.length > 0 && 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">
|
||||
@@ -572,44 +530,67 @@ export default function Export() {
|
||||
<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>
|
||||
))}
|
||||
{preview.activity_matrix.activities.map((a, i) => {
|
||||
const lastAct = i < preview.activity_matrix!.activities!.length - 1;
|
||||
return (
|
||||
<th
|
||||
key={a.id}
|
||||
className={`px-3 py-2.5 text-center font-semibold whitespace-nowrap text-gray-800 ${lastAct ? 'border-r border-gray-200' : ''}`}
|
||||
style={{ minWidth: 180 }}
|
||||
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">时长 / 备注</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) : '—'}
|
||||
{(() => {
|
||||
const rem: Record<string, string> = {};
|
||||
for (const h of (preview.hours || []) as any[]) {
|
||||
if (h.remark) rem[`${h.user_id}_${h.activity_id}`] = String(h.remark);
|
||||
}
|
||||
return preview.activity_matrix.users.map((u, ri) => {
|
||||
const bg = ri % 2 ? '#f9fafb' : '#fff';
|
||||
return (
|
||||
<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: bg }}>{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: bg }}>{u.volunteer_no || '-'}</td>
|
||||
{preview.activity_matrix!.activities!.map((a, i) => {
|
||||
const lastAct = i < preview.activity_matrix!.activities!.length - 1;
|
||||
const v = Number(u[String(a.id)] || 0);
|
||||
const r = rem[`${u.id}_${a.id}`];
|
||||
const cls = v > 0
|
||||
? 'text-primary-700 font-semibold bg-primary-50/40'
|
||||
: 'text-gray-400';
|
||||
return (
|
||||
<td
|
||||
key={a.id}
|
||||
className={`px-2 py-2 whitespace-normal text-xs leading-snug text-center align-top ${cls} ${lastAct ? 'border-r border-gray-200/70' : ''}`}
|
||||
style={{ minWidth: 180, verticalAlign: 'top' }}
|
||||
>
|
||||
<div className={`text-sm font-semibold ${v > 0 ? 'text-primary-700' : 'text-gray-400'}`}>
|
||||
{v > 0 ? v.toFixed(2) : '—'}
|
||||
</div>
|
||||
{r ? (
|
||||
<div className="mt-1 text-[11px] font-normal text-gray-500 text-left bg-white/70 rounded border border-gray-100 px-1.5 py-1 leading-snug break-all" title={r}>
|
||||
{r}
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user