功能:新增更新紀錄分頁、勝利後鎖記分與結算後自動選隊、報分語音改唸個位數

摘要:
- 新增「更新紀錄」分頁,依 git commit 自動產生日誌(scripts/generate-changelog.mjs → src/data/changelog.json)
- 比賽分出勝負後鎖住記分按鈕,需先結算才能開下一場
- 結算(上傳或不上傳)完成後自動打開選隊伍面板並清空已選,方便直接排下一場
- 得分播報只唸個位數(10→0、19→9、20→0),同分改唸「N 平」

根本原因:
- 想在 App 內直接看到版本更新內容,原本沒有頁面
- 勝利後仍可繼續點加分,容易誤觸超過該局比分
- 一場打完要手動回去重設下一場對戰,流程多一步
- 報分連十位數一起唸(十九、二十)冗長,現場聽不直覺

影響:
- 新增 scripts/generate-changelog.mjs、src/pages/ChangelogPage.tsx;package.json 加 predev/prebuild/gen:changelog;tsconfig.app.json 開 resolveJsonModule;.gitignore 排除產生的 changelog.json
- src/types.ts 新增 ChangelogEntry/ChangelogData;src/App.tsx 加導覽與路由、結算後發 nextMatchSignal、recordPoint 加勝負防呆
- src/lib/match.ts 將 hasWonGame 抽出並 export 供記分板共用
- src/pages/ScoreboardPage.tsx:勝負時 canScore=false、收到 nextMatchSignal 自動清空並打開選隊 picker、報分組字改個位數與「平」
- src/App.css 新增更新紀錄卡片樣式

修法:
- changelog 於 dev/build 前自動產生,正式版讀打包後的 JSON 顯示
- hasWonGame 統一判斷(達標分/Deuce 領先 2 分/30 分上限),App 與記分板共用
- 結算成功才遞增 nextMatchSignal,記分板以「render 期間依 prop 變化調整 state」開啟選隊面板
- 報分以 servingScore % 10 / opponentScore % 10 組字,同分輸出「N 平」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:15:28 +08:00
co-authored by Claude Opus 4.8
parent 304bcaeedd
commit 40cb2f01d7
10 changed files with 353 additions and 37 deletions
+20 -19
View File
@@ -19,9 +19,11 @@ import {
getServingPlayer,
getTeamDisplayName,
getWinnerName,
hasWonGame,
parseRoster,
swapCourtPositions,
} from './lib/match'
import { ChangelogPage } from './pages/ChangelogPage'
import { HistoryPage } from './pages/HistoryPage'
import { RoomListPage } from './pages/RoomListPage'
import { RoomSpectatorPage } from './pages/RoomSpectatorPage'
@@ -143,6 +145,8 @@ function App() {
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('')
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
const [nextMatchSignal, setNextMatchSignal] = useState(0)
const currentAppVersionRef = useRef<string | null>(null)
const creatingRoomRef = useRef(false)
const lastSyncedRoomSignatureRef = useRef('')
@@ -730,6 +734,11 @@ function App() {
return
}
// 已分出勝負就不再計分,需先結算才能開始下一場。
if (hasWonGame(scoreState)) {
return
}
const starter = getServerHistoryIndex(scoreState, leftTeam, rightTeam)
if (starter === null) {
@@ -865,6 +874,7 @@ function App() {
uploading: false,
})
resetScoring(initialScoreState, { releaseLiveRoom: false })
setNextMatchSignal((current) => current + 1)
})
}
@@ -914,6 +924,7 @@ function App() {
uploading: false,
})
resetScoring(initialScoreState, { releaseLiveRoom: false })
setNextMatchSignal((current) => current + 1)
} catch (error) {
setSettlement({
error: error instanceof Error ? error.message : '上傳戰績失敗。',
@@ -975,6 +986,13 @@ function App() {
>
</NavLink>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/changelog')}
to="/changelog"
>
</NavLink>
</nav>
</header>
@@ -1031,6 +1049,7 @@ function App() {
hasRecordedPoint={pointLog.length > 0}
leftTeam={leftTeam}
liveRoomId={liveRoomId}
nextMatchSignal={nextMatchSignal}
rightTeam={rightTeam}
scoreState={scoreState}
selectedGroup={selectedGroup}
@@ -1052,6 +1071,7 @@ function App() {
}
/>
<Route path="/history" element={<HistoryPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/rooms" element={<RoomListPage />} />
<Route
path="/rooms/:roomId"
@@ -1159,25 +1179,6 @@ function getNextServerName(
return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? ''
}
function hasWonGame(state: ScoreState) {
const leadingScore = Math.max(state.scoreLeft, state.scoreRight)
const trailingScore = Math.min(state.scoreLeft, state.scoreRight)
if (leadingScore < state.targetScore) {
return false
}
if (leadingScore >= 30) {
return true
}
if (trailingScore >= state.targetScore - 1) {
return leadingScore - trailingScore >= 2
}
return true
}
function formatPlayedAt(timestamp: number) {
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })
}