feat: 新增四小时核销批次撤回审计与定时备份管理页
Deploy to Server / deploy (push) Successful in 1m57s

This commit is contained in:
Trae Assistant
2026-10-07 01:04:55 +08:00
parent ef73970930
commit b99cfa4d9f
11 changed files with 1323 additions and 11 deletions
+158 -4
View File
@@ -1,8 +1,8 @@
import { useRef, useState } from 'react';
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 { FourHourPreviewResult } from '../../types';
import type { FourHourExportHistoryItem, FourHourPreviewResult } from '../../types';
import {
IconFourHour,
IconFileExcel,
@@ -11,6 +11,8 @@ import {
IconCheck,
IconUsers,
IconClock,
IconUndo,
IconHistory,
} from '../../components/Icons';
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
@@ -69,8 +71,29 @@ export default function FourHourExport() {
const [confirming, setConfirming] = useState(false);
const [preview, setPreview] = useState<FourHourPreviewResult | null>(null);
const [confirmedMsg, setConfirmedMsg] = useState<string | null>(null);
const [history, setHistory] = useState<FourHourExportHistoryItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [voidingId, setVoidingId] = useState<number | null>(null);
const previewRef = useRef<HTMLDivElement>(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);
@@ -101,16 +124,22 @@ export default function FourHourExport() {
setConfirming(true);
(async () => {
try {
const r = await exportApi.fourHourConfirm(preview.confirm_plan);
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\n` +
`跳过(并发冲突/已结算):${r.skipped}\n` +
`本次实际核销合计:${Number(r.total_applied || 0).toFixed(1)} h\n\n` +
`请点击下方「重新生成预览」以查看核销后的最新数据。`;
setConfirmedMsg(msg2);
alert(msg2);
loadHistory();
} else {
alert('确认失败:服务器未返回成功状态');
}
@@ -122,6 +151,44 @@ export default function FourHourExport() {
})();
};
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);
@@ -290,6 +357,93 @@ export default function FourHourExport() {
</div>
) : null}
<section className="card p-4 md:p-5">
<div className="flex items-start justify-between gap-3 flex-wrap mb-4">
<div className="inline-flex items-center gap-2">
<div className="shrink-0 p-2.5 bg-gray-50 text-primary-600 rounded-lg border border-gray-200 shadow-sm">
<IconHistory size={20} />
</div>
<div>
<h3 className="font-semibold text-lg text-gray-800 leading-tight">核销批次历史</h3>
<p className="muted text-sm mt-0.5">
最近 {Math.min(200, history.length || 0)} 条 · 状态为「active」的批次可一键撤回(把小时数加回未核销余额)
</p>
</div>
</div>
<Button variant="secondary" size="sm" onClick={loadHistory} disabled={historyLoading}>
<span className="inline-flex items-center gap-2">
<IconHistory size={16} />
{historyLoading ? '刷新中...' : '刷新历史'}
</span>
</Button>
</div>
{history.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-lg bg-gray-50/50">
暂无核销批次记录
</div>
) : (
<div className="overflow-x-auto border border-gray-200 rounded-lg bg-white">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 border-b border-gray-200">
{['批次', '时间', '状态', '操作人', '达标部员', '核销合计', '明细条数', '撤回时间', '操作'].map((h) => (
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">{h}</th>
))}
</tr>
</thead>
<tbody>
{history.map((it, i) => {
const isVoided = it.status === 'voided';
return (
<tr
key={it.id}
className={`border-b border-gray-200 ${i % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}
>
<td className="px-3 py-2 whitespace-nowrap font-mono text-xs text-gray-600">#{it.id}</td>
<td className="px-3 py-2 whitespace-nowrap">{String(it.created_at || '').slice(0, 19)}</td>
<td className="px-3 py-2 whitespace-nowrap">
{isVoided ? (
<span className="text-xs font-medium text-gray-600 bg-gray-100 border border-gray-200 rounded px-2 py-0.5">
已撤回
</span>
) : (
<span className="text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-2 py-0.5">
active
</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">{it.admin_username || '—'}</td>
<td className="px-3 py-2 whitespace-nowrap text-center font-medium">{it.eligible_count}</td>
<td className="px-3 py-2 whitespace-nowrap text-center font-semibold text-primary-700">
{Number(it.eligible_hours_total || 0).toFixed(1)} h
</td>
<td className="px-3 py-2 whitespace-nowrap text-center">{it.items_updated}</td>
<td className="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
{isVoided ? String(it.voided_at || '').slice(0, 19) : '—'}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<Button
size="sm"
variant={isVoided ? 'ghost' : 'danger'}
disabled={!it.can_void || voidingId === it.id}
onClick={() => doVoid(it)}
className="!px-3"
>
<span className="inline-flex items-center gap-1.5">
<IconUndo size={15} />
{voidingId === it.id ? '撤回中...' : '撤回'}
</span>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{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">