功能:新增更新紀錄分頁、勝利後鎖記分與結算後自動選隊、報分語音改唸個位數
摘要: - 新增「更新紀錄」分頁,依 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.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# 由 git log 自動產生(predev / prebuild 會重建),不需追蹤避免時間戳記造成 diff 噪音
|
||||||
|
src/data/changelog.json
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"gen:changelog": "node scripts/generate-changelog.mjs",
|
||||||
|
"predev": "node scripts/generate-changelog.mjs",
|
||||||
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
|
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
|
||||||
"dev:client": "vite",
|
"dev:client": "vite",
|
||||||
"dev:server": "node server/server.mjs",
|
"dev:server": "node server/server.mjs",
|
||||||
|
"prebuild": "node scripts/generate-changelog.mjs",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"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;
|
display: grid;
|
||||||
gap: 18px;
|
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,
|
getServingPlayer,
|
||||||
getTeamDisplayName,
|
getTeamDisplayName,
|
||||||
getWinnerName,
|
getWinnerName,
|
||||||
|
hasWonGame,
|
||||||
parseRoster,
|
parseRoster,
|
||||||
swapCourtPositions,
|
swapCourtPositions,
|
||||||
} from './lib/match'
|
} from './lib/match'
|
||||||
|
import { ChangelogPage } from './pages/ChangelogPage'
|
||||||
import { HistoryPage } from './pages/HistoryPage'
|
import { HistoryPage } from './pages/HistoryPage'
|
||||||
import { RoomListPage } from './pages/RoomListPage'
|
import { RoomListPage } from './pages/RoomListPage'
|
||||||
import { RoomSpectatorPage } from './pages/RoomSpectatorPage'
|
import { RoomSpectatorPage } from './pages/RoomSpectatorPage'
|
||||||
@@ -143,6 +145,8 @@ function App() {
|
|||||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||||
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||||
|
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
|
||||||
|
const [nextMatchSignal, setNextMatchSignal] = useState(0)
|
||||||
const currentAppVersionRef = useRef<string | null>(null)
|
const currentAppVersionRef = useRef<string | null>(null)
|
||||||
const creatingRoomRef = useRef(false)
|
const creatingRoomRef = useRef(false)
|
||||||
const lastSyncedRoomSignatureRef = useRef('')
|
const lastSyncedRoomSignatureRef = useRef('')
|
||||||
@@ -730,6 +734,11 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已分出勝負就不再計分,需先結算才能開始下一場。
|
||||||
|
if (hasWonGame(scoreState)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const starter = getServerHistoryIndex(scoreState, leftTeam, rightTeam)
|
const starter = getServerHistoryIndex(scoreState, leftTeam, rightTeam)
|
||||||
|
|
||||||
if (starter === null) {
|
if (starter === null) {
|
||||||
@@ -865,6 +874,7 @@ function App() {
|
|||||||
uploading: false,
|
uploading: false,
|
||||||
})
|
})
|
||||||
resetScoring(initialScoreState, { releaseLiveRoom: false })
|
resetScoring(initialScoreState, { releaseLiveRoom: false })
|
||||||
|
setNextMatchSignal((current) => current + 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,6 +924,7 @@ function App() {
|
|||||||
uploading: false,
|
uploading: false,
|
||||||
})
|
})
|
||||||
resetScoring(initialScoreState, { releaseLiveRoom: false })
|
resetScoring(initialScoreState, { releaseLiveRoom: false })
|
||||||
|
setNextMatchSignal((current) => current + 1)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSettlement({
|
setSettlement({
|
||||||
error: error instanceof Error ? error.message : '上傳戰績失敗。',
|
error: error instanceof Error ? error.message : '上傳戰績失敗。',
|
||||||
@@ -975,6 +986,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
房間列表
|
房間列表
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||||
|
onClick={handleNavAttempt('/changelog')}
|
||||||
|
to="/changelog"
|
||||||
|
>
|
||||||
|
更新紀錄
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -1031,6 +1049,7 @@ function App() {
|
|||||||
hasRecordedPoint={pointLog.length > 0}
|
hasRecordedPoint={pointLog.length > 0}
|
||||||
leftTeam={leftTeam}
|
leftTeam={leftTeam}
|
||||||
liveRoomId={liveRoomId}
|
liveRoomId={liveRoomId}
|
||||||
|
nextMatchSignal={nextMatchSignal}
|
||||||
rightTeam={rightTeam}
|
rightTeam={rightTeam}
|
||||||
scoreState={scoreState}
|
scoreState={scoreState}
|
||||||
selectedGroup={selectedGroup}
|
selectedGroup={selectedGroup}
|
||||||
@@ -1052,6 +1071,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
|
<Route path="/changelog" element={<ChangelogPage />} />
|
||||||
<Route path="/rooms" element={<RoomListPage />} />
|
<Route path="/rooms" element={<RoomListPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/rooms/:roomId"
|
path="/rooms/:roomId"
|
||||||
@@ -1159,25 +1179,6 @@ function getNextServerName(
|
|||||||
return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? ''
|
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) {
|
function formatPlayedAt(timestamp: number) {
|
||||||
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })
|
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'
|
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) {
|
function createTeam(id: number, playerA: string, playerB: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
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,
|
getServiceCourt,
|
||||||
getServingPlayer,
|
getServingPlayer,
|
||||||
getTeamDisplayName,
|
getTeamDisplayName,
|
||||||
|
hasWonGame,
|
||||||
} from '../lib/match'
|
} from '../lib/match'
|
||||||
import type {
|
import type {
|
||||||
CourtSide,
|
CourtSide,
|
||||||
@@ -42,6 +43,7 @@ type ScoreboardPageProps = {
|
|||||||
hasRecordedPoint: boolean
|
hasRecordedPoint: boolean
|
||||||
leftTeam: GroupTeam | null
|
leftTeam: GroupTeam | null
|
||||||
liveRoomId: string | null
|
liveRoomId: string | null
|
||||||
|
nextMatchSignal: number
|
||||||
rightTeam: GroupTeam | null
|
rightTeam: GroupTeam | null
|
||||||
scoreState: ScoreState
|
scoreState: ScoreState
|
||||||
selectedGroup: RoundGroup | null
|
selectedGroup: RoundGroup | null
|
||||||
@@ -92,6 +94,7 @@ export function ScoreboardPage({
|
|||||||
hasRecordedPoint,
|
hasRecordedPoint,
|
||||||
leftTeam,
|
leftTeam,
|
||||||
liveRoomId,
|
liveRoomId,
|
||||||
|
nextMatchSignal,
|
||||||
rightTeam,
|
rightTeam,
|
||||||
scoreState,
|
scoreState,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
@@ -188,8 +191,9 @@ export function ScoreboardPage({
|
|||||||
[selectedGroup],
|
[selectedGroup],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const matchWon = hasWonGame(scoreState)
|
||||||
const canArrangeMatch = !hasRecordedPoint
|
const canArrangeMatch = !hasRecordedPoint
|
||||||
const canScore = scoreState.serving !== null
|
const canScore = scoreState.serving !== null && !matchWon
|
||||||
const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0
|
const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0
|
||||||
|
|
||||||
const servingScore =
|
const servingScore =
|
||||||
@@ -260,11 +264,14 @@ export function ScoreboardPage({
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (voiceSettings.announceScore) {
|
if (voiceSettings.announceScore) {
|
||||||
parts.push(
|
// 報分只唸個位數:10→0、19→9、20→0;同分改唸「N 平」。
|
||||||
`${voiceAnnouncement.servingScore}比${voiceAnnouncement.opponentScore}${
|
const spokenServing = voiceAnnouncement.servingScore % 10
|
||||||
voiceAnnouncement.matchPoint ? ' 賽末點' : ''
|
const spokenOpponent = voiceAnnouncement.opponentScore % 10
|
||||||
}`,
|
const scoreText =
|
||||||
)
|
voiceAnnouncement.servingScore === voiceAnnouncement.opponentScore
|
||||||
|
? `${spokenServing}平`
|
||||||
|
: `${spokenServing}比${spokenOpponent}`
|
||||||
|
parts.push(`${scoreText}${voiceAnnouncement.matchPoint ? ' 賽末點' : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (voiceSettings.announceServer && voiceAnnouncement.serverName) {
|
if (voiceSettings.announceServer && voiceAnnouncement.serverName) {
|
||||||
@@ -285,6 +292,17 @@ export function ScoreboardPage({
|
|||||||
voiceSettings.rate,
|
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) {
|
if (!selectedGroup) {
|
||||||
return (
|
return (
|
||||||
<section className="page-grid">
|
<section className="page-grid">
|
||||||
|
|||||||
+25
-12
@@ -1,5 +1,18 @@
|
|||||||
export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'empty' | 'error'
|
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 ScoreSide = 'left' | 'right'
|
||||||
|
|
||||||
export type PlayerSlot = 'playerA' | 'playerB'
|
export type PlayerSlot = 'playerA' | 'playerB'
|
||||||
@@ -35,18 +48,18 @@ export type ActiveMatchup = {
|
|||||||
rightTeam: GroupTeam | null
|
rightTeam: GroupTeam | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScoreState = {
|
export type ScoreState = {
|
||||||
scoreLeft: number
|
scoreLeft: number
|
||||||
scoreRight: number
|
scoreRight: number
|
||||||
gamesLeft: number
|
gamesLeft: number
|
||||||
gamesRight: number
|
gamesRight: number
|
||||||
currentGame: number
|
currentGame: number
|
||||||
targetScore: number
|
targetScore: number
|
||||||
initialServing: ScoreSide | null
|
initialServing: ScoreSide | null
|
||||||
serving: ScoreSide | null
|
serving: ScoreSide | null
|
||||||
leftRightCourtPlayer: PlayerSlot
|
leftRightCourtPlayer: PlayerSlot
|
||||||
rightRightCourtPlayer: PlayerSlot
|
rightRightCourtPlayer: PlayerSlot
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PointHistoryEntry = {
|
export type PointHistoryEntry = {
|
||||||
round: number
|
round: number
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user