From 2d1ad0600e4ceb72e70a3c67b0e290c252f14679 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Sun, 19 Apr 2026 13:02:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=9C=E4=B8=8A=E6=88=BF=E9=96=93=E9=97=9C?= =?UTF-8?q?=E9=96=89=E9=80=9A=E7=9F=A5=E8=88=87=E5=88=97=E8=A1=A8=E9=87=8D?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/data/live-rooms.json | 57 +++++++++++-------- src/App.css | 11 ++++ src/lib/api.ts | 9 +++ src/pages/RoomListPage.tsx | 75 ++++++++++++++++++++++++- src/pages/RoomSpectatorPage.tsx | 97 ++++++++++++++++++++++++--------- 5 files changed, 199 insertions(+), 50 deletions(-) diff --git a/server/data/live-rooms.json b/server/data/live-rooms.json index 4655827..08b73dd 100644 --- a/server/data/live-rooms.json +++ b/server/data/live-rooms.json @@ -138,36 +138,24 @@ "winnerTeamName": "柏威 / 玟瑄" }, { - "createdAt": "2026-04-19T04:52:26.119Z", - "groupId": 2, - "hostToken": "mo5ahuo76ktdmleh", - "leftTeamName": "景涵 / 小念", - "matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄", + "createdAt": "2026-04-19T04:58:15.291Z", + "groupId": 1, + "hostToken": "mo5apc3foksw0enn", + "leftTeamName": "景涵 / RuRu", + "matchupLabel": "景涵 / RuRu vs 小念 / 柏威", "pointLog": [ { "round": 0, "starter": 0, "winCount": 0, - "winner": 1 - }, - { - "round": 1, - "starter": 2, - "winCount": 0, - "winner": 0 - }, - { - "round": 2, - "starter": 1, - "winCount": 1, "winner": 0 } ], - "rightTeamName": "柏威 / 玟瑄", - "roomId": "208299", + "rightTeamName": "小念 / 柏威", + "roomId": "432277", "scoreState": { - "scoreLeft": 2, - "scoreRight": 1, + "scoreLeft": 1, + "scoreRight": 0, "gamesLeft": 0, "gamesRight": 0, "currentGame": 1, @@ -176,9 +164,34 @@ "leftRightCourtPlayer": "playerB", "rightRightCourtPlayer": "playerA" }, + "status": "finished", + "targetDate": "2026-04-13", + "updatedAt": "2026-04-19T04:58:25.870Z", + "winnerTeamName": "景涵 / RuRu" + }, + { + "createdAt": "2026-04-19T05:01:10.705Z", + "groupId": 1, + "hostToken": "mo5at3g18sybc1fw", + "leftTeamName": "柏威 / RuRu", + "matchupLabel": "柏威 / RuRu vs 建喵 / 小念", + "pointLog": [], + "rightTeamName": "建喵 / 小念", + "roomId": "498013", + "scoreState": { + "scoreLeft": 0, + "scoreRight": 0, + "gamesLeft": 0, + "gamesRight": 0, + "currentGame": 1, + "targetScore": 21, + "serving": null, + "leftRightCourtPlayer": "playerA", + "rightRightCourtPlayer": "playerA" + }, "status": "live", "targetDate": "2026-04-13", - "updatedAt": "2026-04-19T04:52:32.296Z", + "updatedAt": "2026-04-19T05:01:10.731Z", "winnerTeamName": null } ] \ No newline at end of file diff --git a/src/App.css b/src/App.css index fa33737..a692b33 100644 --- a/src/App.css +++ b/src/App.css @@ -2176,6 +2176,17 @@ gap: 16px; } +.room-list-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.room-refresh-button:disabled { + cursor: default; + opacity: 0.72; +} + .room-card { display: grid; gap: 14px; diff --git a/src/lib/api.ts b/src/lib/api.ts index bac6a9b..81b7dc6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -201,6 +201,7 @@ export function subscribeLiveRoom( roomId: string, onMessage: (room: LiveRoomDetail) => void, onError?: () => void, + onRoomClosed?: (payload: { roomId: string; status: string }) => void, ) { const source = new EventSource(`/api/rooms/${roomId}/stream`) @@ -209,6 +210,14 @@ export function subscribeLiveRoom( onMessage(payload) }) + source.addEventListener('room-closed', (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + roomId: string + status: string + } + onRoomClosed?.(payload) + }) + source.onerror = () => { onError?.() } diff --git a/src/pages/RoomListPage.tsx b/src/pages/RoomListPage.tsx index 447a8a5..1b2c86e 100644 --- a/src/pages/RoomListPage.tsx +++ b/src/pages/RoomListPage.tsx @@ -1,17 +1,32 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { loadLiveRoomList, subscribeRoomList } from '../lib/api' import type { LiveRoomSummary } from '../types' +const REFRESH_COOLDOWN_SECONDS = 5 + export function RoomListPage() { const [error, setError] = useState('') const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [refreshCooldown, setRefreshCooldown] = useState(0) const [rooms, setRooms] = useState([]) + const loadingRef = useRef(false) useEffect(() => { let active = true - const load = async () => { + const loadRooms = async (options?: { manual?: boolean }) => { + if (loadingRef.current) { + return + } + + loadingRef.current = true + + if (options?.manual) { + setRefreshing(true) + } + try { const nextRooms = await loadLiveRoomList() @@ -28,13 +43,18 @@ export function RoomListPage() { setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。') } finally { + loadingRef.current = false + if (active) { setLoading(false) + if (options?.manual) { + setRefreshing(false) + } } } } - void load() + void loadRooms() const unsubscribe = subscribeRoomList((nextRooms) => { if (!active) { return @@ -51,6 +71,40 @@ export function RoomListPage() { } }, []) + useEffect(() => { + if (refreshCooldown <= 0) { + return + } + + const timer = window.setTimeout(() => { + setRefreshCooldown((current) => Math.max(0, current - 1)) + }, 1000) + + return () => window.clearTimeout(timer) + }, [refreshCooldown]) + + const refreshRoomList = async () => { + if (refreshCooldown > 0 || loadingRef.current) { + return + } + + loadingRef.current = true + setRefreshing(true) + setRefreshCooldown(REFRESH_COOLDOWN_SECONDS) + + try { + const nextRooms = await loadLiveRoomList() + setRooms(nextRooms) + setError('') + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。') + } finally { + loadingRef.current = false + setRefreshing(false) + setLoading(false) + } + } + return (
@@ -60,6 +114,21 @@ export function RoomListPage() {
+
+ +
+ {loading ?

正在載入房間列表...

: null} {!loading && error ?

{error}

: null} {!loading && !error && rooms.length === 0 ? ( diff --git a/src/pages/RoomSpectatorPage.tsx b/src/pages/RoomSpectatorPage.tsx index 09e9ca7..29f8c95 100644 --- a/src/pages/RoomSpectatorPage.tsx +++ b/src/pages/RoomSpectatorPage.tsx @@ -9,12 +9,18 @@ type RoomSpectatorPageProps = { const ROOM_POLL_MS = 1500 +type RoomClosedDialog = { + message: string + title: string +} | null + export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps) { const { roomId = '' } = useParams() const [error, setError] = useState('') const [loading, setLoading] = useState(true) const [room, setRoom] = useState(null) const [showFinishedDialog, setShowFinishedDialog] = useState(false) + const [roomClosedDialog, setRoomClosedDialog] = useState(null) const previousStatusRef = useRef(null) const hasRoomRef = useRef(false) @@ -75,6 +81,19 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps) setLoading(false) } }, + (payload) => { + if (!active) { + return + } + + if (payload.status === 'released') { + setRoomClosedDialog({ + title: '房間已關閉', + message: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。', + }) + setLoading(false) + } + }, ) const timer = window.setInterval(() => { @@ -98,13 +117,13 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps) ) } - if (!room || error) { + if (!room && error) { return (

Spectator

無法進入房間

-

{error || '這個房間不存在或已關閉。'}

+

{error}

返回房間列表 @@ -118,42 +137,48 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps)

Spectator

-

房號 {room.roomId}

+

房號 {room?.roomId ?? roomId}

這是觀戰模式,只會即時同步比分,不提供任何操作。

-
-
-
- {room.leftTeamName} - {room.scoreState.scoreLeft} + {room ? ( +
+
+
+ {room.leftTeamName} + {room.scoreState.scoreLeft} +
+
:
+
+ {room.rightTeamName} + {room.scoreState.scoreRight} +
-
:
-
- {room.rightTeamName} - {room.scoreState.scoreRight} -
-
-
- 目標分數 {room.scoreState.targetScore} - 房間狀態 {room.status === 'finished' ? '已結束' : '進行中'} - - 最後更新 {new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })} - -
-
+
+ 目標分數 {room.scoreState.targetScore} + 房間狀態 {room.status === 'finished' ? '已結束' : '進行中'} + + 最後更新 {new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })} + +
+
+ ) : ( +
+

房間已不存在,請返回房間列表。

+
+ )}
- {showFinishedDialog ? ( + {showFinishedDialog && room ? (

比賽結束

{room.winnerTeamName ?? '已有獲勝隊伍'}

{room.winnerTeamName - ? `${room.winnerTeamName} 已獲勝,本場觀戰會返回列表。` - : '比賽已結束,本場觀戰會返回列表。'} + ? `${room.winnerTeamName} 已獲勝,按下確定後返回房間列表。` + : '比賽已結束,按下確定後返回房間列表。'}

) : null} + + {roomClosedDialog ? ( +
+
+

觀戰提醒

+

{roomClosedDialog.title}

+

{roomClosedDialog.message}

+
+ +
+
+
+ ) : null} ) }