功能:新增更新紀錄分頁、勝利後鎖記分與結算後自動選隊、報分語音改唸個位數
摘要: - 新增「更新紀錄」分頁,依 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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user