強化房間清理與比賽中分頁限制
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
- `比賽結算` 需要長按 `1 秒` 才會觸發。
|
- `比賽結算` 需要長按 `1 秒` 才會觸發。
|
||||||
- 比分 `0:0` 時不可結算。
|
- 比分 `0:0` 時不可結算。
|
||||||
- 全站文字預設不可選取,避免手機誤觸反白。
|
- 全站文字預設不可選取,避免手機誤觸反白。
|
||||||
|
- 只要已設定先攻並開始比賽,就不能切換到其他分頁,需先完成結算。
|
||||||
- 語音播報
|
- 語音播報
|
||||||
- 可設定是否播報得分者。
|
- 可設定是否播報得分者。
|
||||||
- 可設定是否播報發球者。
|
- 可設定是否播報發球者。
|
||||||
@@ -38,6 +39,8 @@
|
|||||||
- 帶入隊伍進入記分板後會自動建立房間。
|
- 帶入隊伍進入記分板後會自動建立房間。
|
||||||
- 記分板會顯示房號。
|
- 記分板會顯示房號。
|
||||||
- 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間。
|
- 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間。
|
||||||
|
- 房間列表可手動重新取得,按一次後有 `5 秒` 冷卻。
|
||||||
|
- 手動重新取得時,會順便清理沒有主控在線的無主房間。
|
||||||
- 觀戰者只能看,不能操作。
|
- 觀戰者只能看,不能操作。
|
||||||
- 觀戰同步使用 `SSE + 輪詢備援`。
|
- 觀戰同步使用 `SSE + 輪詢備援`。
|
||||||
- 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。
|
- 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
- PWA
|
- PWA
|
||||||
- 可加入手機主畫面,像 App 一樣開啟。
|
- 可加入手機主畫面,像 App 一樣開啟。
|
||||||
- 支援主畫面 icon 與版本更新提示。
|
- 支援主畫面 icon 與版本更新提示。
|
||||||
|
- 文件頁面改為網路優先,降低 iPad / iPhone PWA 卡舊版快取的機率。
|
||||||
|
|
||||||
## 開發
|
## 開發
|
||||||
|
|
||||||
|
|||||||
49
public/sw.js
49
public/sw.js
@@ -1,7 +1,5 @@
|
|||||||
const CACHE_NAME = 'badminton-scoreboard-v1'
|
const CACHE_NAME = 'badminton-scoreboard-v2'
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/',
|
|
||||||
'/index.html',
|
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/favicon.png',
|
'/favicon.png',
|
||||||
'/icon.png',
|
'/icon.png',
|
||||||
@@ -51,19 +49,45 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.respondWith(
|
if (requestUrl.pathname.startsWith('/api/')) {
|
||||||
caches.match(event.request).then(async (cachedResponse) => {
|
event.respondWith(fetch(event.request))
|
||||||
if (cachedResponse) {
|
return
|
||||||
return cachedResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNavigationRequest =
|
||||||
|
event.request.mode === 'navigate' || event.request.destination === 'document'
|
||||||
|
|
||||||
|
if (isNavigationRequest) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then(async (networkResponse) => {
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME)
|
||||||
|
cache.put('/index.html', networkResponse.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const fallback = await caches.match('/index.html')
|
||||||
|
if (fallback) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Navigation request failed')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(async (cachedResponse) => {
|
||||||
try {
|
try {
|
||||||
const networkResponse = await fetch(event.request)
|
const networkResponse = await fetch(event.request)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
networkResponse.ok &&
|
networkResponse.ok &&
|
||||||
(event.request.destination === 'document' ||
|
(event.request.destination === 'script' ||
|
||||||
event.request.destination === 'script' ||
|
|
||||||
event.request.destination === 'style' ||
|
event.request.destination === 'style' ||
|
||||||
event.request.destination === 'image' ||
|
event.request.destination === 'image' ||
|
||||||
requestUrl.pathname.startsWith('/assets/'))
|
requestUrl.pathname.startsWith('/assets/'))
|
||||||
@@ -74,11 +98,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
|
|
||||||
return networkResponse
|
return networkResponse
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (event.request.mode === 'navigate') {
|
if (cachedResponse) {
|
||||||
const fallback = await caches.match('/index.html')
|
return cachedResponse
|
||||||
if (fallback) {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -1,197 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"createdAt": "2026-04-19T04:50:38.216Z",
|
|
||||||
"groupId": 1,
|
|
||||||
"hostToken": "mo5afjewicxz50b9",
|
|
||||||
"leftTeamName": "柏威 / 玟瑄",
|
|
||||||
"matchupLabel": "柏威 / 玟瑄 vs 小念 / 建喵",
|
|
||||||
"pointLog": [
|
|
||||||
{
|
|
||||||
"round": 0,
|
|
||||||
"starter": 3,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 1,
|
|
||||||
"starter": 3,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 2,
|
|
||||||
"starter": 1,
|
|
||||||
"winCount": 1,
|
|
||||||
"winner": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 3,
|
|
||||||
"starter": 1,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 4,
|
|
||||||
"starter": 2,
|
|
||||||
"winCount": 1,
|
|
||||||
"winner": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rightTeamName": "小念 / 建喵",
|
|
||||||
"roomId": "341793",
|
|
||||||
"scoreState": {
|
|
||||||
"scoreLeft": 2,
|
|
||||||
"scoreRight": 3,
|
|
||||||
"gamesLeft": 0,
|
|
||||||
"gamesRight": 0,
|
|
||||||
"currentGame": 1,
|
|
||||||
"targetScore": 21,
|
|
||||||
"serving": "right",
|
|
||||||
"leftRightCourtPlayer": "playerB",
|
|
||||||
"rightRightCourtPlayer": "playerA"
|
|
||||||
},
|
|
||||||
"status": "finished",
|
|
||||||
"targetDate": "2026-04-13",
|
|
||||||
"updatedAt": "2026-04-19T04:50:56.351Z",
|
|
||||||
"winnerTeamName": "小念 / 建喵"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createdAt": "2026-04-19T04:51:17.794Z",
|
|
||||||
"groupId": 2,
|
|
||||||
"hostToken": "mo5agdyag7fyqyxv",
|
|
||||||
"leftTeamName": "景涵 / 小念",
|
|
||||||
"matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄",
|
|
||||||
"pointLog": [
|
|
||||||
{
|
|
||||||
"round": 0,
|
|
||||||
"starter": 0,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 1,
|
|
||||||
"starter": 0,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rightTeamName": "柏威 / 玟瑄",
|
|
||||||
"roomId": "174740",
|
|
||||||
"scoreState": {
|
|
||||||
"scoreLeft": 1,
|
|
||||||
"scoreRight": 1,
|
|
||||||
"gamesLeft": 0,
|
|
||||||
"gamesRight": 0,
|
|
||||||
"currentGame": 1,
|
|
||||||
"targetScore": 21,
|
|
||||||
"serving": "right",
|
|
||||||
"leftRightCourtPlayer": "playerB",
|
|
||||||
"rightRightCourtPlayer": "playerA"
|
|
||||||
},
|
|
||||||
"status": "finished",
|
|
||||||
"targetDate": "2026-04-13",
|
|
||||||
"updatedAt": "2026-04-19T04:51:25.160Z",
|
|
||||||
"winnerTeamName": "景涵 / 小念"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createdAt": "2026-04-19T04:51:25.190Z",
|
|
||||||
"groupId": 2,
|
|
||||||
"hostToken": "mo5agjnqeabkfpr2",
|
|
||||||
"leftTeamName": "景涵 / 小念",
|
|
||||||
"matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄",
|
|
||||||
"pointLog": [
|
|
||||||
{
|
|
||||||
"round": 0,
|
|
||||||
"starter": 0,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 1,
|
|
||||||
"starter": 0,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"round": 2,
|
|
||||||
"starter": 2,
|
|
||||||
"winCount": 1,
|
|
||||||
"winner": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rightTeamName": "柏威 / 玟瑄",
|
|
||||||
"roomId": "239300",
|
|
||||||
"scoreState": {
|
|
||||||
"scoreLeft": 1,
|
|
||||||
"scoreRight": 2,
|
|
||||||
"gamesLeft": 0,
|
|
||||||
"gamesRight": 0,
|
|
||||||
"currentGame": 1,
|
|
||||||
"targetScore": 21,
|
|
||||||
"serving": "right",
|
|
||||||
"leftRightCourtPlayer": "playerB",
|
|
||||||
"rightRightCourtPlayer": "playerB"
|
|
||||||
},
|
|
||||||
"status": "finished",
|
|
||||||
"targetDate": "2026-04-13",
|
|
||||||
"updatedAt": "2026-04-19T04:52:26.087Z",
|
|
||||||
"winnerTeamName": "柏威 / 玟瑄"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createdAt": "2026-04-19T04:58:15.291Z",
|
|
||||||
"groupId": 1,
|
|
||||||
"hostToken": "mo5apc3foksw0enn",
|
|
||||||
"leftTeamName": "景涵 / RuRu",
|
|
||||||
"matchupLabel": "景涵 / RuRu vs 小念 / 柏威",
|
|
||||||
"pointLog": [
|
|
||||||
{
|
|
||||||
"round": 0,
|
|
||||||
"starter": 0,
|
|
||||||
"winCount": 0,
|
|
||||||
"winner": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rightTeamName": "小念 / 柏威",
|
|
||||||
"roomId": "432277",
|
|
||||||
"scoreState": {
|
|
||||||
"scoreLeft": 1,
|
|
||||||
"scoreRight": 0,
|
|
||||||
"gamesLeft": 0,
|
|
||||||
"gamesRight": 0,
|
|
||||||
"currentGame": 1,
|
|
||||||
"targetScore": 21,
|
|
||||||
"serving": "left",
|
|
||||||
"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-19T05:01:10.731Z",
|
|
||||||
"winnerTeamName": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const matchTableName = process.env.DB_TABLE ?? 'badminton'
|
|||||||
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
|
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
|
||||||
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
|
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
|
||||||
const appStartedAt = new Date().toISOString()
|
const appStartedAt = new Date().toISOString()
|
||||||
|
const LIVE_ROOM_STALE_MS = 30_000
|
||||||
|
|
||||||
const currentFilePath = fileURLToPath(import.meta.url)
|
const currentFilePath = fileURLToPath(import.meta.url)
|
||||||
const currentDir = path.dirname(currentFilePath)
|
const currentDir = path.dirname(currentFilePath)
|
||||||
@@ -77,6 +78,17 @@ app.get('/api/rooms', (_request, response) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/api/rooms/reconcile', (_request, response) => {
|
||||||
|
const removedRoomIds = pruneStaleRooms()
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
removedRoomIds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/rooms/stream', (request, response) => {
|
app.get('/api/rooms/stream', (request, response) => {
|
||||||
setupSse(response)
|
setupSse(response)
|
||||||
roomListClients.add(response)
|
roomListClients.add(response)
|
||||||
@@ -106,6 +118,7 @@ app.post('/api/rooms', (request, response) => {
|
|||||||
clients: new Set(),
|
clients: new Set(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
hostToken,
|
hostToken,
|
||||||
|
hostSeenAt: now,
|
||||||
roomId,
|
roomId,
|
||||||
status: 'live',
|
status: 'live',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -171,6 +184,35 @@ app.post('/api/rooms/:roomId/release', (request, response) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/api/rooms/:roomId/heartbeat', (request, response) => {
|
||||||
|
const room = rooms.get(request.params.roomId)
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
response.status(404).json({
|
||||||
|
ok: false,
|
||||||
|
message: '找不到這個房間。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostToken } = request.body ?? {}
|
||||||
|
|
||||||
|
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
|
||||||
|
response.status(403).json({
|
||||||
|
ok: false,
|
||||||
|
message: '沒有權限更新房間心跳。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room.hostSeenAt = new Date().toISOString()
|
||||||
|
persistRooms()
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/rooms/:roomId', (request, response) => {
|
app.get('/api/rooms/:roomId', (request, response) => {
|
||||||
const room = rooms.get(request.params.roomId)
|
const room = rooms.get(request.params.roomId)
|
||||||
|
|
||||||
@@ -520,6 +562,8 @@ function loadPersistedRooms() {
|
|||||||
|
|
||||||
nextRooms.set(savedRoom.roomId, {
|
nextRooms.set(savedRoom.roomId, {
|
||||||
...savedRoom,
|
...savedRoom,
|
||||||
|
hostSeenAt:
|
||||||
|
typeof savedRoom.hostSeenAt === 'string' ? savedRoom.hostSeenAt : savedRoom.updatedAt,
|
||||||
clients: new Set(),
|
clients: new Set(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -540,6 +584,7 @@ function persistRooms() {
|
|||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
groupId: room.groupId,
|
groupId: room.groupId,
|
||||||
hostToken: room.hostToken,
|
hostToken: room.hostToken,
|
||||||
|
hostSeenAt: room.hostSeenAt,
|
||||||
leftTeamName: room.leftTeamName,
|
leftTeamName: room.leftTeamName,
|
||||||
matchupLabel: room.matchupLabel,
|
matchupLabel: room.matchupLabel,
|
||||||
pointLog: room.pointLog,
|
pointLog: room.pointLog,
|
||||||
@@ -618,6 +663,39 @@ function serializeRoom(room) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneStaleRooms() {
|
||||||
|
const now = Date.now()
|
||||||
|
const removedRoomIds = []
|
||||||
|
|
||||||
|
rooms.forEach((room, roomId) => {
|
||||||
|
if (room.status !== 'live') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostSeenAtTime = Date.parse(room.hostSeenAt ?? '')
|
||||||
|
|
||||||
|
if (!Number.isFinite(hostSeenAtTime) || now - hostSeenAtTime > LIVE_ROOM_STALE_MS) {
|
||||||
|
room.clients.forEach((client) => {
|
||||||
|
sendSse(client, 'room-closed', {
|
||||||
|
roomId: room.roomId,
|
||||||
|
status: 'stale',
|
||||||
|
})
|
||||||
|
client.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
rooms.delete(roomId)
|
||||||
|
removedRoomIds.push(roomId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (removedRoomIds.length > 0) {
|
||||||
|
persistRooms()
|
||||||
|
broadcastRoomList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return removedRoomIds
|
||||||
|
}
|
||||||
|
|
||||||
function getLiveRoomSummaries() {
|
function getLiveRoomSummaries() {
|
||||||
return Array.from(rooms.values())
|
return Array.from(rooms.values())
|
||||||
.filter((room) => room.status === 'live')
|
.filter((room) => room.status === 'live')
|
||||||
|
|||||||
92
src/App.tsx
92
src/App.tsx
@@ -6,6 +6,7 @@ import {
|
|||||||
loadMatchResults,
|
loadMatchResults,
|
||||||
releaseLiveRoom,
|
releaseLiveRoom,
|
||||||
saveMatchHistory,
|
saveMatchHistory,
|
||||||
|
sendLiveRoomHeartbeat,
|
||||||
updateLiveRoom,
|
updateLiveRoom,
|
||||||
} from './lib/api'
|
} from './lib/api'
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +91,7 @@ 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
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -129,6 +131,7 @@ function App() {
|
|||||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||||
|
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||||
const currentAppVersionRef = useRef<string | null>(null)
|
const currentAppVersionRef = useRef<string | null>(null)
|
||||||
const creatingRoomRef = useRef(false)
|
const creatingRoomRef = useRef(false)
|
||||||
const lastSyncedRoomSignatureRef = useRef('')
|
const lastSyncedRoomSignatureRef = useRef('')
|
||||||
@@ -139,6 +142,7 @@ function App() {
|
|||||||
const leftTeam = activeMatchup.leftTeam
|
const leftTeam = activeMatchup.leftTeam
|
||||||
const rightTeam = activeMatchup.rightTeam
|
const rightTeam = activeMatchup.rightTeam
|
||||||
const liveRoomId = liveRoomSession?.roomId ?? null
|
const liveRoomId = liveRoomSession?.roomId ?? null
|
||||||
|
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
||||||
@@ -192,6 +196,18 @@ function App() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [victoryAnnouncement])
|
}, [victoryAnnouncement])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navigationLockMessage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setNavigationLockMessage('')
|
||||||
|
}, 1400)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [navigationLockMessage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePwaUpdateReady = () => {
|
const handlePwaUpdateReady = () => {
|
||||||
setPwaUpdateReady(true)
|
setPwaUpdateReady(true)
|
||||||
@@ -482,6 +498,43 @@ function App() {
|
|||||||
isScoreboardRoute,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!liveRoomSession || liveRoomSession.status !== 'live') {
|
if (!liveRoomSession || liveRoomSession.status !== 'live') {
|
||||||
return
|
return
|
||||||
@@ -822,6 +875,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (!isNavigationLocked || targetPath === '/scoreboard') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
setNavigationLockMessage('比賽進行中,請先完成結算。')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard' : 'app-shell'}>
|
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard' : 'app-shell'}>
|
||||||
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
|
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
|
||||||
@@ -837,16 +899,32 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="topnav" aria-label="主要導覽">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -943,6 +1021,12 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{navigationLockMessage ? (
|
||||||
|
<div className="floating-status-bubble" role="status" aria-live="polite">
|
||||||
|
{navigationLockMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function loadMatchResults(time: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
if (!response.ok || !payload.ok) {
|
||||||
throw new Error(payload.message ?? '無法讀取對戰資料。')
|
throw new Error(payload.message ?? '讀取指定日期分組失敗。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.data ?? null
|
return payload.data ?? null
|
||||||
@@ -46,7 +46,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !result.ok || !result.data) {
|
if (!response.ok || !result.ok || !result.data) {
|
||||||
throw new Error(result.message ?? '無法上傳戰績。')
|
throw new Error(result.message ?? '上傳戰績失敗。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
@@ -61,7 +61,7 @@ export async function loadHistoryList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
return (payload.data ?? []).map(normalizeHistoryRecord)
|
||||||
@@ -98,7 +98,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !result.ok || !result.data) {
|
if (!response.ok || !result.ok || !result.data) {
|
||||||
throw new Error(result.message ?? '建立觀戰房間失敗。')
|
throw new Error(result.message ?? '建立房間失敗。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data
|
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() {
|
export async function loadLiveRoomList() {
|
||||||
const response = await fetch('/api/rooms')
|
const response = await fetch('/api/rooms')
|
||||||
const result = (await readJsonSafely(response)) as {
|
const result = (await readJsonSafely(response)) as {
|
||||||
@@ -178,7 +218,7 @@ export async function loadLiveRoom(roomId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !result.ok || !result.data) {
|
if (!response.ok || !result.ok || !result.data) {
|
||||||
throw new Error(result.message ?? '載入房間內容失敗。')
|
throw new Error(result.message ?? '載入觀戰房間失敗。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
@@ -271,7 +311,7 @@ function parseJson<T>(value: string | null, fallback: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDayLabel(dayOfWeek: number) {
|
function getDayLabel(dayOfWeek: number) {
|
||||||
const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
|
const labels = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||||
return labels[dayOfWeek] ?? '-'
|
return labels[dayOfWeek] ?? '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ if ('serviceWorker' in navigator) {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
void navigator.serviceWorker.register('/sw.js').then((registration) => {
|
void navigator.serviceWorker
|
||||||
|
.register('/sw.js', {
|
||||||
|
updateViaCache: 'none',
|
||||||
|
})
|
||||||
|
.then((registration) => {
|
||||||
if (registration.waiting) {
|
if (registration.waiting) {
|
||||||
notifyUpdateReady()
|
notifyUpdateReady()
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,7 @@ if ('serviceWorker' in navigator) {
|
|||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
trackWorker(registration.installing)
|
trackWorker(registration.installing)
|
||||||
})
|
})
|
||||||
|
void registration.update()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { loadLiveRoomList, subscribeRoomList } from '../lib/api'
|
import { loadLiveRoomList, reconcileLiveRooms, subscribeRoomList } from '../lib/api'
|
||||||
import type { LiveRoomSummary } from '../types'
|
import type { LiveRoomSummary } from '../types'
|
||||||
|
|
||||||
const REFRESH_COOLDOWN_SECONDS = 5
|
const REFRESH_COOLDOWN_SECONDS = 5
|
||||||
@@ -10,23 +10,20 @@ export function RoomListPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [refreshCooldown, setRefreshCooldown] = useState(0)
|
const [refreshCooldown, setRefreshCooldown] = useState(0)
|
||||||
|
const [refreshMessage, setRefreshMessage] = useState('')
|
||||||
const [rooms, setRooms] = useState<LiveRoomSummary[]>([])
|
const [rooms, setRooms] = useState<LiveRoomSummary[]>([])
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
const loadRooms = async (options?: { manual?: boolean }) => {
|
const loadRooms = async () => {
|
||||||
if (loadingRef.current) {
|
if (loadingRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingRef.current = true
|
loadingRef.current = true
|
||||||
|
|
||||||
if (options?.manual) {
|
|
||||||
setRefreshing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextRooms = await loadLiveRoomList()
|
const nextRooms = await loadLiveRoomList()
|
||||||
|
|
||||||
@@ -47,9 +44,6 @@ export function RoomListPage() {
|
|||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (options?.manual) {
|
|
||||||
setRefreshing(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +77,18 @@ export function RoomListPage() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [refreshCooldown])
|
}, [refreshCooldown])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refreshMessage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setRefreshMessage('')
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [refreshMessage])
|
||||||
|
|
||||||
const refreshRoomList = async () => {
|
const refreshRoomList = async () => {
|
||||||
if (refreshCooldown > 0 || loadingRef.current) {
|
if (refreshCooldown > 0 || loadingRef.current) {
|
||||||
return
|
return
|
||||||
@@ -93,9 +99,15 @@ export function RoomListPage() {
|
|||||||
setRefreshCooldown(REFRESH_COOLDOWN_SECONDS)
|
setRefreshCooldown(REFRESH_COOLDOWN_SECONDS)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const removedRoomIds = await reconcileLiveRooms()
|
||||||
const nextRooms = await loadLiveRoomList()
|
const nextRooms = await loadLiveRoomList()
|
||||||
setRooms(nextRooms)
|
setRooms(nextRooms)
|
||||||
setError('')
|
setError('')
|
||||||
|
setRefreshMessage(
|
||||||
|
removedRoomIds.length > 0
|
||||||
|
? `已清掉 ${removedRoomIds.length} 個無主房間。`
|
||||||
|
: '已檢查房間列表,沒有需要清理的房間。',
|
||||||
|
)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。')
|
setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -129,6 +141,7 @@ export function RoomListPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{refreshMessage ? <p className="selection-hint">{refreshMessage}</p> : null}
|
||||||
{loading ? <p>正在載入房間列表...</p> : null}
|
{loading ? <p>正在載入房間列表...</p> : null}
|
||||||
{!loading && error ? <p className="history-empty">{error}</p> : null}
|
{!loading && error ? <p className="history-empty">{error}</p> : null}
|
||||||
{!loading && !error && rooms.length === 0 ? (
|
{!loading && !error && rooms.length === 0 ? (
|
||||||
|
|||||||
@@ -86,10 +86,13 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.status === 'released') {
|
if (payload.status === 'released' || payload.status === 'stale') {
|
||||||
setRoomClosedDialog({
|
setRoomClosedDialog({
|
||||||
title: '房間已關閉',
|
title: '房間已關閉',
|
||||||
message: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。',
|
message:
|
||||||
|
payload.status === 'stale'
|
||||||
|
? '這個房間已經沒有主控在線上,系統已自動清理並結束觀戰。'
|
||||||
|
: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。',
|
||||||
})
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user