功能:結算上傳戰績時自動幫忘記報名的上場球員補報名

摘要:
- 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 13:51:55 +08:00
co-authored by Claude Fable 5
parent 6f38c155f0
commit cb01c14421
4 changed files with 114 additions and 1 deletions
+1
View File
@@ -38,6 +38,7 @@
- 每筆資料可刪除,刪除前會顯示確認提示。
- 今日場次
- 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。
- 結算上傳戰績時會檢查上場 4 人有沒有在當天 `attendance` 報名名單,忘了報名會自動補:有 TG 綁定(`tg_members`)就用真實 user_id,沒有就用名字 md5 產生的固定負數 id(與歷史匯入規則一致);補了誰會在畫面上提示。輪空等佔位字不補。
- 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。
- 出席率統計
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
+87 -1
View File
@@ -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_dateYYYYMMDD 整數)。台北沒有日光節約,直接 +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)
+24
View File
@@ -177,6 +177,8 @@ function App() {
const certWarningDismissedRef = useRef(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('')
// 結算上傳時被自動補報名的人,顯示幾秒提示。
const [signupNotice, setSignupNotice] = useState('')
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
const [nextMatchSignal, setNextMatchSignal] = useState(0)
const currentAppVersionRef = useRef<string | null>(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() {
</div>
) : null}
{signupNotice ? (
<div className="floating-status-bubble" role="status" aria-live="polite">
{signupNotice}
</div>
) : null}
{certExpiryWarning && !pwaUpdateReady ? (
<div className="pwa-update-toast" role="alert">
<div className="pwa-update-copy">
+2
View File
@@ -101,6 +101,8 @@ export type HistoryUploadPayload = {
export type HistoryUploadResponse = {
id: number
// 上傳時被自動補報名(原本不在當天出席名單)的人。
addedSignups?: string[]
}
export type HistoryRecord = {