功能: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:
2026-07-13 15:44:44 +08:00
co-authored by Claude Fable 5
parent 13f3bf5198
commit 1bc42f5bf6
9 changed files with 356 additions and 31 deletions
+42
View File
@@ -489,6 +489,48 @@
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px;
+49 -1
View File
@@ -5,6 +5,7 @@ import {
createLiveRoom,
loadMatchResults,
releaseLiveRoom,
rememberCertNotAfter,
saveMatchHistory,
sendLiveRoomHeartbeat,
updateLiveRoom,
@@ -106,6 +107,8 @@ const STREAK_TITLES: Record<number, string> = {
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
const APP_VERSION_POLL_MS = 30000
const LIVE_ROOM_HEARTBEAT_MS = 10_000
// 憑證到期前 7 天開始提醒,避免到期當天才突然全部連不上。
const CERT_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000
function App() {
const location = useLocation()
@@ -143,6 +146,8 @@ function App() {
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [certExpiryWarning, setCertExpiryWarning] = useState('')
const certWarningDismissedRef = useRef(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('')
// 結算完成後遞增,通知記分板自動打開選隊伍面板讓人選下一場。
@@ -256,12 +261,36 @@ function App() {
}
const payload = (await response.json()) as {
certNotAfter?: string | null
ok?: boolean
version?: string
}
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
}
@@ -1096,6 +1125,25 @@ function App() {
{navigationLockMessage}
</div>
) : 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>
)
}
+80 -13
View File
@@ -1,5 +1,6 @@
import type {
HistoryListItem,
HistoryListPage,
HistoryRecord,
HistoryUploadPayload,
HistoryUploadResponse,
@@ -11,8 +12,63 @@ import type {
MatchResultsRecord,
} 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) {
const response = await fetch(`/api/match-results/${time}`)
const response = await apiFetch(`/api/match-results/${time}`)
const payload = (await readJsonSafely(response)) as {
ok?: boolean
message?: string
@@ -31,7 +87,7 @@ export async function loadMatchResults(time: string) {
}
export async function saveMatchHistory(payload: HistoryUploadPayload) {
const response = await fetch('/api/history', {
const response = await apiFetch('/api/history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -52,23 +108,34 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
return result.data
}
export async function loadHistoryList() {
const response = await fetch('/api/history')
export async function loadHistoryList(page = 1, pageSize = 20): Promise<HistoryListPage> {
const response = await apiFetch(`/api/history?page=${page}&pageSize=${pageSize}`)
const payload = (await readJsonSafely(response)) as {
ok?: boolean
message?: string
data?: HistoryRecord[]
page?: number
pageSize?: number
total?: number
totalPages?: number
}
if (!response.ok || !payload.ok) {
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) {
const response = await fetch(`/api/history/${id}`, {
const response = await apiFetch(`/api/history/${id}`, {
method: 'DELETE',
})
@@ -83,7 +150,7 @@ export async function deleteHistoryItem(id: number) {
}
export async function createLiveRoom(payload: LiveRoomPayload) {
const response = await fetch('/api/rooms', {
const response = await apiFetch('/api/rooms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -105,7 +172,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) {
}
export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayload) {
const response = await fetch(`/api/rooms/${roomId}`, {
const response = await apiFetch(`/api/rooms/${roomId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -127,7 +194,7 @@ export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayl
}
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',
headers: {
'Content-Type': 'application/json',
@@ -147,7 +214,7 @@ export async function releaseLiveRoom(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',
headers: {
'Content-Type': 'application/json',
@@ -167,7 +234,7 @@ export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) {
}
export async function reconcileLiveRooms() {
const response = await fetch('/api/rooms/reconcile', {
const response = await apiFetch('/api/rooms/reconcile', {
method: 'POST',
})
@@ -187,7 +254,7 @@ export async function reconcileLiveRooms() {
}
export async function loadLiveRoomList() {
const response = await fetch('/api/rooms')
const response = await apiFetch('/api/rooms')
const result = (await readJsonSafely(response)) as {
ok?: boolean
message?: string
@@ -206,7 +273,7 @@ export async function loadLiveRoomList() {
}
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 {
ok?: boolean
message?: string
+101 -4
View File
@@ -2,12 +2,19 @@ import { useEffect, useState } from 'react'
import { deleteHistoryItem, loadHistoryList } from '../lib/api'
import type { HistoryListItem } from '../types'
const HISTORY_PAGE_SIZE = 20
export function HistoryPage() {
const [history, setHistory] = useState<HistoryListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedItem, setSelectedItem] = useState<HistoryListItem | 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(() => {
let active = true
@@ -17,13 +24,21 @@ export function HistoryPage() {
setError('')
try {
const nextHistory = await loadHistoryList()
const result = await loadHistoryList(page, HISTORY_PAGE_SIZE)
if (!active) {
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) {
if (!active) {
return
@@ -42,7 +57,7 @@ export function HistoryPage() {
return () => {
active = false
}
}, [])
}, [page, reloadSignal])
const handleDelete = async (item: HistoryListItem) => {
const confirmed = window.confirm(
@@ -58,8 +73,8 @@ export function HistoryPage() {
try {
await deleteHistoryItem(item.id)
setHistory((current) => current.filter((entry) => entry.id !== item.id))
setSelectedItem((current) => (current?.id === item.id ? null : current))
setReloadSignal((current) => current + 1)
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : '刪除戰績失敗。')
} finally {
@@ -67,6 +82,14 @@ export function HistoryPage() {
}
}
const goToPage = (nextPage: number) => {
if (nextPage < 1 || nextPage > totalPages || nextPage === page || loading) {
return
}
setPage(nextPage)
}
return (
<>
<section className="page-grid">
@@ -95,6 +118,7 @@ export function HistoryPage() {
<p> `history` </p>
</div>
) : (
<>
<div className="history-list">
{history.map((item) => (
<article className="history-card history-card-shell" key={item.id}>
@@ -135,6 +159,53 @@ export function HistoryPage() {
</article>
))}
</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>
</section>
@@ -214,3 +285,29 @@ function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) {
function getStarterName(item: HistoryListItem, starter: number) {
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
}
+7
View File
@@ -131,6 +131,13 @@ export type HistoryListItem = {
winnerTeamName: string
}
export type HistoryListPage = {
items: HistoryListItem[]
page: number
total: number
totalPages: number
}
export type LiveRoomStatus = 'live' | 'finished'
export type LiveRoomSession = {