import { useEffect, useRef, useState } from 'react'; import Button from '../../components/Button'; import { exportApi, errMsg } from '../../api'; import { exportToExcel, exportTableToImage, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export'; import type { FourHourExportHistoryItem, FourHourPreviewResult } from '../../types'; import { IconFourHour, IconFileExcel, IconFileImage, IconEye, IconCheck, IconUsers, IconClock, IconUndo, IconHistory, } from '../../components/Icons'; function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) { return (
{heads.map((h, i) => ( ))} {rows.map((r, ri) => ( {r.map((c, ci) => ( ))} ))}
{h}
{c}
); } 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 (
0 ? 'text-primary-700' : 'text-gray-400'}`}> {numStr} {truncated ? ( 截断 ) : null}
{remark ? (
{remark}
) : null}
); } export default function FourHourExport() { const [loading, setLoading] = useState(false); const [confirming, setConfirming] = useState(false); const [preview, setPreview] = useState(null); const [confirmedMsg, setConfirmedMsg] = useState(null); const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const [voidingId, setVoidingId] = useState(null); const previewRef = useRef(null); const loadHistory = () => { setHistoryLoading(true); (async () => { try { const r = await exportApi.fourHourHistory(); setHistory(Array.isArray(r?.list) ? r.list : []); } catch (e) { console.error(e); } finally { setHistoryLoading(false); } })(); }; useEffect(() => { loadHistory(); }, []); 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, { eligible_count: preview.summary.eligible_count, eligible_hours_total: preview.summary.eligible_hours_total, }); if (r?.ok) { const msg2 = `确认成功!\n\n` + `批次编号:#${r.export_id || '—'}\n` + `计划处理明细条数:${r.total_processed}\n` + `实际更新成功:${r.updated}\n` + `跳过(并发冲突/已结算):${r.skipped}\n` + `本次实际核销合计:${Number(r.total_applied || 0).toFixed(1)} h\n\n` + `请点击下方「重新生成预览」以查看核销后的最新数据。`; setConfirmedMsg(msg2); alert(msg2); loadHistory(); } else { alert('确认失败:服务器未返回成功状态'); } } catch (e) { alert(errMsg(e)); } finally { setConfirming(false); } })(); }; const doVoid = (it: FourHourExportHistoryItem) => { const prefix = `即将撤回批次 #${it.id}(${String(it.created_at || '').slice(0, 19)})\n` + `该批次:${it.eligible_count} 人 / ${Number(it.eligible_hours_total || 0).toFixed(1)} h / ${it.items_updated} 条明细\n` + `操作人:${it.admin_username || '—'}\n\n` + `撤回后将:\n` + ` 1. 把该批次核销掉的小时数逐条加回「未核销余额」;\n` + ` 2. 标记该批次状态为 voided(不可再次撤回)。\n\n` + `请在输入框内输入 "撤回批次 #${it.id}"(不含引号)以继续。`; const input = window.prompt(prefix, ''); if (input === null) return; if (input.trim() !== `撤回批次 #${it.id}`) { alert('确认文本不匹配,已取消'); return; } setVoidingId(it.id); (async () => { try { const r = await exportApi.fourHourVoid(it.id); if (r?.ok) { alert( `撤回成功!\n\n` + `批次:#${r.export_id}\n` + `成功回退明细:${r.rolled}\n` + `跳过:${r.skipped}\n` + `累计回退小时数:${Number(r.rolled_hours || 0).toFixed(1)} h` ); loadHistory(); if (preview) loadPreview(); } } catch (e) { alert(errMsg(e)); } finally { setVoidingId(null); } })(); }; 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[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 (

四小时导出核销

按部员维度:每位部员「尚未核销的累计义工时」≥ 4h 即列入当次导出。 单次活动若剩余时长超过 4h,显示时自动截断为 4.0(并标注「截断」),但原始登记时长不变。 点击「确认核销」后,本次合计小时数将写入核销记录,下次自动扣除、不会重复统计。

当次达标部员
{preview ? eligibleCount : '—'} 人
当次核销总小时数
{preview ? eligibleHours : '—'} h
核销状态
{!preview ? ( 尚未生成预览 ) : confirmedMsg ? ( 已确认核销 ) : ( 已预览 · 待确认 )}
建议流程:生成预览 → 核对数据 → 导出 Excel/图片留档 → 确认核销。
{confirmedMsg ? (
{confirmedMsg}
) : null}

核销批次历史

最近 {Math.min(200, history.length || 0)} 条 · 状态为「active」的批次可一键撤回(把小时数加回未核销余额)

{history.length === 0 ? (
暂无核销批次记录
) : (
{['批次', '时间', '状态', '操作人', '达标部员', '核销合计', '明细条数', '撤回时间', '操作'].map((h) => ( ))} {history.map((it, i) => { const isVoided = it.status === 'voided'; return ( ); })}
{h}
#{it.id} {String(it.created_at || '').slice(0, 19)} {isVoided ? ( 已撤回 ) : ( active )} {it.admin_username || '—'} {it.eligible_count} {Number(it.eligible_hours_total || 0).toFixed(1)} h {it.items_updated} {isVoided ? String(it.voided_at || '').slice(0, 19) : '—'}
)}
{preview ? (
宣传部义工时长记录系统 · 四小时导出核销报告
达标部员 {preview.summary.eligible_count} 人 · 本次核销合计 {Number(preview.summary.eligible_hours_total).toFixed(1)}h {' | '}生成时间:{utc8DateTimeStamp()}
{preview.hours?.length ? (

A 四小时核销 · 总合明细平铺({preview.hours.length} 条)

每行对应「部员 × 活动」的本次核销小时数,超过单条上限 4h 的单元格会标注「截断」,剩余未核销部分留待下次四小时导出继续统计。
[ 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), {Number(h.hours || 0).toFixed(1)} {h.truncated ? ( 截断 ) : null} , h.remark || '-', ])} />
) : null} {preview.user_totals?.length ? (

B 部员核销汇总({preview.user_totals.length} 人)

[ u.rank, u.class, u.name, {u.volunteer_no || '-'}, u.group_name || '-', {Number(u.exported_total || 0).toFixed(1)}, {Number(u.unexported_remaining || 0).toFixed(1)}, ])} />
) : null} {preview.activity_matrix?.activities?.length ? (

C 活动矩阵 · 四小时核销视图 {preview.activity_matrix.users.length} 人 × {preview.activity_matrix.activities.length} 活动 「截断」= 剩余未核销超过当次单活动上限,下次四小时导出可继续使用

{preview.activity_matrix.activities.map((a, i) => { const last = i < preview.activity_matrix!.activities!.length - 1; return ( ); })} {preview.activity_matrix.users.map((u: any, ri) => { const bg = ri % 2 ? '#f9fafb' : '#fff'; return ( {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 ( ); })} ); })}
部员 义工号
{a.title}
本次核销 / 备注
{String(a.start_time || '').slice(0, 10)}
本次合计
{u.name} {u.volunteer_no || '-'} {Number(u._total || 0).toFixed(1)}
) : null} {!preview.hours?.length && !preview.activity_matrix?.activities?.length ? (
目前没有部员的未核销累计时长达到或超过 4.0 小时
请先在「时长登记」中登记义工时,或等待部员累计到满足门槛后再次生成预览。
) : null}
) : null}
); }