- 数据库: hours 表新增 exported_hours 字段(默认 0),支持部分核销与多次核销 - 后端: 新增 /export/four-hour/preview 预览接口与 /export/four-hour/confirm 事务确认接口 - 分配算法: 按部员累计未核销 ≥ 4h 筛选;单活动本次最多计 4h,超出标记「截断」;按活动时间正序抵扣 - 前端: 新增独立管理页面 FourHourExport,支持矩阵/平铺/汇总三视图、Excel/图片导出、核销确认 - 导航: 侧边栏新增「四小时导出」菜单项,图标使用新增 IconFourHour/IconCheck SVG
This commit is contained in:
@@ -7,6 +7,7 @@ import Activities from './pages/admin/Activities';
|
|||||||
import Hours from './pages/admin/Hours';
|
import Hours from './pages/admin/Hours';
|
||||||
import Users from './pages/admin/Users';
|
import Users from './pages/admin/Users';
|
||||||
import Export from './pages/admin/Export';
|
import Export from './pages/admin/Export';
|
||||||
|
import FourHourExport from './pages/admin/FourHourExport';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +22,7 @@ export default function App() {
|
|||||||
<Route path="hours" element={<Hours />} />
|
<Route path="hours" element={<Hours />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="export" element={<Export />} />
|
<Route path="export" element={<Export />} />
|
||||||
|
<Route path="four-hour-export" element={<FourHourExport />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/u" replace />} />
|
<Route path="*" element={<Navigate to="/u" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type {
|
import type {
|
||||||
WorkGroup, UserItem, UserDetail, ActivityItem, Participant,
|
WorkGroup, UserItem, UserDetail, ActivityItem, Participant,
|
||||||
HoursActivityResult, ExportResult,
|
HoursActivityResult, ExportResult, FourHourPreviewResult, FourHourPlanItem,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@@ -100,4 +100,11 @@ export const exportApi = {
|
|||||||
include_user_totals?: boolean;
|
include_user_totals?: boolean;
|
||||||
include_user_activities?: boolean;
|
include_user_activities?: boolean;
|
||||||
}) => api.post<ExportResult>('/export', opts).then(r => r.data),
|
}) => api.post<ExportResult>('/export', opts).then(r => r.data),
|
||||||
|
fourHourPreview: () =>
|
||||||
|
api.post<FourHourPreviewResult>('/export/four-hour/preview').then(r => r.data),
|
||||||
|
fourHourConfirm: (plan: FourHourPlanItem[]) =>
|
||||||
|
api.post<{ ok: boolean; updated: number; skipped: number; total_processed: number }>(
|
||||||
|
'/export/four-hour/confirm',
|
||||||
|
{ plan }
|
||||||
|
).then(r => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,3 +128,18 @@ export const IconBatchSet: React.FC<IconProps> = ({ size, ...rest }) => (
|
|||||||
<path d="M3 6h13v4H3zM20 8h-3M3 14h13v4H3zM20 16h-3" />
|
<path d="M3 6h13v4H3zM20 8h-3M3 14h13v4H3zM20 16h-3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IconFourHour: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||||
|
<svg {...base(size)} {...rest}>
|
||||||
|
<path d="M4 5h13l3 3v11H4z" />
|
||||||
|
<path d="M17 5v3h3" />
|
||||||
|
<path d="M9 13l2 0M9 11l0 2 1.5 0" />
|
||||||
|
<path d="M14.5 11l1 3M14 11.5h2l-2 3h2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconCheck: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||||
|
<svg {...base(size)} {...rest}>
|
||||||
|
<path d="M4 12.5l4.5 4.5L20 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import Modal from '../../components/Modal';
|
|||||||
import Input from '../../components/Input';
|
import Input from '../../components/Input';
|
||||||
import Brand from '../../components/Brand';
|
import Brand from '../../components/Brand';
|
||||||
import { authApi, errMsg } from '../../api';
|
import { authApi, errMsg } from '../../api';
|
||||||
import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu } from '../../components/Icons';
|
import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu, IconFourHour } from '../../components/Icons';
|
||||||
|
|
||||||
type MenuItem = { path: string; label: string; icon: ReactNode };
|
type MenuItem = { path: string; label: string; icon: ReactNode };
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ const menu: MenuItem[] = [
|
|||||||
{ path: '/admin/activities', label: '活动设置', icon: <IconCalendar size={20} /> },
|
{ path: '/admin/activities', label: '活动设置', icon: <IconCalendar size={20} /> },
|
||||||
{ path: '/admin/hours', label: '时长登记', icon: <IconClock size={20} /> },
|
{ path: '/admin/hours', label: '时长登记', icon: <IconClock size={20} /> },
|
||||||
{ path: '/admin/users', label: '用户管理', icon: <IconUsers size={20} /> },
|
{ path: '/admin/users', label: '用户管理', icon: <IconUsers size={20} /> },
|
||||||
|
{ path: '/admin/four-hour-export', label: '四小时导出', icon: <IconFourHour size={20} /> },
|
||||||
{ path: '/admin/export', label: '数据导出', icon: <IconExport size={20} /> },
|
{ path: '/admin/export', label: '数据导出', icon: <IconExport size={20} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,49 @@ export interface ExportResult {
|
|||||||
users: Array<Record<string, any>>;
|
users: Array<Record<string, any>>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FourHourPlanItem {
|
||||||
|
hour_id: number;
|
||||||
|
delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FourHourPreviewResult {
|
||||||
|
ok: boolean;
|
||||||
|
generated_at: string;
|
||||||
|
summary: {
|
||||||
|
eligible_count: number;
|
||||||
|
eligible_hours_total: number;
|
||||||
|
};
|
||||||
|
activity_matrix: {
|
||||||
|
activities: Array<{ id: number; title: string; start_time: string }>;
|
||||||
|
users: Array<Record<string, any> & {
|
||||||
|
_remarks?: Record<string, string>;
|
||||||
|
_truncated?: Record<string, boolean>;
|
||||||
|
_total: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
hours: Array<{
|
||||||
|
idx: number;
|
||||||
|
class: string;
|
||||||
|
name: string;
|
||||||
|
volunteer_no: string | null;
|
||||||
|
group_name: string | null;
|
||||||
|
activity_title: string;
|
||||||
|
activity_start_time: string;
|
||||||
|
hours: number;
|
||||||
|
truncated: boolean;
|
||||||
|
remark: string | null;
|
||||||
|
}>;
|
||||||
|
user_totals: Array<{
|
||||||
|
rank: number;
|
||||||
|
id: number;
|
||||||
|
class: string;
|
||||||
|
name: string;
|
||||||
|
volunteer_no: string | null;
|
||||||
|
group_name: string | null;
|
||||||
|
exported_total: number;
|
||||||
|
unexported_remaining: number;
|
||||||
|
}>;
|
||||||
|
confirm_plan: FourHourPlanItem[];
|
||||||
|
per_user_remaining?: Array<{ user_id: number; remaining: number }>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,6 +177,48 @@ export async function initDb() {
|
|||||||
try { d.execCompat('ROLLBACK'); } catch {}
|
try { d.execCompat('ROLLBACK'); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pragma = d.prepareCompat(`PRAGMA table_info('hours')`).all() || [];
|
||||||
|
const hasExported = pragma.some((c: any) => String(c.name || '').toLowerCase() === 'exported_hours');
|
||||||
|
if (!hasExported) {
|
||||||
|
console.log('[DB] Migrating hours: add exported_hours column (default 0)');
|
||||||
|
d.prepareCompat(`ALTER TABLE hours ADD COLUMN exported_hours REAL NOT NULL DEFAULT 0`).run();
|
||||||
|
d.save();
|
||||||
|
console.log('[DB] exported_hours column added');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('exported_hours migration error, try rebuild path:', e?.message || e);
|
||||||
|
try {
|
||||||
|
console.log('[DB] Fallback: rebuild hours table with exported_hours');
|
||||||
|
d.execCompat('BEGIN');
|
||||||
|
d.execCompat(`CREATE TABLE IF NOT EXISTS hours_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activity_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
hours REAL NOT NULL DEFAULT 0,
|
||||||
|
exported_hours REAL NOT NULL DEFAULT 0,
|
||||||
|
remark TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
||||||
|
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
const cols = d.prepareCompat(`PRAGMA table_info('hours')`).all() || [];
|
||||||
|
const existingCols = (cols as any[]).map((c: any) => String(c.name || '')).filter(n =>
|
||||||
|
['id','activity_id','user_id','hours','remark','created_at'].includes(n)
|
||||||
|
).join(',');
|
||||||
|
d.execCompat(`INSERT INTO hours_new (${existingCols}, exported_hours)
|
||||||
|
SELECT ${existingCols}, 0 AS exported_hours FROM hours`);
|
||||||
|
d.execCompat('DROP TABLE IF EXISTS hours');
|
||||||
|
d.execCompat('ALTER TABLE hours_new RENAME TO hours');
|
||||||
|
d.execCompat('COMMIT');
|
||||||
|
d.save();
|
||||||
|
console.log('[DB] Rebuild hours migration done');
|
||||||
|
} catch (e2: any) {
|
||||||
|
console.error('Rebuild hours migration failed', e2?.message || e2);
|
||||||
|
try { d.execCompat('ROLLBACK'); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
|
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
|
||||||
if (!adminCount || adminCount.c === 0) {
|
if (!adminCount || adminCount.c === 0) {
|
||||||
const hash = bcrypt.hashSync('admin123', 10);
|
const hash = bcrypt.hashSync('admin123', 10);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS hours (
|
|||||||
activity_id INTEGER NOT NULL,
|
activity_id INTEGER NOT NULL,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
hours REAL NOT NULL DEFAULT 0,
|
hours REAL NOT NULL DEFAULT 0,
|
||||||
|
exported_hours REAL NOT NULL DEFAULT 0,
|
||||||
remark TEXT,
|
remark TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
||||||
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -157,4 +157,226 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type HourRow = {
|
||||||
|
id: number;
|
||||||
|
activity_id: number;
|
||||||
|
user_id: number;
|
||||||
|
hours: number;
|
||||||
|
exported_hours: number;
|
||||||
|
remark: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post('/four-hour/preview', authMiddleware, (_req: AuthRequest, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const usersRaw = db.prepareCompat(`
|
||||||
|
SELECT u.id, u.class, u.name, u.volunteer_no,
|
||||||
|
GROUP_CONCAT(g.name) AS _group_names_csv
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_groups ug ON u.id = ug.user_id
|
||||||
|
LEFT JOIN work_groups g ON ug.group_id = g.id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.class, u.name
|
||||||
|
`).all() as any[];
|
||||||
|
const users = withGroupNames(usersRaw);
|
||||||
|
|
||||||
|
const activities = db.prepareCompat(`
|
||||||
|
SELECT id, title, start_time FROM activities ORDER BY start_time ASC, id ASC
|
||||||
|
`).all() as any[];
|
||||||
|
|
||||||
|
const hoursRows = db.prepareCompat(`
|
||||||
|
SELECT h.id, h.activity_id, h.user_id, h.hours,
|
||||||
|
COALESCE(h.exported_hours, 0) AS exported_hours,
|
||||||
|
h.remark, h.created_at, a.start_time
|
||||||
|
FROM hours h
|
||||||
|
JOIN activities a ON a.id = h.activity_id
|
||||||
|
ORDER BY a.start_time ASC, h.id ASC
|
||||||
|
`).all() as (HourRow & { start_time: string })[];
|
||||||
|
|
||||||
|
type UAKey = string;
|
||||||
|
const uaRemaining = new Map<UAKey, number>();
|
||||||
|
const uaRemark = new Map<UAKey, string>();
|
||||||
|
const uaHourIds = new Map<UAKey, number[]>();
|
||||||
|
const uaHourRemains = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const h of hoursRows) {
|
||||||
|
const key: UAKey = `${h.user_id}|${h.activity_id}`;
|
||||||
|
const total = Number(h.hours || 0);
|
||||||
|
const exported = Number(h.exported_hours || 0);
|
||||||
|
const remain = Math.max(0, Number((total - exported).toFixed(1)));
|
||||||
|
uaRemaining.set(key, Number(((uaRemaining.get(key) || 0) + remain).toFixed(1)));
|
||||||
|
if (h.remark) {
|
||||||
|
const prev = uaRemark.get(key);
|
||||||
|
uaRemark.set(key, prev ? `${prev};${String(h.remark)}` : String(h.remark));
|
||||||
|
}
|
||||||
|
let list = uaHourIds.get(key);
|
||||||
|
if (!list) { list = []; uaHourIds.set(key, list); }
|
||||||
|
list.push(h.id);
|
||||||
|
uaHourRemains.set(h.id, remain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perUserRemaining = new Map<number, number>();
|
||||||
|
for (const [k, v] of uaRemaining.entries()) {
|
||||||
|
const [uidS] = k.split('|');
|
||||||
|
const uid = Number(uidS);
|
||||||
|
perUserRemaining.set(uid, Number(((perUserRemaining.get(uid) || 0) + v).toFixed(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleUsers = users.filter((u) => (perUserRemaining.get(Number(u.id)) || 0) >= 4);
|
||||||
|
const eligibleUserIds = new Set(eligibleUsers.map(u => Number(u.id)));
|
||||||
|
|
||||||
|
const perUaAlloc = new Map<UAKey, number>();
|
||||||
|
const perUaTruncated = new Map<UAKey, boolean>();
|
||||||
|
const touchedActivityIds = new Set<number>();
|
||||||
|
const plan: Array<{ hour_id: number; delta: number }> = [];
|
||||||
|
|
||||||
|
for (const u of eligibleUsers) {
|
||||||
|
const uid = Number(u.id);
|
||||||
|
let quota = 4;
|
||||||
|
for (const a of activities) {
|
||||||
|
if (quota <= 0.0001) break;
|
||||||
|
const key: UAKey = `${uid}|${a.id}`;
|
||||||
|
const uaR = uaRemaining.get(key) || 0;
|
||||||
|
if (uaR <= 0.0001) continue;
|
||||||
|
const take = Math.min(uaR, quota, 4);
|
||||||
|
const takeFixed = Number(take.toFixed(1));
|
||||||
|
perUaAlloc.set(key, takeFixed);
|
||||||
|
perUaTruncated.set(key, uaR - takeFixed > 0.0001);
|
||||||
|
touchedActivityIds.add(Number(a.id));
|
||||||
|
quota = Number((quota - takeFixed).toFixed(1));
|
||||||
|
|
||||||
|
const hourIds = uaHourIds.get(key) || [];
|
||||||
|
let remainForUA = takeFixed;
|
||||||
|
for (const hid of hourIds) {
|
||||||
|
if (remainForUA <= 0.0001) break;
|
||||||
|
const rowR = uaHourRemains.get(hid) || 0;
|
||||||
|
if (rowR <= 0.0001) continue;
|
||||||
|
const d = Math.min(rowR, remainForUA);
|
||||||
|
const dFixed = Number(d.toFixed(1));
|
||||||
|
plan.push({ hour_id: hid, delta: dFixed });
|
||||||
|
uaHourRemains.set(hid, Number((rowR - dFixed).toFixed(1)));
|
||||||
|
remainForUA = Number((remainForUA - dFixed).toFixed(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrixActivities = activities.filter(a => touchedActivityIds.has(Number(a.id)))
|
||||||
|
.sort((x, y) => String(y.start_time).localeCompare(String(x.start_time)) || Number(y.id) - Number(x.id));
|
||||||
|
|
||||||
|
const matrixUsers = eligibleUsers.map((u) => {
|
||||||
|
const uid = Number(u.id);
|
||||||
|
const row: Record<string, any> = {
|
||||||
|
id: u.id,
|
||||||
|
class: u.class,
|
||||||
|
name: u.name,
|
||||||
|
volunteer_no: u.volunteer_no,
|
||||||
|
group_name: u.group_name,
|
||||||
|
_remarks: {} as Record<string, string>,
|
||||||
|
_truncated: {} as Record<string, boolean>,
|
||||||
|
};
|
||||||
|
let total = 0;
|
||||||
|
for (const a of matrixActivities) {
|
||||||
|
const key: UAKey = `${uid}|${a.id}`;
|
||||||
|
const alloc = perUaAlloc.get(key) || 0;
|
||||||
|
row[String(a.id)] = Number(alloc.toFixed(1));
|
||||||
|
total += alloc;
|
||||||
|
const rmk = uaRemark.get(key);
|
||||||
|
if (rmk) (row._remarks as any)[String(a.id)] = rmk;
|
||||||
|
if (perUaTruncated.get(key)) (row._truncated as any)[String(a.id)] = true;
|
||||||
|
}
|
||||||
|
row._total = Number(total.toFixed(1));
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatRows: any[] = [];
|
||||||
|
let serial = 1;
|
||||||
|
for (const a of matrixActivities) {
|
||||||
|
for (const uRow of matrixUsers) {
|
||||||
|
const v = Number(uRow[String(a.id)] || 0);
|
||||||
|
if (v <= 0) continue;
|
||||||
|
flatRows.push({
|
||||||
|
idx: serial++,
|
||||||
|
class: uRow.class,
|
||||||
|
name: uRow.name,
|
||||||
|
volunteer_no: uRow.volunteer_no,
|
||||||
|
group_name: uRow.group_name,
|
||||||
|
activity_title: a.title,
|
||||||
|
activity_start_time: a.start_time,
|
||||||
|
hours: v,
|
||||||
|
truncated: !!((uRow as any)._truncated?.[String(a.id)]),
|
||||||
|
remark: (uRow as any)._remarks?.[String(a.id)] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flatRows.sort((a, b) =>
|
||||||
|
String(a.activity_start_time).localeCompare(String(b.activity_start_time)) ||
|
||||||
|
String(a.class).localeCompare(String(b.class)) ||
|
||||||
|
String(a.name).localeCompare(String(b.name))
|
||||||
|
);
|
||||||
|
flatRows.forEach((r, i) => r.idx = i + 1);
|
||||||
|
|
||||||
|
const userTotals = matrixUsers.map((u, i) => ({
|
||||||
|
rank: i + 1,
|
||||||
|
id: u.id,
|
||||||
|
class: u.class,
|
||||||
|
name: u.name,
|
||||||
|
volunteer_no: u.volunteer_no,
|
||||||
|
group_name: u.group_name,
|
||||||
|
exported_total: Number(u._total || 0),
|
||||||
|
unexported_remaining: Number(((perUserRemaining.get(Number(u.id)) || 0) - Number(u._total || 0)).toFixed(1)),
|
||||||
|
})).sort((a, b) => b.exported_total - a.exported_total || String(a.class).localeCompare(String(b.class)));
|
||||||
|
userTotals.forEach((r, i) => r.rank = i + 1);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
eligible_count: eligibleUsers.length,
|
||||||
|
eligible_hours_total: Number(matrixUsers.reduce((s, u) => s + Number(u._total || 0), 0).toFixed(1)),
|
||||||
|
},
|
||||||
|
activity_matrix: {
|
||||||
|
activities: matrixActivities,
|
||||||
|
users: matrixUsers,
|
||||||
|
},
|
||||||
|
hours: flatRows,
|
||||||
|
user_totals: userTotals,
|
||||||
|
confirm_plan: plan,
|
||||||
|
per_user_remaining: perUserRemaining.size ? Array.from(perUserRemaining.entries()).map(([id, r]) => ({ user_id: id, remaining: r })) : [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
|
||||||
|
const plan: Array<{ hour_id: number; delta: number }> | undefined = (req.body as any)?.plan;
|
||||||
|
if (!Array.isArray(plan) || plan.length === 0) {
|
||||||
|
res.status(400).json({ error: '导出计划为空,请先生成预览' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
try {
|
||||||
|
db.execCompat('BEGIN');
|
||||||
|
const updStmt = db.prepareCompat(`
|
||||||
|
UPDATE hours SET exported_hours = COALESCE(exported_hours, 0) + ?
|
||||||
|
WHERE id = ? AND COALESCE(exported_hours, 0) + ? <= hours + 0.0001
|
||||||
|
`);
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const p of plan) {
|
||||||
|
const hid = Number(p.hour_id);
|
||||||
|
const d = Number(p.delta || 0);
|
||||||
|
if (!hid || d <= 0.0001) { skipped++; continue; }
|
||||||
|
const r = updStmt.run(d, hid, d);
|
||||||
|
if (r && r.changes > 0) updated++;
|
||||||
|
else skipped++;
|
||||||
|
}
|
||||||
|
db.save();
|
||||||
|
db.execCompat('COMMIT');
|
||||||
|
res.json({ ok: true, updated, skipped, total_processed: plan.length });
|
||||||
|
} catch (e: any) {
|
||||||
|
try { db.execCompat('ROLLBACK'); } catch {}
|
||||||
|
console.error('[4h-export] confirm error', e);
|
||||||
|
res.status(500).json({ error: e?.message || '确认导出失败,已回滚' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user