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

摘要:
- 新增「更新紀錄」分頁,依 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
parent 304bcaeedd
commit 40cb2f01d7
10 changed files with 353 additions and 37 deletions
+3
View File
@@ -15,6 +15,9 @@ dist-ssr
.env.*
!.env.example
# 由 git log 自動產生(predev / prebuild 會重建),不需追蹤避免時間戳記造成 diff 噪音
src/data/changelog.json
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+3
View File
@@ -4,9 +4,12 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"gen:changelog": "node scripts/generate-changelog.mjs",
"predev": "node scripts/generate-changelog.mjs",
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
"dev:client": "vite",
"dev:server": "node server/server.mjs",
"prebuild": "node scripts/generate-changelog.mjs",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
+57
View File
@@ -0,0 +1,57 @@
// 從 git log 產生更新紀錄資料,輸出到 src/data/changelog.json
// 正式版(build 後)沒有 git 也能直接讀 JSON 顯示;dev 與 build 前會自動重新產生。
import { execFileSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const outFile = resolve(here, '../src/data/changelog.json')
const UNIT = '\x1f' // 欄位分隔(unit separator
const RECORD = '\x1e' // 紀錄分隔(record separator
function readGitLog() {
const format = ['%H', '%h', '%ad', '%s', '%b'].join(UNIT) + RECORD
const raw = execFileSync(
'git',
['log', `--pretty=format:${format}`, '--date=format:%Y-%m-%d'],
{ cwd: resolve(here, '..'), encoding: 'utf8', maxBuffer: 1024 * 1024 * 16 },
)
return raw
.split(RECORD)
.map((chunk) => chunk.replace(/^\s+/, ''))
.filter(Boolean)
.map((chunk) => {
const [hash, shortHash, date, subject, body = ''] = chunk.split(UNIT)
return {
hash,
shortHash,
date,
subject: subject.trim(),
body: body.trim(),
}
})
}
function main() {
let entries = []
try {
entries = readGitLog()
} catch (error) {
console.warn('[generate-changelog] 讀取 git log 失敗,輸出空清單:', error.message)
}
const payload = {
generatedAt: new Date().toISOString(),
entries,
}
mkdirSync(dirname(outFile), { recursive: true })
writeFileSync(outFile, JSON.stringify(payload, null, 2) + '\n', 'utf8')
console.log(`[generate-changelog] 已輸出 ${entries.length} 筆更新紀錄到 ${outFile}`)
}
main()
+81
View File
@@ -2351,3 +2351,84 @@
display: grid;
gap: 18px;
}
.changelog-list {
display: grid;
gap: 16px;
}
.changelog-card {
position: relative;
padding: 20px 22px;
border-radius: 20px;
background: rgba(246, 249, 244, 0.95);
border: 1px solid rgba(10, 51, 45, 0.08);
}
.changelog-card-head {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.changelog-meta {
display: inline-flex;
align-items: center;
gap: 10px;
}
.changelog-date {
font-size: 0.85rem;
color: var(--panel-soft);
}
.changelog-hash {
font-family: var(--mono);
font-size: 0.78rem;
padding: 2px 8px;
border-radius: 999px;
color: var(--panel-strong);
background: rgba(11, 88, 73, 0.1);
}
.changelog-subject {
margin: 0;
font-size: 1.1rem;
line-height: 1.4;
color: var(--panel-strong);
}
.changelog-notes {
display: grid;
gap: 6px;
margin-bottom: 12px;
}
.changelog-notes p {
margin: 0;
color: var(--panel-soft);
}
.changelog-section {
margin-top: 12px;
}
.changelog-section-title {
margin: 0 0 6px;
font-size: 0.8rem;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--panel-strong);
}
.changelog-section-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 4px;
}
.changelog-section-list li {
color: var(--panel-soft);
line-height: 1.55;
}
+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 })
}
+20
View File
@@ -166,6 +166,26 @@ export function swapCourtPositions(rightCourtPlayer: PlayerSlot): PlayerSlot {
return rightCourtPlayer === 'playerA' ? 'playerB' : 'playerA'
}
// 判斷目前比分是否已分出勝負(達標分、Deuce 領先 2 分、或 30 分上限)。
export 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 createTeam(id: number, playerA: string, playerB: string) {
return {
id,
+119
View File
@@ -0,0 +1,119 @@
import { useMemo } from 'react'
import changelogData from '../data/changelog.json'
import type { ChangelogData, ChangelogEntry } from '../types'
const data = changelogData as ChangelogData
type ChangelogSection = {
title: string
items: string[]
}
type ParsedBody = {
sections: ChangelogSection[]
notes: string[]
}
// 把 commit body 拆成「摘要 / 根本原因 / 影響 / 修法」等區段,
// 過濾掉結尾的 Co-Authored-By 協作資訊。
function parseBody(body: string): ParsedBody {
const sections: ChangelogSection[] = []
const notes: string[] = []
let current: ChangelogSection | null = null
for (const rawLine of body.split('\n')) {
const line = rawLine.trim()
if (!line) {
continue
}
if (/^Co-Authored-By/i.test(line)) {
continue
}
const headerMatch = line.match(/^(.+?)$/)
if (headerMatch) {
current = { title: headerMatch[1], items: [] }
sections.push(current)
continue
}
const content = line.replace(/^[-*]\s*/, '')
if (current) {
current.items.push(content)
} else {
notes.push(content)
}
}
return { sections, notes }
}
function ChangelogCard({ entry }: { entry: ChangelogEntry }) {
const parsed = useMemo(() => parseBody(entry.body), [entry.body])
return (
<article className="changelog-card">
<div className="changelog-card-head">
<div className="changelog-meta">
<span className="changelog-date">{entry.date}</span>
<span className="changelog-hash">{entry.shortHash}</span>
</div>
<h3 className="changelog-subject">{entry.subject}</h3>
</div>
{parsed.notes.length > 0 ? (
<div className="changelog-notes">
{parsed.notes.map((note, index) => (
<p key={index}>{note}</p>
))}
</div>
) : null}
{parsed.sections.map((section, index) => (
<div className="changelog-section" key={`${section.title}-${index}`}>
<p className="changelog-section-title">{section.title}</p>
<ul className="changelog-section-list">
{section.items.map((item, itemIndex) => (
<li key={itemIndex}>{item}</li>
))}
</ul>
</div>
))}
</article>
)
}
export function ChangelogPage() {
const entries = data.entries
return (
<section className="page-grid">
<article className="panel panel-hero">
<p className="panel-kicker">Changelog</p>
<h2></h2>
<p className="panel-copy">
git commit
</p>
</article>
<article className="panel full-span">
{entries.length === 0 ? (
<div className="empty-state">
<h3></h3>
<p> changelog `npm run gen:changelog` </p>
</div>
) : (
<div className="changelog-list">
{entries.map((entry) => (
<ChangelogCard entry={entry} key={entry.hash} />
))}
</div>
)}
</article>
</section>
)
}
+24 -6
View File
@@ -7,6 +7,7 @@ import {
getServiceCourt,
getServingPlayer,
getTeamDisplayName,
hasWonGame,
} from '../lib/match'
import type {
CourtSide,
@@ -42,6 +43,7 @@ type ScoreboardPageProps = {
hasRecordedPoint: boolean
leftTeam: GroupTeam | null
liveRoomId: string | null
nextMatchSignal: number
rightTeam: GroupTeam | null
scoreState: ScoreState
selectedGroup: RoundGroup | null
@@ -92,6 +94,7 @@ export function ScoreboardPage({
hasRecordedPoint,
leftTeam,
liveRoomId,
nextMatchSignal,
rightTeam,
scoreState,
selectedGroup,
@@ -188,8 +191,9 @@ export function ScoreboardPage({
[selectedGroup],
)
const matchWon = hasWonGame(scoreState)
const canArrangeMatch = !hasRecordedPoint
const canScore = scoreState.serving !== null
const canScore = scoreState.serving !== null && !matchWon
const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0
const servingScore =
@@ -260,11 +264,14 @@ export function ScoreboardPage({
const parts: string[] = []
if (voiceSettings.announceScore) {
parts.push(
`${voiceAnnouncement.servingScore}${voiceAnnouncement.opponentScore}${
voiceAnnouncement.matchPoint ? ' 賽末點' : ''
}`,
)
// 報分只唸個位數:10→0、19→9、20→0;同分改唸「N 平」。
const spokenServing = voiceAnnouncement.servingScore % 10
const spokenOpponent = voiceAnnouncement.opponentScore % 10
const scoreText =
voiceAnnouncement.servingScore === voiceAnnouncement.opponentScore
? `${spokenServing}`
: `${spokenServing}${spokenOpponent}`
parts.push(`${scoreText}${voiceAnnouncement.matchPoint ? ' 賽末點' : ''}`)
}
if (voiceSettings.announceServer && voiceAnnouncement.serverName) {
@@ -285,6 +292,17 @@ export function ScoreboardPage({
voiceSettings.rate,
])
// 結算完成後(nextMatchSignal 遞增)自動打開選隊伍面板並清空已選,方便直接排下一場。
// 採 React 官方「render 期間依 prop 變化調整 state」模式,避免在 effect 內 setState。
const [handledNextMatchSignal, setHandledNextMatchSignal] = useState(nextMatchSignal)
if (nextMatchSignal !== handledNextMatchSignal) {
setHandledNextMatchSignal(nextMatchSignal)
setDraftPlayers([])
setDraftTargetScore(String(scoreState.targetScore))
setPickerOpen(true)
}
if (!selectedGroup) {
return (
<section className="page-grid">
+13
View File
@@ -1,5 +1,18 @@
export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'empty' | 'error'
export type ChangelogEntry = {
hash: string
shortHash: string
date: string
subject: string
body: string
}
export type ChangelogData = {
generatedAt: string
entries: ChangelogEntry[]
}
export type ScoreSide = 'left' | 'right'
export type PlayerSlot = 'playerA' | 'playerB'
+1
View File
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,