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) {