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 0000000..865ef0d Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..380d7fe --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,26 @@ +{ + "name": "羽球記分板", + "short_name": "羽球記分板", + "description": "羽毛球記分板,可在手機主畫面像 App 一樣開啟。", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4ecd8", + "theme_color": "#143f49", + "lang": "zh-Hant", + "orientation": "portrait", + "icons": [ + { + "src": "/pwa-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/pwa-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/pwa-192.png b/public/pwa-192.png new file mode 100644 index 0000000..c6f35d3 Binary files /dev/null and b/public/pwa-192.png differ diff --git a/public/pwa-512.png b/public/pwa-512.png new file mode 100644 index 0000000..f5eec54 Binary files /dev/null and b/public/pwa-512.png differ 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) {