功能:選隊面板新增「同步今日場次」,從 history 表撈今天資料覆蓋本機場次
摘要: - 選隊伍面板新增「同步今日場次」按鈕,按下後把每個人的今日場次更新成 DB 的數字 - 後端新增 GET /api/history/play-counts?from=&to=,回傳區間內每人上場場數與比賽數 - 按鈕上方顯示同步結果或錯誤訊息;手機版同步按鈕獨占一列 根本原因: - 今日場次只存在本機 localStorage,跨裝置記分時其他裝置上傳的場次本機看不到, 自動選擇與「今日 N 場」提示就不準 影響: - 任一裝置按同步後,今日場次會與 DB history 表一致(以 DB 為準,覆蓋本機) - 輪空/那個等佔位字不計場次 修法: - server.mjs 新增 play-counts 端點,依 time 區間查 history.players 累計 - api.ts 新增 loadHistoryPlayCounts;App.tsx 以本機時區當天 00:00 起算 24 小時區間並 setPlayCounts - ScoreboardPage 加入同步狀態與按鈕,App.css 調整按鈕列為三欄、≤720px 改兩列 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,9 @@
|
||||
- 可從資料庫讀取歷史列表。
|
||||
- 點開單筆可查看得分過程。
|
||||
- 每筆資料可刪除,刪除前會顯示確認提示。
|
||||
- 今日場次
|
||||
- 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。
|
||||
- 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。
|
||||
- 出席率統計
|
||||
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
|
||||
- 資料取自 `attendance` 表(TG 報名 bot 每天每人一列),不是 `history` 表,所以有報名到場但沒被記分的場次也算出席;當天名單還沒存進 `badminton` 表也已經算得到。
|
||||
|
||||
@@ -528,6 +528,63 @@ app.get('/api/history', async (request, response) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 指定時間區間內(通常是「今天」)每個人在 history 表打了幾場,
|
||||
// 讓不同裝置記分後可以把本機的今日場次同步成 DB 的數字。
|
||||
app.get('/api/history/play-counts', async (request, response) => {
|
||||
if (!pool) {
|
||||
response.status(500).json({
|
||||
ok: false,
|
||||
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const from = Number.parseInt(request.query.from, 10)
|
||||
const to = Number.parseInt(request.query.to, 10)
|
||||
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to) {
|
||||
response.status(400).json({
|
||||
ok: false,
|
||||
message: '時間區間格式不正確。',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureHistoryTable(pool, historyTableName)
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT players FROM \`${historyTableName}\` WHERE time >= ? AND time < ?`,
|
||||
[from, to],
|
||||
)
|
||||
const counts = {}
|
||||
|
||||
rows.forEach((row) => {
|
||||
parseAttendanceNames(row.players).forEach((name) => {
|
||||
// 輪空等佔位字不是真的有人上場。
|
||||
if (ATTENDANCE_IGNORED_NAMES.has(name)) {
|
||||
return
|
||||
}
|
||||
|
||||
counts[name] = (counts[name] ?? 0) + 1
|
||||
})
|
||||
})
|
||||
|
||||
response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
counts,
|
||||
matches: rows.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('history play-counts error:', error)
|
||||
response.status(500).json({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : '讀取今日場次失敗。',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/history/:id', async (request, response) => {
|
||||
if (!pool) {
|
||||
response.status(500).json({
|
||||
|
||||
+31
-1
@@ -1540,10 +1540,27 @@
|
||||
|
||||
.team-picker-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
/* 同步今日場次 / 自動選擇 / 確認 三顆並排,確認稍寬一點當主要按鈕。 */
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.team-picker-actions button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 同步今日場次的結果或錯誤訊息,放在按鈕列上方。 */
|
||||
.team-picker-sync-note {
|
||||
margin: 0 0 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
color: #5c4633;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
box-shadow: inset 0 0 0 1px rgba(199, 155, 83, 0.22);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.team-picker-ghost,
|
||||
.team-picker-confirm,
|
||||
.team-picker-clear {
|
||||
@@ -2670,6 +2687,19 @@
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* 手機版面板很窄,同步按鈕獨占一列,下面才是自動選擇 / 確認。 */
|
||||
.team-picker-actions {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.team-picker-sync-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.team-picker-sync-note {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.preset-team-list {
|
||||
gap: 6px;
|
||||
max-height: min(42dvh, 300px);
|
||||
|
||||
+16
@@ -3,6 +3,7 @@ import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-d
|
||||
import './App.css'
|
||||
import {
|
||||
createLiveRoom,
|
||||
loadHistoryPlayCounts,
|
||||
loadMatchResults,
|
||||
releaseLiveRoom,
|
||||
rememberCertNotAfter,
|
||||
@@ -939,6 +940,20 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
// 從 DB 的 history 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字。
|
||||
// 跨裝置記分時,其他裝置上傳的場次本機沒有紀錄,靠這個補齊。
|
||||
const syncPlayCountsFromHistory = async () => {
|
||||
const dayStart = new Date()
|
||||
dayStart.setHours(0, 0, 0, 0)
|
||||
const from = Math.floor(dayStart.getTime() / 1000)
|
||||
const to = from + 24 * 60 * 60
|
||||
|
||||
const result = await loadHistoryPlayCounts(from, to)
|
||||
setPlayCounts(result.counts)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const skipUpload = () => {
|
||||
void finalizeLiveRoom().finally(() => {
|
||||
recordPlayedMatch()
|
||||
@@ -1140,6 +1155,7 @@ function App() {
|
||||
voiceAnnouncement={voiceAnnouncement}
|
||||
targetDate={targetDate}
|
||||
onApplyMatchup={applyMatchup}
|
||||
onSyncPlayCounts={syncPlayCountsFromHistory}
|
||||
onCloseFinishDialog={closeSettlementDialog}
|
||||
onConfirmUpload={uploadSettledMatch}
|
||||
onOpenFinishDialog={openSettlementDialog}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AttendanceStats,
|
||||
HistoryListItem,
|
||||
HistoryListPage,
|
||||
HistoryPlayCounts,
|
||||
HistoryRecord,
|
||||
HistoryUploadPayload,
|
||||
HistoryUploadResponse,
|
||||
@@ -154,6 +155,26 @@ export async function loadAttendanceStats(): Promise<AttendanceStats> {
|
||||
return payload.data
|
||||
}
|
||||
|
||||
// 讀取 history 表在 [from, to) 秒級時間區間內每個人打了幾場。
|
||||
export async function loadHistoryPlayCounts(from: number, to: number): Promise<HistoryPlayCounts> {
|
||||
const response = await apiFetch(`/api/history/play-counts?from=${from}&to=${to}`)
|
||||
const payload = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: HistoryPlayCounts
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('後端還沒更新到同步場次功能,請重新部署最新版。')
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.data) {
|
||||
throw new Error(payload.message ?? '讀取今日場次失敗。')
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export async function deleteHistoryItem(id: number) {
|
||||
const response = await apiFetch(`/api/history/${id}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type {
|
||||
CourtSide,
|
||||
GroupTeam,
|
||||
HistoryPlayCounts,
|
||||
PlayerSlot,
|
||||
PointHistoryEntry,
|
||||
RoundGroup,
|
||||
@@ -85,6 +86,7 @@ type ScoreboardPageProps = {
|
||||
onOpenFinishDialog: () => void
|
||||
onRecordPoint: (side: ScoreSide) => void
|
||||
onSetServing: (side: ScoreSide) => void
|
||||
onSyncPlayCounts: () => Promise<HistoryPlayCounts>
|
||||
onSkipUpload: () => void
|
||||
onSwapMatchup: () => void
|
||||
onSwapTeamPlayers: (side: ScoreSide) => void
|
||||
@@ -117,6 +119,7 @@ export function ScoreboardPage({
|
||||
onOpenFinishDialog,
|
||||
onRecordPoint,
|
||||
onSetServing,
|
||||
onSyncPlayCounts,
|
||||
onSkipUpload,
|
||||
onSwapMatchup,
|
||||
onSwapTeamPlayers,
|
||||
@@ -124,6 +127,11 @@ export function ScoreboardPage({
|
||||
}: ScoreboardPageProps) {
|
||||
const FINISH_HOLD_DURATION = 1000
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
// 從 DB 同步今日場次的進度與結果訊息,只在選隊面板顯示。
|
||||
const [playCountsSync, setPlayCountsSync] = useState<{ loading: boolean; message: string }>({
|
||||
loading: false,
|
||||
message: '',
|
||||
})
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
||||
const [draftTargetScore, setDraftTargetScore] = useState(() =>
|
||||
@@ -544,6 +552,32 @@ export function ScoreboardPage({
|
||||
setDraftPlayers(shuffled.slice(0, 4))
|
||||
}
|
||||
|
||||
const syncPlayCounts = () => {
|
||||
if (playCountsSync.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
setPlayCountsSync({ loading: true, message: '' })
|
||||
|
||||
onSyncPlayCounts()
|
||||
.then((result) => {
|
||||
const playerCount = Object.keys(result.counts).length
|
||||
setPlayCountsSync({
|
||||
loading: false,
|
||||
message:
|
||||
result.matches === 0
|
||||
? 'DB 今天還沒有比賽紀錄,今日場次已全部歸零。'
|
||||
: `已同步 DB 今天 ${result.matches} 場比賽、${playerCount} 位球員的場次。`,
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setPlayCountsSync({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : '同步今日場次失敗。',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{streakAnnouncement ? (
|
||||
@@ -732,6 +766,7 @@ export function ScoreboardPage({
|
||||
extraPlayerSet={extraPlayerSet}
|
||||
group={selectedGroup}
|
||||
playCounts={playCounts}
|
||||
playCountsSync={playCountsSync}
|
||||
presetTeams={presetTeams}
|
||||
selectablePlayers={selectablePlayers}
|
||||
selectionCount={draftPlayers.length}
|
||||
@@ -742,6 +777,7 @@ export function ScoreboardPage({
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={confirmDraftTeams}
|
||||
onDraftTargetScoreChange={setDraftTargetScore}
|
||||
onSyncPlayCounts={syncPlayCounts}
|
||||
onTogglePlayer={toggleDraftPlayer}
|
||||
onTogglePresetTeam={togglePresetTeam}
|
||||
/>
|
||||
@@ -921,6 +957,7 @@ type TeamPickerModalProps = {
|
||||
draftPlayers: string[]
|
||||
extraPlayerSet: Set<string>
|
||||
playCounts: Record<string, number>
|
||||
playCountsSync: { loading: boolean; message: string }
|
||||
draftTargetScore: string
|
||||
group: RoundGroup
|
||||
presetTeams: GroupTeam[]
|
||||
@@ -933,6 +970,7 @@ type TeamPickerModalProps = {
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
onDraftTargetScoreChange: (value: string) => void
|
||||
onSyncPlayCounts: () => void
|
||||
onTogglePlayer: (playerName: string) => void
|
||||
onTogglePresetTeam: (team: GroupTeam) => void
|
||||
}
|
||||
@@ -941,6 +979,7 @@ function TeamPickerModal({
|
||||
draftPlayers,
|
||||
extraPlayerSet,
|
||||
playCounts,
|
||||
playCountsSync,
|
||||
draftTargetScore,
|
||||
group,
|
||||
presetTeams,
|
||||
@@ -953,6 +992,7 @@ function TeamPickerModal({
|
||||
onClose,
|
||||
onConfirm,
|
||||
onDraftTargetScoreChange,
|
||||
onSyncPlayCounts,
|
||||
onTogglePlayer,
|
||||
onTogglePresetTeam,
|
||||
}: TeamPickerModalProps) {
|
||||
@@ -1035,7 +1075,20 @@ function TeamPickerModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{playCountsSync.message ? (
|
||||
<p className="team-picker-sync-note">{playCountsSync.message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="team-picker-actions">
|
||||
<button
|
||||
className="team-picker-ghost team-picker-sync-button"
|
||||
disabled={playCountsSync.loading}
|
||||
title="從 DB 歷史戰績撈今天的比賽,把每個人的今日場次更新成 DB 的數字(跨裝置記分用)"
|
||||
type="button"
|
||||
onClick={onSyncPlayCounts}
|
||||
>
|
||||
{playCountsSync.loading ? '同步中…' : '同步今日場次'}
|
||||
</button>
|
||||
<button className="team-picker-ghost" type="button" onClick={onAutoPick}>
|
||||
自動選擇
|
||||
</button>
|
||||
|
||||
@@ -131,6 +131,11 @@ export type HistoryListItem = {
|
||||
winnerTeamName: string
|
||||
}
|
||||
|
||||
export type HistoryPlayCounts = {
|
||||
counts: Record<string, number>
|
||||
matches: number
|
||||
}
|
||||
|
||||
export type HistoryListPage = {
|
||||
items: HistoryListItem[]
|
||||
page: number
|
||||
|
||||
Reference in New Issue
Block a user