功能:SSL 憑證過期偵測與提醒、歷史戰績改為分頁載入
根本原因: 1. 憑證過期時瀏覽器只回 Failed to fetch,前端無法辨識原因,使用者誤以為是 DB 連不上(本次 7/1 憑證過期即是如此)。 2. 歷史戰績一次撈出全部資料(400+ 筆、逾 100KB),筆數持續成長會越來越慢。 影響:所有 API 的連線錯誤訊息、歷史戰績頁載入速度、Docker 憑證掛載路徑與檔名。 修法: - 後端讀取 cert.pem 到期日(60 秒快取),由 /api/version 與 /api/health 回傳 certNotAfter - 前端把到期日存進 localStorage;fetch 失敗時依離線、憑證已過期、其他連線問題分別顯示明確訊息 - 憑證到期前 7 天於頁面跳出「SSL 憑證提醒」通知,可手動關閉 - /api/history 支援 page/pageSize(預設 20 筆、上限 100),回傳 total 與 totalPages - 歷史戰績頁加入分頁控制列(上一頁/頁碼含省略號/下一頁/總筆數),刪除後自動重新載入並修正超出範圍的頁碼 - 憑證掛載目錄改為 /volume1/docker/certs,檔名改為 cert.pem / chain.pem / privkey.pem,app 容器同步掛載以讀取到期日 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,16 +125,22 @@ https://你的網域或 NAS IP:3501
|
|||||||
Docker Compose 會掛載以下目錄:
|
Docker Compose 會掛載以下目錄:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/volume1/homes/JianMiau/www/certificate/
|
/volume1/docker/certs/
|
||||||
```
|
```
|
||||||
|
|
||||||
需包含:
|
需包含:
|
||||||
|
|
||||||
- `RSA-cert.pem`
|
- `cert.pem`
|
||||||
- `RSA-chain.pem`
|
- `chain.pem`
|
||||||
- `RSA-privkey.pem`
|
- `privkey.pem`
|
||||||
|
|
||||||
之後只要更新這個目錄內的憑證檔案,再重新部署容器即可套用新 SSL。
|
nginx 容器會監看這個目錄,更新憑證檔案後會自動重新載入,不需要重啟容器。
|
||||||
|
|
||||||
|
### 憑證到期偵測
|
||||||
|
|
||||||
|
- 後端會讀取 `cert.pem` 的到期日,透過 `/api/version` 與 `/api/health` 回報給前端。
|
||||||
|
- 前端會把到期日存在 localStorage;當 API 出現 `Failed to fetch` 時,若存下來的到期日已過,會明確顯示「SSL 憑證已於某日過期」而不是通用錯誤。
|
||||||
|
- 憑證到期前 `7` 天,頁面會跳出提醒更新憑證的通知。
|
||||||
|
|
||||||
## 資料表格式
|
## 資料表格式
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -17,6 +17,10 @@ services:
|
|||||||
DB_DATABASE: ${DB_DATABASE:-badminton}
|
DB_DATABASE: ${DB_DATABASE:-badminton}
|
||||||
DB_TABLE: ${DB_TABLE:-badminton}
|
DB_TABLE: ${DB_TABLE:-badminton}
|
||||||
DB_HISTORY_TABLE: ${DB_HISTORY_TABLE:-history}
|
DB_HISTORY_TABLE: ${DB_HISTORY_TABLE:-history}
|
||||||
|
SSL_CERT_DIR: /certs
|
||||||
|
SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem}
|
||||||
|
volumes:
|
||||||
|
- /volume1/docker/certs:/certs:ro
|
||||||
|
|
||||||
badminton-scoreboard-web:
|
badminton-scoreboard-web:
|
||||||
container_name: badminton-scoreboard-web
|
container_name: badminton-scoreboard-web
|
||||||
@@ -33,10 +37,10 @@ services:
|
|||||||
NGINX_PORT: 3501
|
NGINX_PORT: 3501
|
||||||
NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_}
|
NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_}
|
||||||
SSL_CERT_DIR: /etc/nginx/certs
|
SSL_CERT_DIR: /etc/nginx/certs
|
||||||
SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-RSA-cert.pem}
|
SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem}
|
||||||
SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-RSA-chain.pem}
|
SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-chain.pem}
|
||||||
SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-RSA-privkey.pem}
|
SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-privkey.pem}
|
||||||
UPSTREAM_HOST: badminton-scoreboard
|
UPSTREAM_HOST: badminton-scoreboard
|
||||||
UPSTREAM_PORT: 8788
|
UPSTREAM_PORT: 8788
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/homes/JianMiau/www/certificate:/etc/nginx/certs:ro
|
- /volume1/docker/certs:/etc/nginx/certs:ro
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ set -eu
|
|||||||
NGINX_PORT="${NGINX_PORT:-3501}"
|
NGINX_PORT="${NGINX_PORT:-3501}"
|
||||||
NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}"
|
NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}"
|
||||||
SSL_CERT_DIR="${SSL_CERT_DIR:-/etc/nginx/certs}"
|
SSL_CERT_DIR="${SSL_CERT_DIR:-/etc/nginx/certs}"
|
||||||
SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-RSA-cert.pem}"
|
SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-cert.pem}"
|
||||||
SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-RSA-chain.pem}"
|
SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-chain.pem}"
|
||||||
SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-RSA-privkey.pem}"
|
SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-privkey.pem}"
|
||||||
UPSTREAM_HOST="${UPSTREAM_HOST:-badminton-scoreboard}"
|
UPSTREAM_HOST="${UPSTREAM_HOST:-badminton-scoreboard}"
|
||||||
UPSTREAM_PORT="${UPSTREAM_PORT:-8788}"
|
UPSTREAM_PORT="${UPSTREAM_PORT:-8788}"
|
||||||
|
|
||||||
|
|||||||
+55
-1
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import mysql from 'mysql2/promise'
|
import mysql from 'mysql2/promise'
|
||||||
|
import { X509Certificate } from 'node:crypto'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -21,6 +22,14 @@ const roomDataDir = path.join(projectRoot, 'server', 'data')
|
|||||||
const roomsFilePath = path.join(roomDataDir, 'live-rooms.json')
|
const roomsFilePath = path.join(roomDataDir, 'live-rooms.json')
|
||||||
const distReady = existsSync(path.join(distDir, 'index.html'))
|
const distReady = existsSync(path.join(distDir, 'index.html'))
|
||||||
|
|
||||||
|
// SSL 憑證由 nginx 處理,這裡只讀取到期日回報給前端;
|
||||||
|
// 前端存下來後,遇到 Failed to fetch 就能判斷是不是憑證過期造成的。
|
||||||
|
const sslCertDir = process.env.SSL_CERT_DIR ?? ''
|
||||||
|
const sslCertFileName = process.env.SSL_CERT_FILE_NAME ?? 'cert.pem'
|
||||||
|
const sslCertPath = sslCertDir ? path.join(sslCertDir, sslCertFileName) : ''
|
||||||
|
const CERT_INFO_TTL_MS = 60_000
|
||||||
|
let certInfoCache = { checkedAt: 0, notAfter: null }
|
||||||
|
|
||||||
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
||||||
const missingEnv = requiredEnv.filter((key) => !process.env[key])
|
const missingEnv = requiredEnv.filter((key) => !process.env[key])
|
||||||
|
|
||||||
@@ -47,6 +56,7 @@ app.get('/api/health', (_request, response) => {
|
|||||||
response.json({
|
response.json({
|
||||||
appStartedAt,
|
appStartedAt,
|
||||||
appVersion,
|
appVersion,
|
||||||
|
certNotAfter: getCertNotAfter(),
|
||||||
ok: true,
|
ok: true,
|
||||||
dbReady: Boolean(pool),
|
dbReady: Boolean(pool),
|
||||||
distReady,
|
distReady,
|
||||||
@@ -65,6 +75,7 @@ app.get('/api/version', (_request, response) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.json({
|
response.json({
|
||||||
|
certNotAfter: getCertNotAfter(),
|
||||||
ok: true,
|
ok: true,
|
||||||
startedAt: appStartedAt,
|
startedAt: appStartedAt,
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
@@ -427,7 +438,7 @@ app.post('/api/history', async (request, response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/history', async (_request, response) => {
|
app.get('/api/history', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
response.status(500).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -436,19 +447,33 @@ app.get('/api/history', async (_request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分頁參數已驗證為整數並限制範圍,直接內插避免 mysql2 execute 對 LIMIT 佔位符的相容問題。
|
||||||
|
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1)
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number.parseInt(request.query.pageSize, 10) || 20))
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureHistoryTable(pool, historyTableName)
|
await ensureHistoryTable(pool, historyTableName)
|
||||||
|
const [[countRow]] = await pool.execute(
|
||||||
|
`SELECT COUNT(*) AS total FROM \`${historyTableName}\``,
|
||||||
|
)
|
||||||
|
const total = Number(countRow?.total ?? 0)
|
||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`
|
`
|
||||||
SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList
|
SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList
|
||||||
FROM \`${historyTableName}\`
|
FROM \`${historyTableName}\`
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
|
LIMIT ${pageSize} OFFSET ${offset}
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
response.json({
|
response.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: rows,
|
data: rows,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('history load error:', error)
|
console.error('history load error:', error)
|
||||||
@@ -521,6 +546,35 @@ app.listen(port, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 憑證檔可能隨時被換新,帶 60 秒快取避免每次請求都讀檔。
|
||||||
|
function getCertNotAfter() {
|
||||||
|
if (!sslCertPath) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (now - certInfoCache.checkedAt < CERT_INFO_TTL_MS) {
|
||||||
|
return certInfoCache.notAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
let notAfter = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certificate = new X509Certificate(readFileSync(sslCertPath))
|
||||||
|
const notAfterTime = Date.parse(certificate.validTo)
|
||||||
|
|
||||||
|
if (Number.isFinite(notAfterTime)) {
|
||||||
|
notAfter = new Date(notAfterTime).toISOString()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('read ssl cert error:', error instanceof Error ? error.message : error)
|
||||||
|
}
|
||||||
|
|
||||||
|
certInfoCache = { checkedAt: now, notAfter }
|
||||||
|
return notAfter
|
||||||
|
}
|
||||||
|
|
||||||
function createHostToken() {
|
function createHostToken() {
|
||||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -489,6 +489,48 @@
|
|||||||
opacity: 0.62;
|
opacity: 0.62;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-pages {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button {
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button-active {
|
||||||
|
color: #f8fff8;
|
||||||
|
background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-ellipsis {
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-summary {
|
||||||
|
color: var(--panel-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
.scoreboard-screen {
|
.scoreboard-screen {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 160px;
|
grid-template-columns: minmax(0, 1fr) 160px;
|
||||||
|
|||||||
+49
-1
@@ -5,6 +5,7 @@ import {
|
|||||||
createLiveRoom,
|
createLiveRoom,
|
||||||
loadMatchResults,
|
loadMatchResults,
|
||||||
releaseLiveRoom,
|
releaseLiveRoom,
|
||||||
|
rememberCertNotAfter,
|
||||||
saveMatchHistory,
|
saveMatchHistory,
|
||||||
sendLiveRoomHeartbeat,
|
sendLiveRoomHeartbeat,
|
||||||
updateLiveRoom,
|
updateLiveRoom,
|
||||||
@@ -106,6 +107,8 @@ const STREAK_TITLES: Record<number, string> = {
|
|||||||
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
||||||
const APP_VERSION_POLL_MS = 30000
|
const APP_VERSION_POLL_MS = 30000
|
||||||
const LIVE_ROOM_HEARTBEAT_MS = 10_000
|
const LIVE_ROOM_HEARTBEAT_MS = 10_000
|
||||||
|
// 憑證到期前 7 天開始提醒,避免到期當天才突然全部連不上。
|
||||||
|
const CERT_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -143,6 +146,8 @@ function App() {
|
|||||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||||
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
|
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
|
||||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||||
|
const [certExpiryWarning, setCertExpiryWarning] = useState('')
|
||||||
|
const certWarningDismissedRef = useRef(false)
|
||||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||||
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||||
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
|
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
|
||||||
@@ -256,12 +261,36 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
|
certNotAfter?: string | null
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
version?: string
|
version?: string
|
||||||
}
|
}
|
||||||
const nextVersion = payload.version?.trim()
|
const nextVersion = payload.version?.trim()
|
||||||
|
|
||||||
if (!active || !nextVersion) {
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberCertNotAfter(payload.certNotAfter)
|
||||||
|
|
||||||
|
const certNotAfterTime = payload.certNotAfter ? Date.parse(payload.certNotAfter) : NaN
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isFinite(certNotAfterTime) &&
|
||||||
|
certNotAfterTime - Date.now() < CERT_EXPIRY_WARNING_MS &&
|
||||||
|
!certWarningDismissedRef.current
|
||||||
|
) {
|
||||||
|
const expiryLabel = new Date(certNotAfterTime).toLocaleString('zh-TW', {
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
setCertExpiryWarning(
|
||||||
|
certNotAfterTime < Date.now()
|
||||||
|
? `SSL 憑證已於 ${expiryLabel} 過期,請盡快更新伺服器憑證。`
|
||||||
|
: `SSL 憑證將於 ${expiryLabel} 到期,請記得更新伺服器憑證。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextVersion) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,6 +1125,25 @@ function App() {
|
|||||||
{navigationLockMessage}
|
{navigationLockMessage}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{certExpiryWarning && !pwaUpdateReady ? (
|
||||||
|
<div className="pwa-update-toast" role="alert">
|
||||||
|
<div className="pwa-update-copy">
|
||||||
|
<strong>SSL 憑證提醒</strong>
|
||||||
|
<span>{certExpiryWarning}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="pwa-update-button"
|
||||||
|
onClick={() => {
|
||||||
|
certWarningDismissedRef.current = true
|
||||||
|
setCertExpiryWarning('')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-13
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
HistoryListItem,
|
HistoryListItem,
|
||||||
|
HistoryListPage,
|
||||||
HistoryRecord,
|
HistoryRecord,
|
||||||
HistoryUploadPayload,
|
HistoryUploadPayload,
|
||||||
HistoryUploadResponse,
|
HistoryUploadResponse,
|
||||||
@@ -11,8 +12,63 @@ import type {
|
|||||||
MatchResultsRecord,
|
MatchResultsRecord,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
|
const CERT_NOT_AFTER_STORAGE_KEY = 'badminton-scoreboard::cert-not-after'
|
||||||
|
|
||||||
|
// 連線正常時(/api/version 輪詢)把後端回報的憑證到期日存起來,
|
||||||
|
// 之後 fetch 直接失敗(瀏覽器只給 Failed to fetch,看不出原因)時,
|
||||||
|
// 就能比對到期日,把「憑證過期」明確顯示給使用者。
|
||||||
|
export function rememberCertNotAfter(value: string | null | undefined) {
|
||||||
|
if (!value || Number.isNaN(Date.parse(value))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CERT_NOT_AFTER_STORAGE_KEY, value)
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用時直接略過,只是少了憑證判斷。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRememberedCertNotAfter() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CERT_NOT_AFTER_STORAGE_KEY)
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = Date.parse(raw)
|
||||||
|
return Number.isNaN(time) ? null : new Date(time)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConnectionErrorMessage() {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
return '目前沒有網路連線,請確認網路後再試。'
|
||||||
|
}
|
||||||
|
|
||||||
|
const certNotAfter = getRememberedCertNotAfter()
|
||||||
|
|
||||||
|
if (certNotAfter && certNotAfter.getTime() < Date.now()) {
|
||||||
|
const expiredLabel = certNotAfter.toLocaleString('zh-TW', { hour12: false })
|
||||||
|
return `無法連線到伺服器:SSL 憑證已於 ${expiredLabel} 過期,請更新伺服器憑證後再試。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return '無法連線到伺服器,可能是網路不穩或伺服器離線,請稍後再試。'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(input: string, init?: RequestInit) {
|
||||||
|
try {
|
||||||
|
return await fetch(input, init)
|
||||||
|
} catch {
|
||||||
|
throw new Error(buildConnectionErrorMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadMatchResults(time: string) {
|
export async function loadMatchResults(time: string) {
|
||||||
const response = await fetch(`/api/match-results/${time}`)
|
const response = await apiFetch(`/api/match-results/${time}`)
|
||||||
const payload = (await readJsonSafely(response)) as {
|
const payload = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@@ -31,7 +87,7 @@ export async function loadMatchResults(time: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
||||||
const response = await fetch('/api/history', {
|
const response = await apiFetch('/api/history', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -52,23 +108,34 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
|||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadHistoryList() {
|
export async function loadHistoryList(page = 1, pageSize = 20): Promise<HistoryListPage> {
|
||||||
const response = await fetch('/api/history')
|
const response = await apiFetch(`/api/history?page=${page}&pageSize=${pageSize}`)
|
||||||
const payload = (await readJsonSafely(response)) as {
|
const payload = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
data?: HistoryRecord[]
|
data?: HistoryRecord[]
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
total?: number
|
||||||
|
totalPages?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
if (!response.ok || !payload.ok) {
|
||||||
throw new Error(payload.message ?? '讀取歷史戰績失敗。')
|
throw new Error(payload.message ?? '讀取歷史戰績失敗。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (payload.data ?? []).map(normalizeHistoryRecord)
|
const items = (payload.data ?? []).map(normalizeHistoryRecord)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page: payload.page ?? page,
|
||||||
|
total: payload.total ?? items.length,
|
||||||
|
totalPages: payload.totalPages ?? 1,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHistoryItem(id: number) {
|
export async function deleteHistoryItem(id: number) {
|
||||||
const response = await fetch(`/api/history/${id}`, {
|
const response = await apiFetch(`/api/history/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,7 +150,7 @@ export async function deleteHistoryItem(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createLiveRoom(payload: LiveRoomPayload) {
|
export async function createLiveRoom(payload: LiveRoomPayload) {
|
||||||
const response = await fetch('/api/rooms', {
|
const response = await apiFetch('/api/rooms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -105,7 +172,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayload) {
|
export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayload) {
|
||||||
const response = await fetch(`/api/rooms/${roomId}`, {
|
const response = await apiFetch(`/api/rooms/${roomId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -127,7 +194,7 @@ export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseLiveRoom(roomId: string, hostToken: string) {
|
export async function releaseLiveRoom(roomId: string, hostToken: string) {
|
||||||
const response = await fetch(`/api/rooms/${roomId}/release`, {
|
const response = await apiFetch(`/api/rooms/${roomId}/release`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -147,7 +214,7 @@ export async function releaseLiveRoom(roomId: string, hostToken: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) {
|
export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) {
|
||||||
const response = await fetch(`/api/rooms/${roomId}/heartbeat`, {
|
const response = await apiFetch(`/api/rooms/${roomId}/heartbeat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -167,7 +234,7 @@ export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function reconcileLiveRooms() {
|
export async function reconcileLiveRooms() {
|
||||||
const response = await fetch('/api/rooms/reconcile', {
|
const response = await apiFetch('/api/rooms/reconcile', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,7 +254,7 @@ export async function reconcileLiveRooms() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLiveRoomList() {
|
export async function loadLiveRoomList() {
|
||||||
const response = await fetch('/api/rooms')
|
const response = await apiFetch('/api/rooms')
|
||||||
const result = (await readJsonSafely(response)) as {
|
const result = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@@ -206,7 +273,7 @@ export async function loadLiveRoomList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLiveRoom(roomId: string) {
|
export async function loadLiveRoom(roomId: string) {
|
||||||
const response = await fetch(`/api/rooms/${roomId}`)
|
const response = await apiFetch(`/api/rooms/${roomId}`)
|
||||||
const result = (await readJsonSafely(response)) as {
|
const result = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
|
|||||||
+101
-4
@@ -2,12 +2,19 @@ import { useEffect, useState } from 'react'
|
|||||||
import { deleteHistoryItem, loadHistoryList } from '../lib/api'
|
import { deleteHistoryItem, loadHistoryList } from '../lib/api'
|
||||||
import type { HistoryListItem } from '../types'
|
import type { HistoryListItem } from '../types'
|
||||||
|
|
||||||
|
const HISTORY_PAGE_SIZE = 20
|
||||||
|
|
||||||
export function HistoryPage() {
|
export function HistoryPage() {
|
||||||
const [history, setHistory] = useState<HistoryListItem[]>([])
|
const [history, setHistory] = useState<HistoryListItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
// 刪除後遞增,讓目前頁面重新讀取一次。
|
||||||
|
const [reloadSignal, setReloadSignal] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -17,13 +24,21 @@ export function HistoryPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextHistory = await loadHistoryList()
|
const result = await loadHistoryList(page, HISTORY_PAGE_SIZE)
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setHistory(nextHistory)
|
// 刪除或資料變動後,目前頁碼可能超出範圍,往回跳到最後一頁。
|
||||||
|
if (result.items.length === 0 && result.total > 0 && page > result.totalPages) {
|
||||||
|
setPage(result.totalPages)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory(result.items)
|
||||||
|
setTotal(result.total)
|
||||||
|
setTotalPages(result.totalPages)
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return
|
return
|
||||||
@@ -42,7 +57,7 @@ export function HistoryPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [page, reloadSignal])
|
||||||
|
|
||||||
const handleDelete = async (item: HistoryListItem) => {
|
const handleDelete = async (item: HistoryListItem) => {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
@@ -58,8 +73,8 @@ export function HistoryPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteHistoryItem(item.id)
|
await deleteHistoryItem(item.id)
|
||||||
setHistory((current) => current.filter((entry) => entry.id !== item.id))
|
|
||||||
setSelectedItem((current) => (current?.id === item.id ? null : current))
|
setSelectedItem((current) => (current?.id === item.id ? null : current))
|
||||||
|
setReloadSignal((current) => current + 1)
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
setError(deleteError instanceof Error ? deleteError.message : '刪除戰績失敗。')
|
setError(deleteError instanceof Error ? deleteError.message : '刪除戰績失敗。')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -67,6 +82,14 @@ export function HistoryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToPage = (nextPage: number) => {
|
||||||
|
if (nextPage < 1 || nextPage > totalPages || nextPage === page || loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-grid">
|
<section className="page-grid">
|
||||||
@@ -95,6 +118,7 @@ export function HistoryPage() {
|
|||||||
<p>資料庫 `history` 表目前還沒有可顯示的資料。</p>
|
<p>資料庫 `history` 表目前還沒有可顯示的資料。</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="history-list">
|
<div className="history-list">
|
||||||
{history.map((item) => (
|
{history.map((item) => (
|
||||||
<article className="history-card history-card-shell" key={item.id}>
|
<article className="history-card history-card-shell" key={item.id}>
|
||||||
@@ -135,6 +159,53 @@ export function HistoryPage() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav className="history-pagination" aria-label="歷史戰績分頁">
|
||||||
|
<button
|
||||||
|
className="secondary-button pagination-button"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(page - 1)}
|
||||||
|
>
|
||||||
|
上一頁
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pagination-pages">
|
||||||
|
{getPageItems(page, totalPages).map((item, index) =>
|
||||||
|
item === 'ellipsis' ? (
|
||||||
|
<span className="pagination-ellipsis" key={`ellipsis-${index}`}>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
item === page
|
||||||
|
? 'secondary-button pagination-button pagination-button-active'
|
||||||
|
: 'secondary-button pagination-button'
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="secondary-button pagination-button"
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(page + 1)}
|
||||||
|
>
|
||||||
|
下一頁
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="pagination-summary">共 {total} 筆</span>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -214,3 +285,29 @@ function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) {
|
|||||||
function getStarterName(item: HistoryListItem, starter: number) {
|
function getStarterName(item: HistoryListItem, starter: number) {
|
||||||
return item.players[starter] ?? '未知球員'
|
return item.players[starter] ?? '未知球員'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 產生分頁按鈕:固定顯示第一頁與最後一頁,目前頁前後各一頁,其餘收成省略號。
|
||||||
|
function getPageItems(currentPage: number, totalPages: number) {
|
||||||
|
const pages = new Set<number>([1, totalPages])
|
||||||
|
|
||||||
|
for (let offset = -1; offset <= 1; offset += 1) {
|
||||||
|
const candidate = currentPage + offset
|
||||||
|
|
||||||
|
if (candidate >= 1 && candidate <= totalPages) {
|
||||||
|
pages.add(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = Array.from(pages).sort((left, right) => left - right)
|
||||||
|
const items: Array<number | 'ellipsis'> = []
|
||||||
|
|
||||||
|
sorted.forEach((pageNumber, index) => {
|
||||||
|
if (index > 0 && pageNumber - sorted[index - 1] > 1) {
|
||||||
|
items.push('ellipsis')
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(pageNumber)
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,13 @@ export type HistoryListItem = {
|
|||||||
winnerTeamName: string
|
winnerTeamName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HistoryListPage = {
|
||||||
|
items: HistoryListItem[]
|
||||||
|
page: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
export type LiveRoomStatus = 'live' | 'finished'
|
export type LiveRoomStatus = 'live' | 'finished'
|
||||||
|
|
||||||
export type LiveRoomSession = {
|
export type LiveRoomSession = {
|
||||||
|
|||||||
Reference in New Issue
Block a user