This commit is contained in:
@@ -127,16 +127,6 @@ 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) => ({
|
const overviewRows = (preview.hours || []).map((h: any, i: number) => ({
|
||||||
'序号': i + 1,
|
'序号': i + 1,
|
||||||
'班级': h.class,
|
'班级': h.class,
|
||||||
@@ -150,27 +140,7 @@ export default function Export() {
|
|||||||
'备注': h.remark || '-',
|
'备注': h.remark || '-',
|
||||||
'登记时间': (h.created_at || '').slice(0, 16),
|
'登记时间': (h.created_at || '').slice(0, 16),
|
||||||
}));
|
}));
|
||||||
sheets.push({
|
sheets.push({ name: '总合明细(平铺)', data: overviewRows });
|
||||||
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: '部员名册',
|
||||||
@@ -260,10 +230,19 @@ export default function Export() {
|
|||||||
}
|
}
|
||||||
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
|
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
|
||||||
const { activities, users } = preview.activity_matrix;
|
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 data = users.map((u) => {
|
||||||
const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
|
const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
|
||||||
for (const a of activities) {
|
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);
|
row['总时长'] = Number(u._total || 0).toFixed(2);
|
||||||
return row;
|
return row;
|
||||||
@@ -290,16 +269,6 @@ export default function Export() {
|
|||||||
|
|
||||||
const doSummaryXlsx = () => {
|
const doSummaryXlsx = () => {
|
||||||
if (!preview) { alert('请先生成预览'); return; }
|
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) => ({
|
const overviewRows = (preview.hours || []).map((h: any, i: number) => ({
|
||||||
'序号': i + 1,
|
'序号': i + 1,
|
||||||
'班级': h.class,
|
'班级': h.class,
|
||||||
@@ -313,26 +282,31 @@ export default function Export() {
|
|||||||
'备注': h.remark || '-',
|
'备注': h.remark || '-',
|
||||||
'登记时间': (h.created_at || '').slice(0, 16),
|
'登记时间': (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([
|
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 },
|
{ name: '总合明细(平铺 · 全部字段)', data: overviewRows },
|
||||||
|
...matrixSheets,
|
||||||
...(preview.users?.length ? [{
|
...(preview.users?.length ? [{
|
||||||
name: '部员名册',
|
name: '部员名册',
|
||||||
data: preview.users.map((u: any, i: number) => ({
|
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 className="muted mt-1.5">义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kpis.length > 0 && (
|
{kpis.length > 0 && null}
|
||||||
<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 ? (
|
{preview.hours?.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">
|
||||||
@@ -572,44 +530,67 @@ export default function Export() {
|
|||||||
<tr className="bg-gray-100 border-b border-gray-200">
|
<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-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>
|
<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) => (
|
{preview.activity_matrix.activities.map((a, i) => {
|
||||||
<th
|
const lastAct = i < preview.activity_matrix!.activities!.length - 1;
|
||||||
key={a.id}
|
return (
|
||||||
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' : ''}`}
|
<th
|
||||||
style={{ minWidth: 160 }}
|
key={a.id}
|
||||||
title={`${a.title}\n${String(a.start_time || '').slice(0, 16)}`}
|
className={`px-3 py-2.5 text-center font-semibold whitespace-nowrap text-gray-800 ${lastAct ? 'border-r border-gray-200' : ''}`}
|
||||||
>
|
style={{ minWidth: 180 }}
|
||||||
<div className="font-semibold">{a.title}</div>
|
title={`${a.title}\n${String(a.start_time || '').slice(0, 16)}`}
|
||||||
<div className="mt-0.5 text-[11px] text-gray-500 font-normal">{String(a.start_time || '').slice(0, 10)}</div>
|
>
|
||||||
</th>
|
<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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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`}>
|
const rem: Record<string, string> = {};
|
||||||
<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>
|
for (const h of (preview.hours || []) as any[]) {
|
||||||
<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>
|
if (h.remark) rem[`${h.user_id}_${h.activity_id}`] = String(h.remark);
|
||||||
{preview.activity_matrix!.activities!.map((a, i) => {
|
}
|
||||||
const v = Number(u[String(a.id)] || 0);
|
return preview.activity_matrix.users.map((u, ri) => {
|
||||||
const cls = v > 0
|
const bg = ri % 2 ? '#f9fafb' : '#fff';
|
||||||
? 'text-primary-700 font-semibold bg-primary-50/40'
|
return (
|
||||||
: 'text-gray-400';
|
<tr key={u.id} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||||||
return (
|
<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
|
<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>
|
||||||
key={a.id}
|
{preview.activity_matrix!.activities!.map((a, i) => {
|
||||||
className={`px-3 py-2 text-center whitespace-nowrap ${cls} ${i < preview.activity_matrix!.activities!.length - 1 ? 'border-r border-gray-200/70' : ''}`}
|
const lastAct = i < preview.activity_matrix!.activities!.length - 1;
|
||||||
>
|
const v = Number(u[String(a.id)] || 0);
|
||||||
{v > 0 ? v.toFixed(2) : '—'}
|
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>
|
||||||
);
|
</tr>
|
||||||
})}
|
);
|
||||||
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user