From cb01c144216ffb04eee5ea900dd25a8b3df63219 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Thu, 20 Aug 2026 13:51:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E7=B5=90=E7=AE=97?= =?UTF-8?q?=E4=B8=8A=E5=82=B3=E6=88=B0=E7=B8=BE=E6=99=82=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E5=B9=AB=E5=BF=98=E8=A8=98=E5=A0=B1=E5=90=8D=E7=9A=84=E4=B8=8A?= =?UTF-8?q?=E5=A0=B4=E7=90=83=E5=93=A1=E8=A3=9C=E5=A0=B1=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: - POST /api/history 寫入戰績後,檢查上場 4 人是否在當天 attendance 名單, 沒報名的自動補:有 TG 綁定用 tg_members 的真實 user_id 與名稱, 沒綁定用名字 md5 前 6 bytes 取負的固定 id(與歷史匯入規則一致,已驗證) - 名字比對沿用出席統計規則(別名表+大小寫不分),輪空等佔位字不補 - 前端上傳成功後浮出「已幫 ○○ 補報名」提示,6 秒自動消失 - 補報名失敗只記 log,不影響戰績上傳 根本原因: - 報名靠 TG bot,臨時上場的人常忘了報名,出席統計就少算; 記分板結算時已經知道誰上場,是補報名最準的時機 影響: - 跨裝置記分也適用,出席統計(attendance 表)不再漏掉臨時上場的人 - 同一場重複上傳不會重複補(已在名單就跳過) 修法: - server.mjs 新增 ensureAttendanceSignups / getTaipeiPollDate / syntheticUserId - types.ts 的 HistoryUploadResponse 增加 addedSignups - App.tsx 新增 signupNotice 狀態與浮動提示 Co-Authored-By: Claude Fable 5 --- README.md | 1 + server/server.mjs | 88 ++++++++++++++++++++++++++++++++++++++++++++++- src/App.tsx | 24 +++++++++++++ src/types.ts | 2 ++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c8beedb..5120a1a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ - 每筆資料可刪除,刪除前會顯示確認提示。 - 今日場次 - 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。 + - 結算上傳戰績時會檢查上場 4 人有沒有在當天 `attendance` 報名名單,忘了報名會自動補:有 TG 綁定(`tg_members`)就用真實 user_id,沒有就用名字 md5 產生的固定負數 id(與歷史匯入規則一致);補了誰會在畫面上提示。輪空等佔位字不補。 - 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。 - 出席率統計 - 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。 diff --git a/server/server.mjs b/server/server.mjs index 27254cd..9d1bb61 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -1,7 +1,7 @@ import 'dotenv/config' import express from 'express' import mysql from 'mysql2/promise' -import { X509Certificate } from 'node:crypto' +import { createHash, X509Certificate } from 'node:crypto' import path from 'node:path' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -505,10 +505,20 @@ app.post('/api/history', async (request, response) => { ], ) + // 上場的人若忘了報名(不在當天 attendance 名單),順便幫他補報名。 + // 補報名失敗不影響戰績上傳,只記 log。 + let addedSignups = [] + try { + addedSignups = await ensureAttendanceSignups(pool, players, time) + } catch (signupError) { + console.error('attendance signup error:', signupError) + } + response.json({ ok: true, data: { id: result.insertId, + addedSignups, }, message: '戰績已寫入 DB。', }) @@ -934,6 +944,82 @@ function broadcastRoomList() { }) } +// 把秒級 timestamp 轉成台北時區的 poll_date(YYYYMMDD 整數)。台北沒有日光節約,直接 +8 小時。 +function getTaipeiPollDate(timeSeconds) { + const shifted = new Date((timeSeconds + 8 * 3600) * 1000) + const year = shifted.getUTCFullYear() + const month = String(shifted.getUTCMonth() + 1).padStart(2, '0') + const day = String(shifted.getUTCDate()).padStart(2, '0') + return Number(`${year}${month}${day}`) +} + +// 沒綁 TG 的人用名字產生固定的負數 user_id(md5 前 6 bytes 取負), +// 規則與 attendance 表的歷史匯入資料一致,同一個名字永遠拿到同一個 id。 +function syntheticUserId(name) { + const hash = createHash('md5').update(name).digest() + return -hash.readUIntBE(0, 6) +} + +// 結算上傳時的補報名:上場的人若不在當天 attendance 名單就自動加入, +// 名字比對沿用出席統計的別名與大小寫規則;有 TG 綁定就用 tg_members 的資料。 +// 回傳實際補進去的名字。 +async function ensureAttendanceSignups(pool, players, timeSeconds) { + const pollDate = getTaipeiPollDate(timeSeconds) + const names = [] + const seenKeys = new Set() + + players.forEach((rawName) => { + const name = String(rawName ?? '').trim() + const key = getAttendanceKey(name) + + if (!name || seenKeys.has(key) || ATTENDANCE_IGNORED_NAMES.has(getAttendanceName(name))) { + return + } + + seenKeys.add(key) + names.push(name) + }) + + if (names.length === 0) { + return [] + } + + const [signedRows] = await pool.execute( + `SELECT nickname FROM \`${attendanceTableName}\` WHERE poll_date = ?`, + [pollDate], + ) + const signedKeys = new Set(signedRows.map((row) => getAttendanceKey(row.nickname))) + const missing = names.filter((name) => !signedKeys.has(getAttendanceKey(name))) + + if (missing.length === 0) { + return [] + } + + const [memberRows] = await pool.execute( + `SELECT user_id, nickname, tg_name, username FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`, + ) + const membersByKey = new Map( + memberRows.map((member) => [getAttendanceKey(member.nickname), member]), + ) + + const added = [] + + for (const name of missing) { + const member = membersByKey.get(getAttendanceKey(name)) + const nickname = member?.nickname ?? getAttendanceName(name) + + await pool.execute( + `INSERT INTO \`${attendanceTableName}\` (poll_date, user_id, nickname, tg_name, username) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE nickname = VALUES(nickname)`, + [pollDate, member?.user_id ?? syntheticUserId(nickname), nickname, member?.tg_name ?? null, member?.username ?? null], + ) + added.push(nickname) + } + + return added +} + // 實力限制在 1~10 的整數,缺值或壞值一律當 1。 function clampSkill(value) { const skill = Number(value) diff --git a/src/App.tsx b/src/App.tsx index fa56ae5..1ceca40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -177,6 +177,8 @@ function App() { const certWarningDismissedRef = useRef(false) const [liveRoomSession, setLiveRoomSession] = useState(null) const [navigationLockMessage, setNavigationLockMessage] = useState('') + // 結算上傳時被自動補報名的人,顯示幾秒提示。 + const [signupNotice, setSignupNotice] = useState('') // 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。 const [nextMatchSignal, setNextMatchSignal] = useState(0) const currentAppVersionRef = useRef(null) @@ -271,6 +273,18 @@ function App() { return () => window.clearTimeout(timer) }, [navigationLockMessage]) + useEffect(() => { + if (!signupNotice) { + return + } + + const timer = window.setTimeout(() => { + setSignupNotice('') + }, 6000) + + return () => window.clearTimeout(timer) + }, [signupNotice]) + useEffect(() => { document.body.classList.toggle('body-scoreboard', isScoreboardRoute) @@ -1021,6 +1035,10 @@ function App() { const result = await saveMatchHistory(payload) + if (result.addedSignups && result.addedSignups.length > 0) { + setSignupNotice(`已幫 ${result.addedSignups.join('、')} 補報名今天的出席名單。`) + } + const historyItem: MatchHistoryItem = { id: String(result.id), playedAt: formatPlayedAt(payload.time), @@ -1224,6 +1242,12 @@ function App() { ) : null} + {signupNotice ? ( +
+ {signupNotice} +
+ ) : null} + {certExpiryWarning && !pwaUpdateReady ? (
diff --git a/src/types.ts b/src/types.ts index 98533fc..bb3f233 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,6 +101,8 @@ export type HistoryUploadPayload = { export type HistoryUploadResponse = { id: number + // 上傳時被自動補報名(原本不在當天出席名單)的人。 + addedSignups?: string[] } export type HistoryRecord = {