From 40cb2f01d76c0ddaef4d17bf5244ee08216e190b Mon Sep 17 00:00:00 2001 From: JianMiau Date: Tue, 23 Jun 2026 12:15:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=B4=80=E9=8C=84=E5=88=86=E9=A0=81=E3=80=81?= =?UTF-8?q?=E5=8B=9D=E5=88=A9=E5=BE=8C=E9=8E=96=E8=A8=98=E5=88=86=E8=88=87?= =?UTF-8?q?=E7=B5=90=E7=AE=97=E5=BE=8C=E8=87=AA=E5=8B=95=E9=81=B8=E9=9A=8A?= =?UTF-8?q?=E3=80=81=E5=A0=B1=E5=88=86=E8=AA=9E=E9=9F=B3=E6=94=B9=E5=94=B8?= =?UTF-8?q?=E5=80=8B=E4=BD=8D=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: - 新增「更新紀錄」分頁,依 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 --- .gitignore | 3 + package.json | 3 + scripts/generate-changelog.mjs | 57 ++++++++++++++++ src/App.css | 81 ++++++++++++++++++++++ src/App.tsx | 39 +++++------ src/lib/match.ts | 20 ++++++ src/pages/ChangelogPage.tsx | 119 +++++++++++++++++++++++++++++++++ src/pages/ScoreboardPage.tsx | 30 +++++++-- src/types.ts | 37 ++++++---- tsconfig.app.json | 1 + 10 files changed, 353 insertions(+), 37 deletions(-) create mode 100644 scripts/generate-changelog.mjs create mode 100644 src/pages/ChangelogPage.tsx diff --git a/.gitignore b/.gitignore index fc130f5..838be55 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package.json b/package.json index 3fd464e..7922465 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-changelog.mjs b/scripts/generate-changelog.mjs new file mode 100644 index 0000000..638d5c5 --- /dev/null +++ b/scripts/generate-changelog.mjs @@ -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() diff --git a/src/App.css b/src/App.css index aed7c45..d652465 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 414569c..0ad38f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null) const [navigationLockMessage, setNavigationLockMessage] = useState('') + // 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。 + const [nextMatchSignal, setNextMatchSignal] = useState(0) const currentAppVersionRef = useRef(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() { > 房間列表 + (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} + onClick={handleNavAttempt('/changelog')} + to="/changelog" + > + 更新紀錄 + @@ -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() { } /> } /> + } /> } /> = 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 }) } diff --git a/src/lib/match.ts b/src/lib/match.ts index 5a66e4e..c06e890 100644 --- a/src/lib/match.ts +++ b/src/lib/match.ts @@ -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, diff --git a/src/pages/ChangelogPage.tsx b/src/pages/ChangelogPage.tsx new file mode 100644 index 0000000..63f67b3 --- /dev/null +++ b/src/pages/ChangelogPage.tsx @@ -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 ( +
+
+
+ {entry.date} + {entry.shortHash} +
+

{entry.subject}

+
+ + {parsed.notes.length > 0 ? ( +
+ {parsed.notes.map((note, index) => ( +

{note}

+ ))} +
+ ) : null} + + {parsed.sections.map((section, index) => ( +
+

{section.title}

+
    + {section.items.map((item, itemIndex) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+ ) +} + +export function ChangelogPage() { + const entries = data.entries + + return ( +
+
+

Changelog

+

更新紀錄

+

+ 這裡列出每次版本更新的內容,目前直接依專案 git commit 產生,由新到舊排列。 +

+
+ +
+ {entries.length === 0 ? ( +
+

目前沒有更新紀錄

+

尚未產生 changelog 資料,請執行 `npm run gen:changelog` 重新建立。

+
+ ) : ( +
+ {entries.map((entry) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 3edf716..73cb014 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -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 (
diff --git a/src/types.ts b/src/types.ts index 623f85c..31e4b89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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' @@ -35,18 +48,18 @@ export type ActiveMatchup = { rightTeam: GroupTeam | null } -export type ScoreState = { - scoreLeft: number - scoreRight: number - gamesLeft: number - gamesRight: number - currentGame: number - targetScore: number - initialServing: ScoreSide | null - serving: ScoreSide | null - leftRightCourtPlayer: PlayerSlot - rightRightCourtPlayer: PlayerSlot -} +export type ScoreState = { + scoreLeft: number + scoreRight: number + gamesLeft: number + gamesRight: number + currentGame: number + targetScore: number + initialServing: ScoreSide | null + serving: ScoreSide | null + leftRightCourtPlayer: PlayerSlot + rightRightCourtPlayer: PlayerSlot +} export type PointHistoryEntry = { round: number diff --git a/tsconfig.app.json b/tsconfig.app.json index 1d29c88..fbf749d 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -10,6 +10,7 @@ /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true,