From 975732017f7758c2128252421cf53e35f818fe35 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Thu, 16 Apr 2026 19:57:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20PWA=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E4=B8=A6=E6=95=B4=E7=90=86=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 120 +++++++++++++++++------------------ index.html | 13 +++- public/apple-touch-icon.png | Bin 0 -> 3670 bytes public/manifest.webmanifest | 26 ++++++++ public/pwa-192.png | Bin 0 -> 4337 bytes public/pwa-512.png | Bin 0 -> 12260 bytes public/sw.js | 87 +++++++++++++++++++++++++ src/App.css | 74 +++++++++++++++++++++ src/App.tsx | 41 ++++++++++++ src/main.tsx | 44 +++++++++++++ src/pages/ScoreboardPage.tsx | 11 +++- 11 files changed, 349 insertions(+), 67 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/manifest.webmanifest create mode 100644 public/pwa-192.png create mode 100644 public/pwa-512.png create mode 100644 public/sw.js diff --git a/README.md b/README.md index a5220ce..b1a7b7d 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,48 @@ -# 羽毛球記分板 +# 羽球記分板 -以 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,提供選隊伍、記分板、歷史戰績三個主要頁面,並可搭配 Docker 部署到 NAS。 +使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援選隊伍、計分板、歷史戰績、語音播報、PWA 安裝與 Docker / NAS 部署。 ## 功能 -- 選隊伍頁 - - 可依指定日期從資料庫讀取分組資料 - - 若當天沒有資料,可手動輸入 A、B 區名單建立分組 - - 成功載入後會在畫面底部顯示 1 秒浮動提示 - - 對戰名單直接點 `進入記分板` 就會帶入該組 - - 分組卡片標題改成左右緊湊排列,減少手機版高度 -- 記分板頁 - - 從所選組別進入記分板 - - 設定隊伍彈窗支援兩種方式 - - 左側逐一選人,依順序 `1、2` 為上方隊伍,`3、4` 為下方隊伍 - - 右側快速套用預設隊伍 - - 可設定幾分獲勝,預設 `21` - - 必須先設定先攻,之後點分數即可直接加分 - - 尚未設定先攻時,`先攻` 文字會做動畫提醒 - - 選定先攻後,該方的先攻方框會直接顯示打勾 - - 第一分後 `設定隊伍` 會改成 `上一步` - - 支援上下交換兩隊位置、左右交換隊內站位 - - 三連勝以上會顯示連勝稱號動畫 - - `3` 連勝:`大殺特殺` - - `4` 連勝:`暴走` - - `5` 連勝:`無人能擋` - - `6` 連勝:`主宰比賽` - - `7` 連勝:`像神一般的` - - `8` 連勝:`成為傳說` - - 達到目標分數獲勝時,會跳出獲勝動畫特效 - - 內建免費瀏覽器 TTS 播報 - - 右側 `設定` 按鈕可開啟語音設定面板 - - 可分別設定是否播報誰得分、是否播報誰發球 - - 可調整語速,範圍 `0.7x ~ 10x` -- 歷史戰績頁 - - 直接從資料庫 `history` 表讀取列表 - - 點擊任一筆可開啟得分紀錄彈窗 - - 彈窗右上角提供 `X` 關閉按鈕,手機更容易操作 - - 每筆資料可單獨刪除 - - 刪除前只會提示一次確認視窗 +- 選隊伍 + - 可依指定日期從資料庫讀取分組資料。 + - 若當天沒有資料,可手動輸入 A、B 區名單產生分組。 + - 每組可直接進入記分板,不需額外再點選這組。 +- 計分板 + - 設定隊伍彈窗支援逐一選人。 + - 依選取順序自動成隊:`1、2` 一隊,`3、4` 一隊。 + - 右側可快速選擇預設隊伍。 + - 可設定本場幾分獲勝,預設 `21` 分。 + - 需先指定先攻,之後點擊分數即可直接加分。 + - 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。 + - 可交換上下隊伍位置,也可交換同隊左右球員位置。 + - 連勝會出現特效提示: + - `3 連勝`:`大殺特殺` + - `4 連勝`:`暴走` + - `5 連勝`:`無人能擋` + - `6 連勝`:`主宰比賽` + - `7 連勝`:`像神一般的` + - `8 連勝`:`成為傳說` + - 達到目標分數時會顯示獲勝動畫。 + - 內建免費瀏覽器 TTS。 + - 可設定是否播報得分者、是否播報發球者、以及語速。 + - `RURU` 已支援不分大小寫的發音別名,會念成 `嚕嚕`。 +- 歷史戰績 + - 直接從資料庫 `history` 表讀取列表。 + - 點擊單筆戰績可開啟得分紀錄彈窗。 + - 彈窗支援右上角 `X` 關閉按鈕。 + - 每筆資料可直接刪除,刪除前會跳一次確認提示。 +- PWA + - 可加入手機主畫面,像 App 一樣開啟。 + - 支援 `manifest`、`service worker`、主畫面 icon。 + - 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。 -## 本機開發 +## 執行環境 ### Port -- Client: `3501` -- Server: `8788` +- Client:`3501` +- Server:`8788` ### 安裝 @@ -53,18 +50,18 @@ npm install ``` -### 啟動開發模式 +### 開發模式 ```bash npm run dev ``` -啟動後可從以下位置開啟: +啟動後會同時開兩個服務: - 前端:`http://localhost:3501` - API:`http://localhost:8788` -### 驗證 +### 檢查 ```bash npm run lint @@ -73,7 +70,7 @@ npm run build ## 環境變數 -請在專案根目錄建立 `.env`,至少包含以下欄位: +請在專案根目錄建立 `.env`: ```env DB_HOST=127.0.0.1 @@ -81,26 +78,25 @@ DB_PORT=3306 DB_USER=root DB_PASSWORD=your_password DB_DATABASE=badminton -DB_TABLE=match_results +DB_TABLE=badminton DB_HISTORY_TABLE=history PORT=8788 ``` ## Docker / NAS 部署 -目前部署設計如下: +正式部署時目前是雙容器架構: -- 對外入口:`3501` - App 內部服務:`8788` -- 由 Nginx 負責 SSL 與反向代理 +- Nginx SSL 對外入口:`3501` -### 啟動 +啟動指令: ```bash sudo docker compose up -d --build ``` -正式部署後入口會是: +部署完成後,對外入口: ```text https://你的網域或 NAS IP:3501 @@ -108,23 +104,23 @@ https://你的網域或 NAS IP:3501 ## SSL 憑證目錄 -Docker Compose 會掛載 NAS 的憑證目錄: +Docker Compose 會直接掛載 NAS 上的憑證目錄: ```text /volume1/homes/JianMiau/www/certificate/ ``` -目前 Nginx 會使用這個目錄下的檔案產生 `fullchain.pem`,因此之後若你更新 SSL,只要更新這個資料夾內的憑證即可,再重新啟動容器讓設定重載。 - -預設使用的檔名為: +目前預設使用這三個檔名: - `RSA-cert.pem` - `RSA-chain.pem` - `RSA-privkey.pem` -## 歷史戰績寫入格式 +更新憑證時,只要更新上述目錄內的檔案,再重新啟動容器即可。 -`history` 表使用的資料欄位如下: +## history 資料表格式 + +`history` 表目前使用以下欄位: - `id` - `time` @@ -135,21 +131,19 @@ Docker Compose 會掛載 NAS 的憑證目錄: - `0`:雙打 - `1`:單打 - `players` - - 依照 `1 ~ 4` 編號順序儲存 + - 依 `1 ~ 4` 順序排序的玩家名稱 - `team` - - `1、2` 為一隊 - - `3、4` 為一隊 + - `1、2` 一隊 + - `3、4` 一隊 - `scoreList` - 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]` -## Git 中文紀錄 +## Git 中文顯示 -專案已設定 git 使用 UTF-8: +若要讓 commit 與 log 正常顯示中文,可設定: ```bash git config i18n.commitEncoding utf-8 git config i18n.logOutputEncoding utf-8 git config core.quotepath false ``` - -之後 commit 訊息與 log 可直接使用中文。 diff --git a/index.html b/index.html index a189bfb..328cda9 100644 --- a/index.html +++ b/index.html @@ -3,9 +3,18 @@ + + - - 羽毛球記分板 + + + + + + 羽球記分板
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..865ef0d3056f25e5fc41e69ff13e138a9a7ffe82 GIT binary patch literal 3670 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 SHy(i7fb(Z<&y=5r-}ygZ>8*GG literal 0 HcmV?d00001 diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..2703855 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,87 @@ +const CACHE_NAME = 'badminton-scoreboard-v1' +const APP_SHELL = [ + '/', + '/index.html', + '/manifest.webmanifest', + '/favicon.svg', + '/apple-touch-icon.png', + '/pwa-192.png', + '/pwa-512.png', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)), + ) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) { + return caches.delete(key) + } + + return Promise.resolve(false) + }), + ), + ), + ) + self.clients.claim() +}) + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting() + } +}) + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return + } + + const requestUrl = new URL(event.request.url) + + if (requestUrl.origin !== self.location.origin) { + return + } + + event.respondWith( + caches.match(event.request).then(async (cachedResponse) => { + if (cachedResponse) { + return cachedResponse + } + + try { + const networkResponse = await fetch(event.request) + + if ( + networkResponse.ok && + (event.request.destination === 'document' || + event.request.destination === 'script' || + event.request.destination === 'style' || + event.request.destination === 'image' || + requestUrl.pathname.startsWith('/assets/')) + ) { + const cache = await caches.open(CACHE_NAME) + cache.put(event.request, networkResponse.clone()) + } + + return networkResponse + } catch (error) { + if (event.request.mode === 'navigate') { + const fallback = await caches.match('/index.html') + if (fallback) { + return fallback + } + } + + throw error + } + }), + ) +}) diff --git a/src/App.css b/src/App.css index 63e7ed3..7d38d5e 100644 --- a/src/App.css +++ b/src/App.css @@ -75,6 +75,59 @@ background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); } +.pwa-update-toast { + position: fixed; + left: 50%; + bottom: 18px; + z-index: 1200; + display: flex; + align-items: center; + gap: 14px; + width: min(560px, calc(100vw - 24px)); + padding: 14px 16px; + border: 1px solid rgba(10, 51, 45, 0.16); + border-radius: 20px; + background: rgba(255, 249, 236, 0.96); + box-shadow: + 0 22px 46px rgba(10, 51, 45, 0.22), + 0 8px 18px rgba(10, 51, 45, 0.12); + transform: translateX(-50%); + backdrop-filter: blur(14px); +} + +.pwa-update-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.pwa-update-copy strong { + font-size: 1rem; + color: var(--panel-strong); +} + +.pwa-update-copy span { + font-size: 0.88rem; + color: var(--panel-soft); +} + +.pwa-update-button { + flex: 0 0 auto; + min-width: 96px; + padding: 10px 14px; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, #0d5d53, #123f49); + color: #f8fff8; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.pwa-update-button:hover { + filter: brightness(1.06); +} + .page-grid { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); @@ -1644,6 +1697,27 @@ } @media (max-width: 720px) { + .pwa-update-toast { + gap: 10px; + bottom: 12px; + padding: 12px 12px 12px 14px; + border-radius: 16px; + } + + .pwa-update-copy strong { + font-size: 0.92rem; + } + + .pwa-update-copy span { + font-size: 0.78rem; + } + + .pwa-update-button { + min-width: 84px; + padding: 9px 12px; + font-size: 0.84rem; + } + .app-shell { width: min(100% - 14px, 1240px); padding: 14px 0 24px; diff --git a/src/App.tsx b/src/App.tsx index 0da5e30..f2fa12d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,6 +79,7 @@ const STREAK_TITLES: Record = { 7: '像神一般的', 8: '成為傳說', } +const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' function App() { const location = useLocation() @@ -115,6 +116,7 @@ function App() { }) const [streakAnnouncement, setStreakAnnouncement] = useState(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) + const [pwaUpdateReady, setPwaUpdateReady] = useState(false) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) @@ -174,6 +176,18 @@ function App() { return () => window.clearTimeout(timer) }, [victoryAnnouncement]) + useEffect(() => { + const handlePwaUpdateReady = () => { + setPwaUpdateReady(true) + } + + window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) + + return () => { + window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) + } + }, []) + const resetScoring = (nextState: ScoreState = initialScoreState) => { setScoreState(nextState) setScoreHistory([]) @@ -215,6 +229,21 @@ function App() { }) } + const refreshForPwaUpdate = () => { + const registrationPromise = navigator.serviceWorker?.getRegistration + ? navigator.serviceWorker.getRegistration() + : Promise.resolve(undefined) + + void registrationPromise.then((registration) => { + if (registration?.waiting) { + registration.waiting.postMessage({ type: 'SKIP_WAITING' }) + return + } + + window.location.reload() + }) + } + const loadGroupsFromDb = async () => { if (!targetDate) { setLoadStatus('error') @@ -601,6 +630,18 @@ function App() { /> } /> + + {pwaUpdateReady ? ( +
+
+ 有新版本可更新 + 點重新整理後套用最新版本。 +
+ +
+ ) : null} ) } diff --git a/src/main.tsx b/src/main.tsx index ade9d64..9311842 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,8 @@ import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' +const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' + createRoot(document.getElementById('root')!).render( @@ -11,3 +13,45 @@ createRoot(document.getElementById('root')!).render( , ) + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + let refreshing = false + + const notifyUpdateReady = () => { + window.dispatchEvent(new CustomEvent(PWA_UPDATE_EVENT)) + } + + const trackWorker = (worker: ServiceWorker | null) => { + if (!worker) { + return + } + + worker.addEventListener('statechange', () => { + if (worker.state === 'installed' && navigator.serviceWorker.controller) { + notifyUpdateReady() + } + }) + } + + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (refreshing) { + return + } + + refreshing = true + window.location.reload() + }) + + void navigator.serviceWorker.register('/sw.js').then((registration) => { + if (registration.waiting) { + notifyUpdateReady() + } + + trackWorker(registration.installing) + registration.addEventListener('updatefound', () => { + trackWorker(registration.installing) + }) + }) + }) +} diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 0d02cb4..ae8321a 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -29,6 +29,9 @@ const defaultVoiceSettings: VoiceSettings = { announceServer: true, rate: 1, } +const SPEECH_NAME_MAP: Record = { + ruru: '嚕嚕', +} type ScoreboardPageProps = { currentSelectionOrder: string[] @@ -252,7 +255,7 @@ export function ScoreboardPage({ } if (voiceSettings.announceServer) { - parts.push(`${currentServer.name}發球`) + parts.push(`${getSpeechName(currentServer.name)}發球`) } if (parts.length > 0) { @@ -1061,7 +1064,11 @@ function loadVoiceSettings(): VoiceSettings { } function getAnnouncementName(team: GroupTeam | null) { - return team?.playerA ?? '本隊' + return getSpeechName(team?.playerA ?? '本隊') +} + +function getSpeechName(name: string) { + return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name } function speakAnnouncement(message: string, rate: number) {