新增 PWA 更新提示並整理 README
This commit is contained in:
@@ -1,51 +1,48 @@
|
|||||||
# 羽毛球記分板
|
# 羽球記分板
|
||||||
|
|
||||||
以 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,提供選隊伍、記分板、歷史戰績三個主要頁面,並可搭配 Docker 部署到 NAS。
|
使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援選隊伍、計分板、歷史戰績、語音播報、PWA 安裝與 Docker / NAS 部署。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 選隊伍頁
|
- 選隊伍
|
||||||
- 可依指定日期從資料庫讀取分組資料
|
- 可依指定日期從資料庫讀取分組資料。
|
||||||
- 若當天沒有資料,可手動輸入 A、B 區名單建立分組
|
- 若當天沒有資料,可手動輸入 A、B 區名單產生分組。
|
||||||
- 成功載入後會在畫面底部顯示 1 秒浮動提示
|
- 每組可直接進入記分板,不需額外再點選這組。
|
||||||
- 對戰名單直接點 `進入記分板` 就會帶入該組
|
- 計分板
|
||||||
- 分組卡片標題改成左右緊湊排列,減少手機版高度
|
- 設定隊伍彈窗支援逐一選人。
|
||||||
- 記分板頁
|
- 依選取順序自動成隊:`1、2` 一隊,`3、4` 一隊。
|
||||||
- 從所選組別進入記分板
|
- 右側可快速選擇預設隊伍。
|
||||||
- 設定隊伍彈窗支援兩種方式
|
- 可設定本場幾分獲勝,預設 `21` 分。
|
||||||
- 左側逐一選人,依順序 `1、2` 為上方隊伍,`3、4` 為下方隊伍
|
- 需先指定先攻,之後點擊分數即可直接加分。
|
||||||
- 右側快速套用預設隊伍
|
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
|
||||||
- 可設定幾分獲勝,預設 `21`
|
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
|
||||||
- 必須先設定先攻,之後點分數即可直接加分
|
- 連勝會出現特效提示:
|
||||||
- 尚未設定先攻時,`先攻` 文字會做動畫提醒
|
- `3 連勝`:`大殺特殺`
|
||||||
- 選定先攻後,該方的先攻方框會直接顯示打勾
|
- `4 連勝`:`暴走`
|
||||||
- 第一分後 `設定隊伍` 會改成 `上一步`
|
- `5 連勝`:`無人能擋`
|
||||||
- 支援上下交換兩隊位置、左右交換隊內站位
|
- `6 連勝`:`主宰比賽`
|
||||||
- 三連勝以上會顯示連勝稱號動畫
|
- `7 連勝`:`像神一般的`
|
||||||
- `3` 連勝:`大殺特殺`
|
- `8 連勝`:`成為傳說`
|
||||||
- `4` 連勝:`暴走`
|
- 達到目標分數時會顯示獲勝動畫。
|
||||||
- `5` 連勝:`無人能擋`
|
- 內建免費瀏覽器 TTS。
|
||||||
- `6` 連勝:`主宰比賽`
|
- 可設定是否播報得分者、是否播報發球者、以及語速。
|
||||||
- `7` 連勝:`像神一般的`
|
- `RURU` 已支援不分大小寫的發音別名,會念成 `嚕嚕`。
|
||||||
- `8` 連勝:`成為傳說`
|
- 歷史戰績
|
||||||
- 達到目標分數獲勝時,會跳出獲勝動畫特效
|
- 直接從資料庫 `history` 表讀取列表。
|
||||||
- 內建免費瀏覽器 TTS 播報
|
- 點擊單筆戰績可開啟得分紀錄彈窗。
|
||||||
- 右側 `設定` 按鈕可開啟語音設定面板
|
- 彈窗支援右上角 `X` 關閉按鈕。
|
||||||
- 可分別設定是否播報誰得分、是否播報誰發球
|
- 每筆資料可直接刪除,刪除前會跳一次確認提示。
|
||||||
- 可調整語速,範圍 `0.7x ~ 10x`
|
- PWA
|
||||||
- 歷史戰績頁
|
- 可加入手機主畫面,像 App 一樣開啟。
|
||||||
- 直接從資料庫 `history` 表讀取列表
|
- 支援 `manifest`、`service worker`、主畫面 icon。
|
||||||
- 點擊任一筆可開啟得分紀錄彈窗
|
- 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。
|
||||||
- 彈窗右上角提供 `X` 關閉按鈕,手機更容易操作
|
|
||||||
- 每筆資料可單獨刪除
|
|
||||||
- 刪除前只會提示一次確認視窗
|
|
||||||
|
|
||||||
## 本機開發
|
## 執行環境
|
||||||
|
|
||||||
### Port
|
### Port
|
||||||
|
|
||||||
- Client: `3501`
|
- Client:`3501`
|
||||||
- Server: `8788`
|
- Server:`8788`
|
||||||
|
|
||||||
### 安裝
|
### 安裝
|
||||||
|
|
||||||
@@ -53,18 +50,18 @@
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 啟動開發模式
|
### 開發模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
啟動後可從以下位置開啟:
|
啟動後會同時開兩個服務:
|
||||||
|
|
||||||
- 前端:`http://localhost:3501`
|
- 前端:`http://localhost:3501`
|
||||||
- API:`http://localhost:8788`
|
- API:`http://localhost:8788`
|
||||||
|
|
||||||
### 驗證
|
### 檢查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
@@ -73,7 +70,7 @@ npm run build
|
|||||||
|
|
||||||
## 環境變數
|
## 環境變數
|
||||||
|
|
||||||
請在專案根目錄建立 `.env`,至少包含以下欄位:
|
請在專案根目錄建立 `.env`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
@@ -81,26 +78,25 @@ DB_PORT=3306
|
|||||||
DB_USER=root
|
DB_USER=root
|
||||||
DB_PASSWORD=your_password
|
DB_PASSWORD=your_password
|
||||||
DB_DATABASE=badminton
|
DB_DATABASE=badminton
|
||||||
DB_TABLE=match_results
|
DB_TABLE=badminton
|
||||||
DB_HISTORY_TABLE=history
|
DB_HISTORY_TABLE=history
|
||||||
PORT=8788
|
PORT=8788
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker / NAS 部署
|
## Docker / NAS 部署
|
||||||
|
|
||||||
目前部署設計如下:
|
正式部署時目前是雙容器架構:
|
||||||
|
|
||||||
- 對外入口:`3501`
|
|
||||||
- App 內部服務:`8788`
|
- App 內部服務:`8788`
|
||||||
- 由 Nginx 負責 SSL 與反向代理
|
- Nginx SSL 對外入口:`3501`
|
||||||
|
|
||||||
### 啟動
|
啟動指令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker compose up -d --build
|
sudo docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
正式部署後入口會是:
|
部署完成後,對外入口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://你的網域或 NAS IP:3501
|
https://你的網域或 NAS IP:3501
|
||||||
@@ -108,23 +104,23 @@ https://你的網域或 NAS IP:3501
|
|||||||
|
|
||||||
## SSL 憑證目錄
|
## SSL 憑證目錄
|
||||||
|
|
||||||
Docker Compose 會掛載 NAS 的憑證目錄:
|
Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/volume1/homes/JianMiau/www/certificate/
|
/volume1/homes/JianMiau/www/certificate/
|
||||||
```
|
```
|
||||||
|
|
||||||
目前 Nginx 會使用這個目錄下的檔案產生 `fullchain.pem`,因此之後若你更新 SSL,只要更新這個資料夾內的憑證即可,再重新啟動容器讓設定重載。
|
目前預設使用這三個檔名:
|
||||||
|
|
||||||
預設使用的檔名為:
|
|
||||||
|
|
||||||
- `RSA-cert.pem`
|
- `RSA-cert.pem`
|
||||||
- `RSA-chain.pem`
|
- `RSA-chain.pem`
|
||||||
- `RSA-privkey.pem`
|
- `RSA-privkey.pem`
|
||||||
|
|
||||||
## 歷史戰績寫入格式
|
更新憑證時,只要更新上述目錄內的檔案,再重新啟動容器即可。
|
||||||
|
|
||||||
`history` 表使用的資料欄位如下:
|
## history 資料表格式
|
||||||
|
|
||||||
|
`history` 表目前使用以下欄位:
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `time`
|
- `time`
|
||||||
@@ -135,21 +131,19 @@ Docker Compose 會掛載 NAS 的憑證目錄:
|
|||||||
- `0`:雙打
|
- `0`:雙打
|
||||||
- `1`:單打
|
- `1`:單打
|
||||||
- `players`
|
- `players`
|
||||||
- 依照 `1 ~ 4` 編號順序儲存
|
- 依 `1 ~ 4` 順序排序的玩家名稱
|
||||||
- `team`
|
- `team`
|
||||||
- `1、2` 為一隊
|
- `1、2` 一隊
|
||||||
- `3、4` 為一隊
|
- `3、4` 一隊
|
||||||
- `scoreList`
|
- `scoreList`
|
||||||
- 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]`
|
- 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]`
|
||||||
|
|
||||||
## Git 中文紀錄
|
## Git 中文顯示
|
||||||
|
|
||||||
專案已設定 git 使用 UTF-8:
|
若要讓 commit 與 log 正常顯示中文,可設定:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git config i18n.commitEncoding utf-8
|
git config i18n.commitEncoding utf-8
|
||||||
git config i18n.logOutputEncoding utf-8
|
git config i18n.logOutputEncoding utf-8
|
||||||
git config core.quotepath false
|
git config core.quotepath false
|
||||||
```
|
```
|
||||||
|
|
||||||
之後 commit 訊息與 log 可直接使用中文。
|
|
||||||
|
|||||||
+11
-2
@@ -3,9 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="羽毛球記分板前端專案,使用 Vite、React 與 TypeScript 建立。" />
|
<meta name="theme-color" content="#143f49" />
|
||||||
<title>羽毛球記分板</title>
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="羽毛球記分板,可在手機上加入主畫面並像 App 一樣開啟。"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="羽球記分板" />
|
||||||
|
<title>羽球記分板</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
+74
@@ -75,6 +75,59 @@
|
|||||||
background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92));
|
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 {
|
.page-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
@@ -1644,6 +1697,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@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 {
|
.app-shell {
|
||||||
width: min(100% - 14px, 1240px);
|
width: min(100% - 14px, 1240px);
|
||||||
padding: 14px 0 24px;
|
padding: 14px 0 24px;
|
||||||
|
|||||||
+41
@@ -79,6 +79,7 @@ const STREAK_TITLES: Record<number, string> = {
|
|||||||
7: '像神一般的',
|
7: '像神一般的',
|
||||||
8: '成為傳說',
|
8: '成為傳說',
|
||||||
}
|
}
|
||||||
|
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -115,6 +116,7 @@ function App() {
|
|||||||
})
|
})
|
||||||
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
||||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||||
|
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||||
|
|
||||||
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||||
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
||||||
@@ -174,6 +176,18 @@ function App() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [victoryAnnouncement])
|
}, [victoryAnnouncement])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePwaUpdateReady = () => {
|
||||||
|
setPwaUpdateReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
||||||
setScoreState(nextState)
|
setScoreState(nextState)
|
||||||
setScoreHistory([])
|
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 () => {
|
const loadGroupsFromDb = async () => {
|
||||||
if (!targetDate) {
|
if (!targetDate) {
|
||||||
setLoadStatus('error')
|
setLoadStatus('error')
|
||||||
@@ -601,6 +630,18 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
|
{pwaUpdateReady ? (
|
||||||
|
<div className="pwa-update-toast" role="status" aria-live="polite">
|
||||||
|
<div className="pwa-update-copy">
|
||||||
|
<strong>有新版本可更新</strong>
|
||||||
|
<span>點重新整理後套用最新版本。</span>
|
||||||
|
</div>
|
||||||
|
<button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button">
|
||||||
|
重新整理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -11,3 +13,45 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const defaultVoiceSettings: VoiceSettings = {
|
|||||||
announceServer: true,
|
announceServer: true,
|
||||||
rate: 1,
|
rate: 1,
|
||||||
}
|
}
|
||||||
|
const SPEECH_NAME_MAP: Record<string, string> = {
|
||||||
|
ruru: '嚕嚕',
|
||||||
|
}
|
||||||
|
|
||||||
type ScoreboardPageProps = {
|
type ScoreboardPageProps = {
|
||||||
currentSelectionOrder: string[]
|
currentSelectionOrder: string[]
|
||||||
@@ -252,7 +255,7 @@ export function ScoreboardPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (voiceSettings.announceServer) {
|
if (voiceSettings.announceServer) {
|
||||||
parts.push(`${currentServer.name}發球`)
|
parts.push(`${getSpeechName(currentServer.name)}發球`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
@@ -1061,7 +1064,11 @@ function loadVoiceSettings(): VoiceSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAnnouncementName(team: GroupTeam | null) {
|
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) {
|
function speakAnnouncement(message: string, rate: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user