功能:結算上傳戰績時自動幫忘記報名的上場球員補報名
摘要: - 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:
@@ -38,6 +38,7 @@
|
|||||||
- 每筆資料可刪除,刪除前會顯示確認提示。
|
- 每筆資料可刪除,刪除前會顯示確認提示。
|
||||||
- 今日場次
|
- 今日場次
|
||||||
- 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。
|
- 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。
|
||||||
|
- 結算上傳戰績時會檢查上場 4 人有沒有在當天 `attendance` 報名名單,忘了報名會自動補:有 TG 綁定(`tg_members`)就用真實 user_id,沒有就用名字 md5 產生的固定負數 id(與歷史匯入規則一致);補了誰會在畫面上提示。輪空等佔位字不補。
|
||||||
- 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。
|
- 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。
|
||||||
- 出席率統計
|
- 出席率統計
|
||||||
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
|
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
|
||||||
|
|||||||
+87
-1
@@ -1,7 +1,7 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import mysql from 'mysql2/promise'
|
import mysql from 'mysql2/promise'
|
||||||
import { X509Certificate } from 'node:crypto'
|
import { createHash, X509Certificate } from 'node:crypto'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
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({
|
response.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
id: result.insertId,
|
id: result.insertId,
|
||||||
|
addedSignups,
|
||||||
},
|
},
|
||||||
message: '戰績已寫入 DB。',
|
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。
|
// 實力限制在 1~10 的整數,缺值或壞值一律當 1。
|
||||||
function clampSkill(value) {
|
function clampSkill(value) {
|
||||||
const skill = Number(value)
|
const skill = Number(value)
|
||||||
|
|||||||
+24
@@ -177,6 +177,8 @@ function App() {
|
|||||||
const certWarningDismissedRef = useRef(false)
|
const certWarningDismissedRef = useRef(false)
|
||||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||||
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||||
|
// 結算上傳時被自動補報名的人,顯示幾秒提示。
|
||||||
|
const [signupNotice, setSignupNotice] = useState('')
|
||||||
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
|
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
|
||||||
const [nextMatchSignal, setNextMatchSignal] = useState(0)
|
const [nextMatchSignal, setNextMatchSignal] = useState(0)
|
||||||
const currentAppVersionRef = useRef<string | null>(null)
|
const currentAppVersionRef = useRef<string | null>(null)
|
||||||
@@ -271,6 +273,18 @@ function App() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [navigationLockMessage])
|
}, [navigationLockMessage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!signupNotice) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSignupNotice('')
|
||||||
|
}, 6000)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [signupNotice])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle('body-scoreboard', isScoreboardRoute)
|
document.body.classList.toggle('body-scoreboard', isScoreboardRoute)
|
||||||
|
|
||||||
@@ -1021,6 +1035,10 @@ function App() {
|
|||||||
|
|
||||||
const result = await saveMatchHistory(payload)
|
const result = await saveMatchHistory(payload)
|
||||||
|
|
||||||
|
if (result.addedSignups && result.addedSignups.length > 0) {
|
||||||
|
setSignupNotice(`已幫 ${result.addedSignups.join('、')} 補報名今天的出席名單。`)
|
||||||
|
}
|
||||||
|
|
||||||
const historyItem: MatchHistoryItem = {
|
const historyItem: MatchHistoryItem = {
|
||||||
id: String(result.id),
|
id: String(result.id),
|
||||||
playedAt: formatPlayedAt(payload.time),
|
playedAt: formatPlayedAt(payload.time),
|
||||||
@@ -1224,6 +1242,12 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{signupNotice ? (
|
||||||
|
<div className="floating-status-bubble" role="status" aria-live="polite">
|
||||||
|
{signupNotice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{certExpiryWarning && !pwaUpdateReady ? (
|
{certExpiryWarning && !pwaUpdateReady ? (
|
||||||
<div className="pwa-update-toast" role="alert">
|
<div className="pwa-update-toast" role="alert">
|
||||||
<div className="pwa-update-copy">
|
<div className="pwa-update-copy">
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export type HistoryUploadPayload = {
|
|||||||
|
|
||||||
export type HistoryUploadResponse = {
|
export type HistoryUploadResponse = {
|
||||||
id: number
|
id: number
|
||||||
|
// 上傳時被自動補報名(原本不在當天出席名單)的人。
|
||||||
|
addedSignups?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HistoryRecord = {
|
export type HistoryRecord = {
|
||||||
|
|||||||
Reference in New Issue
Block a user