功能:選隊面板新增「同步今日場次」,從 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:
2026-08-18 11:33:54 +08:00
co-authored by Claude Fable 5
parent d53ebbd3a8
commit 6ae1eda7e2
7 changed files with 186 additions and 1 deletions
+31 -1
View File
@@ -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
View File
@@ -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}
+21
View File
@@ -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',
+53
View File
@@ -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>
+5
View File
@@ -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