更新圖示與部署更新機制並整理 README

This commit is contained in:
2026-04-16 20:35:31 +08:00
parent 36a39f0b8f
commit c097ceb9ad
11 changed files with 1134 additions and 1019 deletions
+7 -1
View File
@@ -16,8 +16,10 @@
- 需先指定先攻,之後點擊分數即可直接加分。 - 需先指定先攻,之後點擊分數即可直接加分。
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步` - 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`
- 可交換上下隊伍位置,也可交換同隊左右球員位置。 - 可交換上下隊伍位置,也可交換同隊左右球員位置。
- `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。 - `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。
- 比分仍是 `0:0` 時,不會啟動比賽結算長按。
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。 - 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
- 手機長按 `比賽結算` 不會再觸發文字選取。
- 連勝會出現特效提示: - 連勝會出現特效提示:
- `3 連勝``大殺特殺` - `3 連勝``大殺特殺`
- `4 連勝``暴走` - `4 連勝``暴走`
@@ -37,7 +39,9 @@
- PWA - PWA
- 可加入手機主畫面,像 App 一樣開啟。 - 可加入手機主畫面,像 App 一樣開啟。
- 支援 `manifest``service worker`、主畫面 icon。 - 支援 `manifest``service worker`、主畫面 icon。
- 網頁 favicon 與 PWA icon 已改用 `ICON.png` 產生的 PNG 圖示。
- 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。 - 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。
- 前端會定期輪詢 `/api/version`,只要重新部署並重建 app container,就能偵測到新版本。
## 執行環境 ## 執行環境
@@ -104,6 +108,8 @@ sudo docker compose up -d --build
https://你的網域或 NAS IP:3501 https://你的網域或 NAS IP:3501
``` ```
每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。
## SSL 憑證目錄 ## SSL 憑證目錄
Docker Compose 會直接掛載 NAS 上的憑證目錄: Docker Compose 會直接掛載 NAS 上的憑證目錄:
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-Hant"> <html lang="zh-Hant">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 167 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 355 KiB

+2 -1
View File
@@ -3,7 +3,8 @@ const APP_SHELL = [
'/', '/',
'/index.html', '/index.html',
'/manifest.webmanifest', '/manifest.webmanifest',
'/favicon.svg', '/favicon.png',
'/icon.png',
'/apple-touch-icon.png', '/apple-touch-icon.png',
'/pwa-192.png', '/pwa-192.png',
'/pwa-512.png', '/pwa-512.png',
+19
View File
@@ -9,6 +9,8 @@ const app = express()
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788) const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
const matchTableName = process.env.DB_TABLE ?? 'badminton' const matchTableName = process.env.DB_TABLE ?? 'badminton'
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history' const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const currentFilePath = fileURLToPath(import.meta.url) const currentFilePath = fileURLToPath(import.meta.url)
const currentDir = path.dirname(currentFilePath) const currentDir = path.dirname(currentFilePath)
@@ -37,6 +39,8 @@ app.use(express.json())
app.get('/api/health', (_request, response) => { app.get('/api/health', (_request, response) => {
response.json({ response.json({
appStartedAt,
appVersion,
ok: true, ok: true,
dbReady: Boolean(pool), dbReady: Boolean(pool),
distReady, distReady,
@@ -46,6 +50,21 @@ app.get('/api/health', (_request, response) => {
}) })
}) })
app.get('/api/version', (_request, response) => {
response.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Expires: '0',
Pragma: 'no-cache',
'Surrogate-Control': 'no-store',
})
response.json({
ok: true,
startedAt: appStartedAt,
version: appVersion,
})
})
app.get('/api/match-results/:time', async (request, response) => { app.get('/api/match-results/:time', async (request, response) => {
if (!pool) { if (!pool) {
response.status(500).json({ response.status(500).json({
+7
View File
@@ -948,6 +948,10 @@
border-radius: 999px; border-radius: 999px;
padding: 14px 14px; padding: 14px 14px;
cursor: pointer; cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
font: inherit; font: inherit;
font-size: 1rem; font-size: 1rem;
color: #4a2e1d; color: #4a2e1d;
@@ -964,6 +968,9 @@
.rail-pill-hold-wrap { .rail-pill-hold-wrap {
display: grid; display: grid;
gap: 8px; gap: 8px;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
} }
.rail-pill-hold-wrap-active { .rail-pill-hold-wrap-active {
+54 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { NavLink, Route, Routes, useLocation } from 'react-router-dom' import { NavLink, Route, Routes, useLocation } from 'react-router-dom'
import './App.css' import './App.css'
import { loadMatchResults, saveMatchHistory } from './lib/api' import { loadMatchResults, saveMatchHistory } from './lib/api'
@@ -80,6 +80,7 @@ const STREAK_TITLES: Record<number, string> = {
8: '成為傳說', 8: '成為傳說',
} }
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
const APP_VERSION_POLL_MS = 30000
function App() { function App() {
const location = useLocation() const location = useLocation()
@@ -117,6 +118,7 @@ function App() {
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null) const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false) const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const currentAppVersionRef = useRef<string | null>(null)
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
@@ -188,6 +190,57 @@ function App() {
} }
}, []) }, [])
useEffect(() => {
let active = true
const checkAppVersion = async () => {
try {
const response = await fetch('/api/version', {
cache: 'no-store',
headers: {
'cache-control': 'no-cache',
},
})
if (!response.ok) {
return
}
const payload = (await response.json()) as {
ok?: boolean
version?: string
}
const nextVersion = payload.version?.trim()
if (!active || !nextVersion) {
return
}
if (!currentAppVersionRef.current) {
currentAppVersionRef.current = nextVersion
return
}
if (currentAppVersionRef.current !== nextVersion) {
currentAppVersionRef.current = nextVersion
setPwaUpdateReady(true)
}
} catch {
// Ignore transient version-check failures and retry on next poll.
}
}
void checkAppVersion()
const timer = window.setInterval(() => {
void checkAppVersion()
}, APP_VERSION_POLL_MS)
return () => {
active = false
window.clearInterval(timer)
}
}, [])
const resetScoring = (nextState: ScoreState = initialScoreState) => { const resetScoring = (nextState: ScoreState = initialScoreState) => {
setScoreState(nextState) setScoreState(nextState)
setScoreHistory([]) setScoreHistory([])
+40 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
getCourtAssignments, getCourtAssignments,
@@ -98,7 +98,7 @@ export function ScoreboardPage({
onSwapTeamPlayers, onSwapTeamPlayers,
onUndoLastPoint, onUndoLastPoint,
}: ScoreboardPageProps) { }: ScoreboardPageProps) {
const FINISH_HOLD_DURATION = 1500 const FINISH_HOLD_DURATION = 1000
const [pickerOpen, setPickerOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [draftPlayers, setDraftPlayers] = useState<string[]>([]) const [draftPlayers, setDraftPlayers] = useState<string[]>([])
@@ -112,6 +112,7 @@ export function ScoreboardPage({
loadVoiceSettings(), loadVoiceSettings(),
) )
const finishHoldFrameRef = useRef<number | null>(null) const finishHoldFrameRef = useRef<number | null>(null)
const finishHoldTimerRef = useRef<number | null>(null)
const finishHoldStartRef = useRef(0) const finishHoldStartRef = useRef(0)
const finishTriggeredRef = useRef(false) const finishTriggeredRef = useRef(false)
const lastAnnouncedPointRef = useRef(0) const lastAnnouncedPointRef = useRef(0)
@@ -138,6 +139,10 @@ export function ScoreboardPage({
window.cancelAnimationFrame(finishHoldFrameRef.current) window.cancelAnimationFrame(finishHoldFrameRef.current)
} }
if (finishHoldTimerRef.current !== null) {
window.clearTimeout(finishHoldTimerRef.current)
}
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
window.speechSynthesis.cancel() window.speechSynthesis.cancel()
} }
@@ -175,6 +180,7 @@ export function ScoreboardPage({
const canArrangeMatch = !hasRecordedPoint const canArrangeMatch = !hasRecordedPoint
const canScore = scoreState.serving !== null const canScore = scoreState.serving !== null
const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0
const servingScore = const servingScore =
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
@@ -317,6 +323,11 @@ export function ScoreboardPage({
finishHoldFrameRef.current = null finishHoldFrameRef.current = null
} }
if (finishHoldTimerRef.current !== null) {
window.clearTimeout(finishHoldTimerRef.current)
finishHoldTimerRef.current = null
}
finishHoldStartRef.current = 0 finishHoldStartRef.current = 0
finishTriggeredRef.current = false finishTriggeredRef.current = false
setFinishHoldActive(false) setFinishHoldActive(false)
@@ -324,7 +335,12 @@ export function ScoreboardPage({
} }
const startFinishHold = () => { const startFinishHold = () => {
if (finishDialogOpen || finishDialogUploading || finishHoldActive) { if (
!canFinishMatch ||
finishDialogOpen ||
finishDialogUploading ||
finishHoldActive
) {
return return
} }
@@ -333,17 +349,27 @@ export function ScoreboardPage({
setFinishHoldActive(true) setFinishHoldActive(true)
setFinishHoldProgress(0) setFinishHoldProgress(0)
finishHoldTimerRef.current = window.setTimeout(() => {
finishTriggeredRef.current = true
setFinishHoldActive(false)
setFinishHoldProgress(0)
finishHoldTimerRef.current = null
if (finishHoldFrameRef.current !== null) {
window.cancelAnimationFrame(finishHoldFrameRef.current)
finishHoldFrameRef.current = null
}
onOpenFinishDialog()
}, FINISH_HOLD_DURATION)
const tick = (now: number) => { const tick = (now: number) => {
const elapsed = now - finishHoldStartRef.current const elapsed = now - finishHoldStartRef.current
const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1) const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1)
setFinishHoldProgress(progress) setFinishHoldProgress(progress)
if (progress >= 1) { if (!finishHoldStartRef.current || finishTriggeredRef.current) {
finishTriggeredRef.current = true
setFinishHoldActive(false)
setFinishHoldProgress(0)
finishHoldFrameRef.current = null finishHoldFrameRef.current = null
onOpenFinishDialog()
return return
} }
@@ -475,8 +501,7 @@ export function ScoreboardPage({
<small> <small>
{scoreState.serving === null {scoreState.serving === null
? `本場 ${scoreState.targetScore} 分獲勝` ? `本場 ${scoreState.targetScore} 分獲勝`
: `發球:${currentServer?.name ?? '-'}${ : `發球:${currentServer?.name ?? '-'}${currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
} / 目標 ${scoreState.targetScore}`} } / 目標 ${scoreState.targetScore}`}
</small> </small>
</div> </div>
@@ -531,9 +556,13 @@ export function ScoreboardPage({
className={ className={
finishHoldActive finishHoldActive
? 'rail-pill rail-pill-danger rail-pill-active-hold' ? 'rail-pill rail-pill-danger rail-pill-active-hold'
: 'rail-pill rail-pill-danger' : canFinishMatch
? 'rail-pill rail-pill-danger'
: 'rail-pill rail-pill-muted'
} }
aria-disabled={!canFinishMatch}
type="button" type="button"
onClick={(event) => event.preventDefault()}
onPointerDown={startFinishHold} onPointerDown={startFinishHold}
onPointerUp={cancelFinishHold} onPointerUp={cancelFinishHold}
onPointerLeave={cancelFinishHold} onPointerLeave={cancelFinishHold}