更新圖示與部署更新機制並整理 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

View File

@@ -16,8 +16,10 @@
- 需先指定先攻,之後點擊分數即可直接加分。
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
- `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。
- `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。
- 比分仍是 `0:0` 時,不會啟動比賽結算長按。
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
- 手機長按 `比賽結算` 不會再觸發文字選取。
- 連勝會出現特效提示:
- `3 連勝``大殺特殺`
- `4 連勝``暴走`
@@ -37,7 +39,9 @@
- PWA
- 可加入手機主畫面,像 App 一樣開啟。
- 支援 `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
```
每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。
## SSL 憑證目錄
Docker Compose 會直接掛載 NAS 上的憑證目錄:

View File

@@ -2,7 +2,7 @@
<html lang="zh-Hant">
<head>
<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="manifest" href="/manifest.webmanifest" />
<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
public/icon.png Normal 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

View File

@@ -3,7 +3,8 @@ const APP_SHELL = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.svg',
'/favicon.png',
'/icon.png',
'/apple-touch-icon.png',
'/pwa-192.png',
'/pwa-512.png',

View File

@@ -9,6 +9,8 @@ const app = express()
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
const matchTableName = process.env.DB_TABLE ?? 'badminton'
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 currentDir = path.dirname(currentFilePath)
@@ -37,6 +39,8 @@ app.use(express.json())
app.get('/api/health', (_request, response) => {
response.json({
appStartedAt,
appVersion,
ok: true,
dbReady: Boolean(pool),
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) => {
if (!pool) {
response.status(500).json({

View File

@@ -948,6 +948,10 @@
border-radius: 999px;
padding: 14px 14px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
font: inherit;
font-size: 1rem;
color: #4a2e1d;
@@ -964,6 +968,9 @@
.rail-pill-hold-wrap {
display: grid;
gap: 8px;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.rail-pill-hold-wrap-active {

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 './App.css'
import { loadMatchResults, saveMatchHistory } from './lib/api'
@@ -80,6 +80,7 @@ const STREAK_TITLES: Record<number, string> = {
8: '成為傳說',
}
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
const APP_VERSION_POLL_MS = 30000
function App() {
const location = useLocation()
@@ -117,6 +118,7 @@ function App() {
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const currentAppVersionRef = useRef<string | null>(null)
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
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) => {
setScoreState(nextState)
setScoreHistory([])

File diff suppressed because it is too large Load Diff