feat: 新增「四小时导出」核销功能
Deploy to Server / deploy (push) Successful in 1m58s

- 数据库: hours 表新增 exported_hours 字段(默认 0),支持部分核销与多次核销
- 后端: 新增 /export/four-hour/preview 预览接口与 /export/four-hour/confirm 事务确认接口
- 分配算法: 按部员累计未核销 ≥ 4h 筛选;单活动本次最多计 4h,超出标记「截断」;按活动时间正序抵扣
- 前端: 新增独立管理页面 FourHourExport,支持矩阵/平铺/汇总三视图、Excel/图片导出、核销确认
- 导航: 侧边栏新增「四小时导出」菜单项,图标使用新增 IconFourHour/IconCheck SVG
This commit is contained in:
Trae Assistant
2026-10-07 00:45:14 +08:00
parent 52c6ef2362
commit ef73970930
9 changed files with 802 additions and 6 deletions
+460
View File
@@ -0,0 +1,460 @@
import { useRef, useState } from 'react';
import Button from '../../components/Button';
import { exportApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export';
import type { FourHourPreviewResult } from '../../types';
import {
IconFourHour,
IconFileExcel,
IconFileImage,
IconEye,
IconCheck,
IconUsers,
IconClock,
} from '../../components/Icons';
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>
);
}
function CellHours({ v, truncated, remark }: { v: number; truncated: boolean; remark?: string }) {
const numStr = v > 0 ? Number(v).toFixed(1) : '—';
const color = v > 0 ? 'text-primary-700 font-semibold bg-primary-50/40' : 'text-gray-400';
return (
<div className="leading-snug text-xs" style={{ minWidth: 180, verticalAlign: 'top' }}>
<div className={`text-sm font-semibold ${v > 0 ? 'text-primary-700' : 'text-gray-400'}`}>
{numStr}
{truncated ? (
<span className="ml-1.5 text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5 align-middle tracking-wide">
截断
</span>
) : null}
</div>
{remark ? (
<div className="mt-1.5 mx-auto max-w-[88%] text-[11px] font-normal text-gray-500 text-left bg-white/95 rounded-sm border border-gray-200/80 px-2 py-1 leading-relaxed break-words shadow-[0_1px_2px_rgba(15,23,42,0.04)]" title={remark}>
{remark}
</div>
) : null}
</div>
);
}
export default function FourHourExport() {
const [loading, setLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
const [preview, setPreview] = useState<FourHourPreviewResult | null>(null);
const [confirmedMsg, setConfirmedMsg] = useState<string | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const loadPreview = () => {
setLoading(true);
setConfirmedMsg(null);
(async () => {
try {
const d = await exportApi.fourHourPreview();
setPreview(d);
} catch (e) {
alert(errMsg(e));
} finally {
setLoading(false);
}
})();
};
const doConfirm = () => {
if (!preview || !preview.confirm_plan?.length) {
alert('无可确认的导出记录,请先生成预览');
return;
}
const msg =
`确认后将把本次预览显示的 ${preview.summary.eligible_count} 位部员合计 ` +
`${Number(preview.summary.eligible_hours_total).toFixed(1)}h 标记为「已通过四小时导出核销」,\n` +
`下次四小时导出时不会再重复计算这些时长。\n\n` +
`注:原始义工时登记不会改变,仅影响「四小时导出」功能本身。\n\n` +
`是否继续?`;
if (!window.confirm(msg)) return;
setConfirming(true);
(async () => {
try {
const r = await exportApi.fourHourConfirm(preview.confirm_plan);
if (r?.ok) {
const msg2 =
`确认成功!\n\n` +
`计划处理明细条数:${r.total_processed}\n` +
`实际更新成功:${r.updated}\n` +
`跳过(并发冲突/已结算):${r.skipped}\n\n` +
`请点击下方「重新生成预览」以查看核销后的最新数据。`;
setConfirmedMsg(msg2);
alert(msg2);
} else {
alert('确认失败:服务器未返回成功状态');
}
} catch (e) {
alert(errMsg(e));
} finally {
setConfirming(false);
}
})();
};
const eligibleCount = preview?.summary?.eligible_count || 0;
const eligibleHours = Number(preview?.summary?.eligible_hours_total || 0).toFixed(1);
const doExcel = () => {
if (!preview) { alert('请先生成预览'); return; }
const sheets: Parameters<typeof exportToExcel>[0] = [];
const flatRows = (preview.hours || []).map((h: any, i: number) => {
const hoursTxt = Number(h.hours || 0).toFixed(1) + (h.truncated ? '(截断)' : '');
return {
'序号': i + 1,
'班级': h.class,
'姓名': h.name,
'义工号': h.volunteer_no || '-',
'工作组': h.group_name || '-',
'活动名称': h.activity_title,
'活动时间': String(h.activity_start_time || '').replace('T', ' ').slice(0, 16),
'本次计时长(小时)': hoursTxt,
'备注': h.remark || '-',
};
});
sheets.push({ name: '总合明细(4h核销)', data: flatRows });
if (preview.user_totals?.length) {
sheets.push({
name: '部员核销汇总',
data: preview.user_totals.map((u: any) => ({
'排名': u.rank,
'班级': u.class,
'姓名': u.name,
'义工号': u.volunteer_no || '-',
'工作组': u.group_name || '-',
'本次核销(h)': Number(u.exported_total || 0).toFixed(1),
'核销后剩余未导出(h)': Number(u.unexported_remaining || 0).toFixed(1),
})),
});
}
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
const { activities, users } = preview.activity_matrix;
const data = users.map((u: any) => {
const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
for (const a of activities) {
const h = Number(u[String(a.id)] || 0);
const rmk = (u as any)?._remarks?.[String(a.id)] as string | undefined;
const tr = (u as any)?._truncated?.[String(a.id)] as boolean | undefined;
let cell = h > 0 ? h.toFixed(1) : '—';
if (tr) cell = cell + '(截断)';
if (rmk) cell = cell + '\n备注:' + rmk;
row[a.title] = cell;
}
row['本次核销合计'] = Number(u._total || 0).toFixed(1);
return row;
});
const firstAct = preview.activity_matrix.activities[0];
sheets.push({
name: '活动矩阵(4h核销)',
data,
hint: firstAct ? {
matrixColStart: String(firstAct.title),
matrixColEnd: '本次核销合计',
} : undefined,
});
}
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">
<IconFourHour size={28} />
</div>
<div className="flex-1">
<h1 className="section-title">四小时导出核销</h1>
<p className="muted mt-1 leading-relaxed">
按部员维度:每位部员「尚未核销的累计义工时」≥ 4h 即列入当次导出。
单次活动若剩余时长超过 4h,显示时自动截断为 4.0(并标注「截断」),但原始登记时长不变。
点击「确认核销」后,本次合计小时数将写入核销记录,下次自动扣除、不会重复统计。
</p>
</div>
</div>
</header>
<section className="card p-4 md:p-5 bg-gradient-to-r from-primary-50/60 to-white border border-primary-100">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-5">
<div className="flex items-center gap-3 md:gap-4">
<div className="shrink-0 w-11 h-11 rounded-lg bg-white border border-primary-200 text-primary-600 grid place-items-center shadow-sm">
<IconUsers size={22} />
</div>
<div>
<div className="text-xs text-gray-500 tracking-wide">当次达标部员</div>
<div className="text-2xl font-bold text-primary-700 leading-tight">
{preview ? eligibleCount : '—'}
<span className="ml-1 text-sm font-medium text-gray-500">人</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4">
<div className="shrink-0 w-11 h-11 rounded-lg bg-white border border-primary-200 text-primary-600 grid place-items-center shadow-sm">
<IconClock size={22} />
</div>
<div>
<div className="text-xs text-gray-500 tracking-wide">当次核销总小时数</div>
<div className="text-2xl font-bold text-primary-700 leading-tight">
{preview ? eligibleHours : '—'}
<span className="ml-1 text-sm font-medium text-gray-500">h</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4">
<div className="shrink-0 w-11 h-11 rounded-lg bg-white border border-primary-200 text-primary-600 grid place-items-center shadow-sm">
<IconCheck size={22} />
</div>
<div>
<div className="text-xs text-gray-500 tracking-wide">核销状态</div>
<div className="text-base md:text-lg font-semibold leading-tight mt-0.5">
{!preview ? (
<span className="text-gray-500">尚未生成预览</span>
) : confirmedMsg ? (
<span className="text-emerald-700">已确认核销</span>
) : (
<span className="text-amber-700">已预览 · 待确认</span>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-5 pt-4 border-t border-primary-100/70">
<div className="muted text-sm">
建议流程:<span className="font-semibold text-gray-700">生成预览 → 核对数据 → 导出 Excel/图片留档 → 确认核销</span>。
</div>
<div className="flex gap-2 flex-wrap justify-end">
<Button variant="secondary" onClick={loadPreview} disabled={loading}>
<span className="inline-flex items-center gap-2">
<IconEye size={18} />
{loading ? '生成中...' : (preview ? '重新生成预览' : '生成预览')}
</span>
</Button>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span>
</Button>
<Button variant="secondary" onClick={doImage} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出为图片</span>
</Button>
<Button onClick={doConfirm} disabled={!preview || confirming || !!confirmedMsg}>
<span className="inline-flex items-center gap-2">
<IconCheck size={18} />
{confirming ? '确认中...' : '确认核销(写入记录)'}
</span>
</Button>
</div>
</div>
</section>
{confirmedMsg ? (
<div className="card p-4 bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm leading-relaxed whitespace-pre-line">
{confirmedMsg}
</div>
) : null}
{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">
达标部员 {preview.summary.eligible_count} 人 · 本次核销合计 {Number(preview.summary.eligible_hours_total).toFixed(1)}h
{' | '}生成时间:{utc8DateTimeStamp()}
</div>
</div>
{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">A</span>
四小时核销 · 总合明细平铺({preview.hours.length} 条)
</h3>
<div className="text-xs text-gray-500 mb-2 leading-relaxed">
每行对应「部员 × 活动」的本次核销小时数,超过单条上限 4h 的单元格会标注「截断」,剩余未核销部分留待下次四小时导出继续统计。
</div>
<MiniTable
heads={['序号', '班级', '姓名', '义工号', '工作组', '活动名称', '活动时间', '本次核销(h)', '备注']}
rows={preview.hours.map((h: any, i) => [
i + 1,
h.class,
h.name,
<span key="vn" className="font-mono text-[12px] tracking-wider text-gray-700">{h.volunteer_no || '-'}</span>,
h.group_name || '-',
h.activity_title,
String(h.activity_start_time || '').replace('T', ' ').slice(0, 16),
<span key="h" className="inline-flex items-center gap-1.5">
<span className={h.truncated ? 'text-amber-700 font-semibold' : 'text-primary-700 font-semibold'}>
{Number(h.hours || 0).toFixed(1)}
</span>
{h.truncated ? (
<span className="text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5">
截断
</span>
) : null}
</span>,
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">B</span>
部员核销汇总({preview.user_totals.length} 人)
</h3>
<MiniTable
heads={['排名', '班级', '姓名', '义工号', '工作组', '本次核销(h)', '核销后剩余未导出(h)']}
rows={preview.user_totals.map((u) => [
u.rank,
u.class,
u.name,
<span key="vn" className="font-mono text-[12px] tracking-wider text-gray-700">{u.volunteer_no || '-'}</span>,
u.group_name || '-',
<span key="t" className="font-semibold text-primary-700">{Number(u.exported_total || 0).toFixed(1)}</span>,
<span key="r" className="font-medium text-gray-600">{Number(u.unexported_remaining || 0).toFixed(1)}</span>,
])}
/>
</section>
) : null}
{preview.activity_matrix?.activities?.length ? (
<section className="card p-5 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">C</span>
活动矩阵 · 四小时核销视图
<span className="chip ml-2">
{preview.activity_matrix.users.length} 人 × {preview.activity_matrix.activities.length} 活动
</span>
<span className="ml-3 inline-flex items-center gap-1.5 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-0.5">
「截断」= 剩余未核销超过当次单活动上限,下次四小时导出可继续使用
</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) => {
const last = 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 ${last ? '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: any, 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 last = i < preview.activity_matrix!.activities!.length - 1;
const v = Number(u[String(a.id)] || 0);
const r = (u as any)?._remarks?.[String(a.id)];
const tr = !!(u as any)?._truncated?.[String(a.id)];
return (
<td
key={a.id}
className={`px-2 py-2 whitespace-normal text-center align-top ${last ? 'border-r border-gray-200/70' : ''}`}
style={{ verticalAlign: 'top' }}
>
<CellHours v={v} truncated={tr} remark={r} />
</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(1)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
) : null}
{!preview.hours?.length && !preview.activity_matrix?.activities?.length ? (
<div className="py-14 text-center text-gray-500">
<div className="text-base font-medium text-gray-700 mb-1">目前没有部员的未核销累计时长达到或超过 4.0 小时</div>
<div className="text-sm">请先在「时长登记」中登记义工时,或等待部员累计到满足门槛后再次生成预览。</div>
</div>
) : null}
</div>
) : null}
</div>
);
}