強化房間清理與比賽中分頁限制
This commit is contained in:
+88
-4
@@ -6,6 +6,7 @@ import {
|
||||
loadMatchResults,
|
||||
releaseLiveRoom,
|
||||
saveMatchHistory,
|
||||
sendLiveRoomHeartbeat,
|
||||
updateLiveRoom,
|
||||
} from './lib/api'
|
||||
import {
|
||||
@@ -90,6 +91,7 @@ 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
|
||||
|
||||
function App() {
|
||||
const location = useLocation()
|
||||
@@ -129,6 +131,7 @@ function App() {
|
||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||
const currentAppVersionRef = useRef<string | null>(null)
|
||||
const creatingRoomRef = useRef(false)
|
||||
const lastSyncedRoomSignatureRef = useRef('')
|
||||
@@ -139,6 +142,7 @@ function App() {
|
||||
const leftTeam = activeMatchup.leftTeam
|
||||
const rightTeam = activeMatchup.rightTeam
|
||||
const liveRoomId = liveRoomSession?.roomId ?? null
|
||||
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
||||
@@ -192,6 +196,18 @@ function App() {
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [victoryAnnouncement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigationLockMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setNavigationLockMessage('')
|
||||
}, 1400)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [navigationLockMessage])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePwaUpdateReady = () => {
|
||||
setPwaUpdateReady(true)
|
||||
@@ -482,6 +498,43 @@ function App() {
|
||||
isScoreboardRoute,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNavigationLocked || isScoreboardRoute) {
|
||||
return
|
||||
}
|
||||
|
||||
navigate('/scoreboard', { replace: true })
|
||||
setNavigationLockMessage('比賽進行中,請先完成結算。')
|
||||
}, [isNavigationLocked, isScoreboardRoute, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
|
||||
const syncHeartbeat = async () => {
|
||||
try {
|
||||
await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken)
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
console.error('live room heartbeat error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void syncHeartbeat()
|
||||
const timer = window.setInterval(() => {
|
||||
void syncHeartbeat()
|
||||
}, LIVE_ROOM_HEARTBEAT_MS)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [isScoreboardRoute, liveRoomSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveRoomSession || liveRoomSession.status !== 'live') {
|
||||
return
|
||||
@@ -822,6 +875,15 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!isNavigationLocked || targetPath === '/scoreboard') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setNavigationLockMessage('比賽進行中,請先完成結算。')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard' : 'app-shell'}>
|
||||
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
|
||||
@@ -837,16 +899,32 @@ function App() {
|
||||
</div>
|
||||
|
||||
<nav className="topnav" aria-label="主要導覽">
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/teams">
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/teams')}
|
||||
to="/teams"
|
||||
>
|
||||
選隊伍
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/scoreboard">
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/scoreboard')}
|
||||
to="/scoreboard"
|
||||
>
|
||||
記分板
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/history')}
|
||||
to="/history"
|
||||
>
|
||||
歷史戰績
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/rooms">
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/rooms')}
|
||||
to="/rooms"
|
||||
>
|
||||
房間列表
|
||||
</NavLink>
|
||||
</nav>
|
||||
@@ -943,6 +1021,12 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{navigationLockMessage ? (
|
||||
<div className="floating-status-bubble" role="status" aria-live="polite">
|
||||
{navigationLockMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+46
-6
@@ -24,7 +24,7 @@ export async function loadMatchResults(time: string) {
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message ?? '無法讀取對戰資料。')
|
||||
throw new Error(payload.message ?? '讀取指定日期分組失敗。')
|
||||
}
|
||||
|
||||
return payload.data ?? null
|
||||
@@ -46,7 +46,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok || !result.data) {
|
||||
throw new Error(result.message ?? '無法上傳戰績。')
|
||||
throw new Error(result.message ?? '上傳戰績失敗。')
|
||||
}
|
||||
|
||||
return result.data
|
||||
@@ -61,7 +61,7 @@ export async function loadHistoryList() {
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message ?? '無法讀取歷史戰績。')
|
||||
throw new Error(payload.message ?? '讀取歷史戰績失敗。')
|
||||
}
|
||||
|
||||
return (payload.data ?? []).map(normalizeHistoryRecord)
|
||||
@@ -98,7 +98,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) {
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok || !result.data) {
|
||||
throw new Error(result.message ?? '建立觀戰房間失敗。')
|
||||
throw new Error(result.message ?? '建立房間失敗。')
|
||||
}
|
||||
|
||||
return result.data
|
||||
@@ -146,6 +146,46 @@ export async function releaseLiveRoom(roomId: string, hostToken: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) {
|
||||
const response = await fetch(`/api/rooms/${roomId}/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ hostToken }),
|
||||
keepalive: true,
|
||||
})
|
||||
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? '更新房間心跳失敗。')
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileLiveRooms() {
|
||||
const response = await fetch('/api/rooms/reconcile', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
removedRoomIds?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? '清理無主房間失敗。')
|
||||
}
|
||||
|
||||
return result.data?.removedRoomIds ?? []
|
||||
}
|
||||
|
||||
export async function loadLiveRoomList() {
|
||||
const response = await fetch('/api/rooms')
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
@@ -178,7 +218,7 @@ export async function loadLiveRoom(roomId: string) {
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok || !result.data) {
|
||||
throw new Error(result.message ?? '載入房間內容失敗。')
|
||||
throw new Error(result.message ?? '載入觀戰房間失敗。')
|
||||
}
|
||||
|
||||
return result.data
|
||||
@@ -271,7 +311,7 @@ function parseJson<T>(value: string | null, fallback: T): T {
|
||||
}
|
||||
|
||||
function getDayLabel(dayOfWeek: number) {
|
||||
const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
|
||||
const labels = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
return labels[dayOfWeek] ?? '-'
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -43,7 +43,11 @@ if ('serviceWorker' in navigator) {
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
void navigator.serviceWorker.register('/sw.js').then((registration) => {
|
||||
void navigator.serviceWorker
|
||||
.register('/sw.js', {
|
||||
updateViaCache: 'none',
|
||||
})
|
||||
.then((registration) => {
|
||||
if (registration.waiting) {
|
||||
notifyUpdateReady()
|
||||
}
|
||||
@@ -52,6 +56,7 @@ if ('serviceWorker' in navigator) {
|
||||
registration.addEventListener('updatefound', () => {
|
||||
trackWorker(registration.installing)
|
||||
})
|
||||
void registration.update()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { loadLiveRoomList, subscribeRoomList } from '../lib/api'
|
||||
import { loadLiveRoomList, reconcileLiveRooms, subscribeRoomList } from '../lib/api'
|
||||
import type { LiveRoomSummary } from '../types'
|
||||
|
||||
const REFRESH_COOLDOWN_SECONDS = 5
|
||||
@@ -10,23 +10,20 @@ export function RoomListPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [refreshCooldown, setRefreshCooldown] = useState(0)
|
||||
const [refreshMessage, setRefreshMessage] = useState('')
|
||||
const [rooms, setRooms] = useState<LiveRoomSummary[]>([])
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const loadRooms = async (options?: { manual?: boolean }) => {
|
||||
const loadRooms = async () => {
|
||||
if (loadingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingRef.current = true
|
||||
|
||||
if (options?.manual) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const nextRooms = await loadLiveRoomList()
|
||||
|
||||
@@ -47,9 +44,6 @@ export function RoomListPage() {
|
||||
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
if (options?.manual) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +77,18 @@ export function RoomListPage() {
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [refreshCooldown])
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setRefreshMessage('')
|
||||
}, 2000)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [refreshMessage])
|
||||
|
||||
const refreshRoomList = async () => {
|
||||
if (refreshCooldown > 0 || loadingRef.current) {
|
||||
return
|
||||
@@ -93,9 +99,15 @@ export function RoomListPage() {
|
||||
setRefreshCooldown(REFRESH_COOLDOWN_SECONDS)
|
||||
|
||||
try {
|
||||
const removedRoomIds = await reconcileLiveRooms()
|
||||
const nextRooms = await loadLiveRoomList()
|
||||
setRooms(nextRooms)
|
||||
setError('')
|
||||
setRefreshMessage(
|
||||
removedRoomIds.length > 0
|
||||
? `已清掉 ${removedRoomIds.length} 個無主房間。`
|
||||
: '已檢查房間列表,沒有需要清理的房間。',
|
||||
)
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。')
|
||||
} finally {
|
||||
@@ -129,6 +141,7 @@ export function RoomListPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{refreshMessage ? <p className="selection-hint">{refreshMessage}</p> : null}
|
||||
{loading ? <p>正在載入房間列表...</p> : null}
|
||||
{!loading && error ? <p className="history-empty">{error}</p> : null}
|
||||
{!loading && !error && rooms.length === 0 ? (
|
||||
|
||||
@@ -86,10 +86,13 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.status === 'released') {
|
||||
if (payload.status === 'released' || payload.status === 'stale') {
|
||||
setRoomClosedDialog({
|
||||
title: '房間已關閉',
|
||||
message: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。',
|
||||
message:
|
||||
payload.status === 'stale'
|
||||
? '這個房間已經沒有主控在線上,系統已自動清理並結束觀戰。'
|
||||
: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。',
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user