更新圖示與部署更新機制並整理 README
@@ -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 上的憑證目錄:
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 167 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 1008 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 355 KiB |
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
55
src/App.tsx
@@ -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([])
|
||||
|
||||