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) => (
{h}
))}
{rows.map((r, ri) => (
{r.map((c, ci) => (
{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/图片留档 → 确认核销 。
{loading ? '生成中...' : (preview ? '重新生成预览' : '生成预览')}
导出 Excel
导出为图片
{confirming ? '确认中...' : '确认核销(写入记录)'}
{confirmedMsg ? (
{confirmedMsg}
) : null}
核销批次历史
最近 {Math.min(200, history.length || 0)} 条 · 状态为「active」的批次可一键撤回(把小时数加回未核销余额)
{historyLoading ? '刷新中...' : '刷新历史'}
{history.length === 0 ? (
暂无核销批次记录
) : (
{['批次', '时间', '状态', '操作人', '达标部员', '核销合计', '明细条数', '撤回时间', '操作'].map((h) => (
{h}
))}
{history.map((it, i) => {
const isVoided = it.status === 'voided';
return (
#{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) : '—'}
doVoid(it)}
className="!px-3"
>
{voidingId === it.id ? '撤回中...' : '撤回'}
);
})}
)}
{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 (
{a.title}
本次核销 / 备注
{String(a.start_time || '').slice(0, 10)}
);
})}
本次合计
{preview.activity_matrix.users.map((u: any, ri) => {
const bg = ri % 2 ? '#f9fafb' : '#fff';
return (
{u.name}
{u.volunteer_no || '-'}
{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 (
);
})}
{Number(u._total || 0).toFixed(1)}
);
})}
) : null}
{!preview.hours?.length && !preview.activity_matrix?.activities?.length ? (
目前没有部员的未核销累计时长达到或超过 4.0 小时
请先在「时长登记」中登记义工时,或等待部员累计到满足门槛后再次生成预览。
) : null}
) : null}
);
}