From ef73970930477bd8c2af7ac93fc5cbdcc2551318 Mon Sep 17 00:00:00 2001 From: Trae Assistant Date: Wed, 7 Oct 2026 00:45:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E5=9B=9B?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E5=AF=BC=E5=87=BA=E3=80=8D=E6=A0=B8=E9=94=80?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库: hours 表新增 exported_hours 字段(默认 0),支持部分核销与多次核销 - 后端: 新增 /export/four-hour/preview 预览接口与 /export/four-hour/confirm 事务确认接口 - 分配算法: 按部员累计未核销 ≥ 4h 筛选;单活动本次最多计 4h,超出标记「截断」;按活动时间正序抵扣 - 前端: 新增独立管理页面 FourHourExport,支持矩阵/平铺/汇总三视图、Excel/图片导出、核销确认 - 导航: 侧边栏新增「四小时导出」菜单项,图标使用新增 IconFourHour/IconCheck SVG --- client/src/App.tsx | 2 + client/src/api/index.ts | 9 +- client/src/components/Icons.tsx | 15 + client/src/pages/admin/FourHourExport.tsx | 460 ++++++++++++++++++++++ client/src/pages/admin/Layout.tsx | 11 +- client/src/types/index.ts | 46 +++ server/src/db/index.ts | 42 ++ server/src/db/schema.sql | 1 + server/src/routes/export.ts | 222 +++++++++++ 9 files changed, 802 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/admin/FourHourExport.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 0f35063..da35073 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,6 +7,7 @@ import Activities from './pages/admin/Activities'; import Hours from './pages/admin/Hours'; import Users from './pages/admin/Users'; import Export from './pages/admin/Export'; +import FourHourExport from './pages/admin/FourHourExport'; export default function App() { return ( @@ -21,6 +22,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 02efee7..925bbba 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import type { WorkGroup, UserItem, UserDetail, ActivityItem, Participant, - HoursActivityResult, ExportResult, + HoursActivityResult, ExportResult, FourHourPreviewResult, FourHourPlanItem, } from '../types'; const api = axios.create({ @@ -100,4 +100,11 @@ export const exportApi = { include_user_totals?: boolean; include_user_activities?: boolean; }) => api.post('/export', opts).then(r => r.data), + fourHourPreview: () => + api.post('/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), }; diff --git a/client/src/components/Icons.tsx b/client/src/components/Icons.tsx index 41deac1..af84a0d 100644 --- a/client/src/components/Icons.tsx +++ b/client/src/components/Icons.tsx @@ -128,3 +128,18 @@ export const IconBatchSet: React.FC = ({ size, ...rest }) => ( ); + +export const IconFourHour: React.FC = ({ size, ...rest }) => ( + + + + + + +); + +export const IconCheck: React.FC = ({ size, ...rest }) => ( + + + +); diff --git a/client/src/pages/admin/FourHourExport.tsx b/client/src/pages/admin/FourHourExport.tsx new file mode 100644 index 0000000..96f4c53 --- /dev/null +++ b/client/src/pages/admin/FourHourExport.tsx @@ -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 ( +
+ + + + {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 previewRef = useRef(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[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} + + {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} +
+ ); +} diff --git a/client/src/pages/admin/Layout.tsx b/client/src/pages/admin/Layout.tsx index 5eeb55d..0fdd15c 100644 --- a/client/src/pages/admin/Layout.tsx +++ b/client/src/pages/admin/Layout.tsx @@ -5,15 +5,16 @@ import Modal from '../../components/Modal'; import Input from '../../components/Input'; import Brand from '../../components/Brand'; 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 }; const menu: MenuItem[] = [ - { path: '/admin/activities', label: '活动设置', icon: }, - { path: '/admin/hours', label: '时长登记', icon: }, - { path: '/admin/users', label: '用户管理', icon: }, - { path: '/admin/export', label: '数据导出', icon: }, + { path: '/admin/activities', label: '活动设置', icon: }, + { path: '/admin/hours', label: '时长登记', icon: }, + { path: '/admin/users', label: '用户管理', icon: }, + { path: '/admin/four-hour-export', label: '四小时导出', icon: }, + { path: '/admin/export', label: '数据导出', icon: }, ]; export default function Layout() { diff --git a/client/src/types/index.ts b/client/src/types/index.ts index c88f884..d87860a 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -101,3 +101,49 @@ export interface ExportResult { users: Array>; }; } + +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 & { + _remarks?: Record; + _truncated?: Record; + _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 }>; +} diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 34c78b3..539376d 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -177,6 +177,48 @@ export async function initDb() { 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(); if (!adminCount || adminCount.c === 0) { const hash = bcrypt.hashSync('admin123', 10); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 1350628..9a8e9ba 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS hours ( 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, diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts index 258d914..9a63098 100644 --- a/server/src/routes/export.ts +++ b/server/src/routes/export.ts @@ -157,4 +157,226 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => { 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(); + const uaRemark = new Map(); + const uaHourIds = new Map(); + const uaHourRemains = new Map(); + + 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(); + 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(); + const perUaTruncated = new Map(); + const touchedActivityIds = new Set(); + 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 = { + id: u.id, + class: u.class, + name: u.name, + volunteer_no: u.volunteer_no, + group_name: u.group_name, + _remarks: {} as Record, + _truncated: {} as Record, + }; + 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;