From 9adc665ef4cec4efaeb292cb3c58275f1cabbc80 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 16 Jul 2026 11:03:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20docker=20=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=A0=8F=E5=B1=95=E7=A4=BA=E7=9B=91=E6=8E=A7=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 2 +- package.json | 2 +- src/main/MacMenuBarController.ts | 57 ++++++++++ src/main/index.ts | 117 ++++++++++++++++---- src/main/menuBarModel.test.ts | 75 +++++++++++++ src/main/menuBarModel.ts | 49 ++++++++ src/main/monitors/MonitorAlertInbox.test.ts | 40 +++++++ src/main/monitors/MonitorAlertInbox.ts | 35 ++++++ src/preload/index.ts | 9 ++ src/renderer/src/App.tsx | 101 +++++++++++++---- src/renderer/src/styles.css | 19 +++- src/shared/contracts.ts | 8 +- 12 files changed, 466 insertions(+), 48 deletions(-) create mode 100644 src/main/MacMenuBarController.ts create mode 100644 src/main/menuBarModel.test.ts create mode 100644 src/main/menuBarModel.ts create mode 100644 src/main/monitors/MonitorAlertInbox.test.ts create mode 100644 src/main/monitors/MonitorAlertInbox.ts diff --git a/package-lock.json b/package-lock.json index e868082..448b25b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/react-dom": "18.3.5", "@vitejs/plugin-react": "4.7.0", "electron": "35.7.5", - "electron-builder": "^26.0.12", + "electron-builder": "26.0.12", "electron-vite": "3.1.0", "typescript": "5.9.3", "vite": "6.4.3", diff --git a/package.json b/package.json index 5251e3b..ff31b8b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "@vitejs/plugin-react": "4.7.0", "electron": "35.7.5", "electron-builder": "26.0.12", - "electron-builder": "^26.0.12", "electron-vite": "3.1.0", "typescript": "5.9.3", "vite": "6.4.3", @@ -49,6 +48,7 @@ "mac": { "category": "public.app-category.sports", "icon": "build/icon-1024.png", + "identity": null, "artifactName": "${productName}-${version}-${arch}.${ext}", "target": [ "dmg", diff --git a/src/main/MacMenuBarController.ts b/src/main/MacMenuBarController.ts new file mode 100644 index 0000000..9f5adf5 --- /dev/null +++ b/src/main/MacMenuBarController.ts @@ -0,0 +1,57 @@ +import { Menu, nativeImage, Tray, type MenuItemConstructorOptions } from 'electron' +import type { CourtSummary, MonitorAlert, MonitorTask } from '../shared/contracts' +import { createMonitorMenuBarModel } from './menuBarModel' + +// 36px monochrome PNG, loaded as a 2x macOS template image (logical size 18px). +// Electron 35 returns an empty NativeImage for SVG data URLs, so this must stay rasterized. +const TRAY_ICON_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAACd0lEQVRYw+2Yu2tUURDGf3evRsJqgijG94NNULRQC2202EIbERExqEHFRlBESBEbs6BdCpskCIJ/gjZiJTaKfRDFRpCAWIiCIio+EB/FuUsuJzNzzr3ZG1L4wTR7vpnz7Zw5j7nwHzbSDvj3AOuBncBP4Dvwdz7/xFLgNPApm1iyKaAFNIG+qoQ0gAlDhGWjwOJOihksKSRvH4DtcxXSBdxUJvgG3ABOAodxy3MUuAZMG8JGKFm7XcBrIeA4sDbgWwOGDVFvyoia9IK8AtZF+PUALwgv4UgRMceVzIRqoA/4HCGmbVE11QgEeQhsFfzqwBeB/w7YhKsxqdCDuy92a19hpg4S4IHAeQssy8W+KHBGLTF1j3wBGDNEPcfVzFllfLkXv1vhqYfnkEcczH6vAYeAH0Kwr8oke5U5zgvcpibIL8gD3niapTi0nBPGKqwW+C2JmArEPUrQfQFBawxBicCfkoi9AnGbEXi/IWgcV1sa7gg+sw7KfoF0HRtPA5kaA7ZkWcnjdkxWm0rQBBlLCNdS3u4Bl4FzuDswWNj9SqABRdCAwB0uKNLMUK9CvKoIkmqoDqxCfyFYNquGUoO8WxB0QuDl0Q0cAe5GiBF3GdgXo3+anvHGn6EjAVYAG4HHQuyW5jiELmga90Zq45g3/gQbi4D7Suym5lTHTu0jZor8YGzagV3YTYHZCMTc9rey9Od/++PFqQE7kLe4ettLZ0wD9zosg1O4Hm0zcCmC/xF3t/0KEaUXYxVWqAuZrEhE2wq9qUHvOjphpbqOtqgyp24oM3P9prBwOtc8FlRvn0elXz+SImQBaSZwJbABeAm8B35XlY15xz+FICoPv6eRiwAAAABJRU5ErkJggg==' + +export class MacMenuBarController { + private tray: Tray | null = null + + constructor( + private readonly showWindow: () => void, + private readonly showMonitorTask: (taskId: string) => void + ) {} + + start() { + if (process.platform !== 'darwin' || this.tray) return + const icon = nativeImage.createFromBuffer( + Buffer.from(TRAY_ICON_PNG_BASE64, 'base64'), + { scaleFactor: 2 } + ) + if (icon.isEmpty()) throw new Error('macOS 菜单栏图标加载失败') + icon.setTemplateImage(true) + this.tray = new Tray(icon) + this.tray.setToolTip('Tennis Book 空场监控') + } + + update(tasks: MonitorTask[], courts: CourtSummary[], unreadAlerts: MonitorAlert[]) { + if (!this.tray) return + const model = createMonitorMenuBarModel(tasks, courts, unreadAlerts) + const taskItems: MenuItemConstructorOptions[] = model.tasks.flatMap((task) => [ + { + label: `${task.hasUnreadAlert ? '● ' : ''}${task.label}`, + sublabel: task.status, + click: () => this.showMonitorTask(task.id) + } + ]) + const template: MenuItemConstructorOptions[] = [ + { label: model.summary, enabled: false }, + { label: model.unreadSummary, enabled: false }, + { type: 'separator' }, + ...(taskItems.length > 0 + ? taskItems + : [{ label: '在应用中创建监控任务', click: this.showWindow }]), + { type: 'separator' }, + { label: '打开 Tennis Book', click: this.showWindow }, + { label: '退出 Tennis Book', role: 'quit' } + ] + this.tray.setContextMenu(Menu.buildFromTemplate(template)) + } + + destroy() { + this.tray?.destroy() + this.tray = null + } +} diff --git a/src/main/index.ts b/src/main/index.ts index ddbd3c6..7a2918d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,8 @@ import { CourtSettingsStore } from './settings/CourtSettingsStore' import { PendingBookingStore } from './bookings/PendingBookingStore' import { MonitorService } from './monitors/MonitorService' import { MonitorTaskStore } from './monitors/MonitorTaskStore' +import { MonitorAlertInbox } from './monitors/MonitorAlertInbox' +import { MacMenuBarController } from './MacMenuBarController' import { IPC_CHANNELS, type AvailabilityQuery @@ -23,7 +25,22 @@ let monitorService: MonitorService | null = null let monitorServiceStartError: string | null = null let monitorStopPromise: Promise | null = null let allowQuitAfterMonitorFlush = false -const pendingMonitorAlerts: import('../shared/contracts').MonitorAlert[] = [] +const monitorAlertInbox = new MonitorAlertInbox() +let macMenuBar: MacMenuBarController | null = null + +function showMainWindow(taskId?: string) { + const window = BrowserWindow.getAllWindows()[0] ?? createWindow() + if (window.isMinimized()) window.restore() + window.show() + window.focus() + if (!taskId) return + const sendTask = () => window.webContents.send(IPC_CHANNELS.showMonitorTask, taskId) + if (window.webContents.isLoading()) { + window.webContents.once('did-finish-load', sendTask) + } else { + sendTask() + } +} function createWindow() { const mainWindow = new BrowserWindow({ @@ -63,11 +80,7 @@ if (!hasSingleInstanceLock) { app.quit() } else { app.on('second-instance', () => { - const window = BrowserWindow.getAllWindows()[0] - if (!window) return - if (window.isMinimized()) window.restore() - window.show() - window.focus() + showMainWindow() }) app.whenReady().then(() => { @@ -81,9 +94,33 @@ app.whenReady().then(() => { const monitorTaskStore = new MonitorTaskStore( join(app.getPath('userData'), 'monitor-tasks.json') ) + const refreshMacMenuBar = async () => { + if (!macMenuBar || !monitorService) return + try { + macMenuBar.update( + await monitorService.listTasks(), + registry.listCourts(), + monitorAlertInbox.listUnread() + ) + } catch (error) { + console.error('[tennis-book][menu-bar]', error) + } + } + const updateUnreadIndicators = () => { + if (process.platform === 'darwin') { + const unreadCount = monitorAlertInbox.listUnread().length + app.dock?.setBadge(unreadCount > 0 ? String(unreadCount) : '') + } + void refreshMacMenuBar() + } + macMenuBar = new MacMenuBarController( + () => showMainWindow(), + (taskId) => showMainWindow(taskId) + ) + macMenuBar.start() monitorService = new MonitorService(registry, monitorTaskStore, (alert) => { - pendingMonitorAlerts.push(alert) - if (pendingMonitorAlerts.length > 100) pendingMonitorAlerts.shift() + monitorAlertInbox.add(alert) + updateUnreadIndicators() const courtName = registry.listCourts().find((court) => court.id === alert.courtId)?.name ?? '球场' const body = `${alert.targetDate} ${alert.slotLabels.join('、')}` if (Notification.isSupported()) { @@ -93,10 +130,7 @@ app.whenReady().then(() => { silent: false }) notification.on('click', () => { - const window = BrowserWindow.getAllWindows()[0] ?? createWindow() - if (window.isMinimized()) window.restore() - window.show() - window.focus() + showMainWindow(alert.taskId) }) notification.show() } @@ -146,32 +180,64 @@ app.whenReady().then(() => { ) ipcMain.handle( IPC_CHANNELS.createMonitorTask, - (_event, input: unknown) => monitorService!.createTask(input) + async (_event, input: unknown) => { + const task = await monitorService!.createTask(input) + void refreshMacMenuBar() + return task + } ) ipcMain.handle( IPC_CHANNELS.setMonitorTaskEnabled, - (_event, taskId: unknown, enabled: unknown) => - monitorService!.setEnabled(taskId, enabled) + async (_event, taskId: unknown, enabled: unknown) => { + const task = await monitorService!.setEnabled(taskId, enabled) + void refreshMacMenuBar() + return task + } ) ipcMain.handle( IPC_CHANNELS.setMonitorTaskAutoBooking, - (_event, taskId: unknown, enabled: unknown) => - monitorService!.setAutoBookingEnabled(taskId, enabled) + async (_event, taskId: unknown, enabled: unknown) => { + const task = await monitorService!.setAutoBookingEnabled(taskId, enabled) + void refreshMacMenuBar() + return task + } ) ipcMain.handle( IPC_CHANNELS.deleteMonitorTask, - (_event, taskId: unknown) => monitorService!.deleteTask(taskId) + async (_event, taskId: unknown) => { + await monitorService!.deleteTask(taskId) + if (typeof taskId === 'string') monitorAlertInbox.acknowledge(taskId) + updateUnreadIndicators() + } ) ipcMain.handle( IPC_CHANNELS.takeMonitorAlerts, - () => pendingMonitorAlerts.splice(0, pendingMonitorAlerts.length) + () => monitorAlertInbox.takePending() + ) + ipcMain.handle( + IPC_CHANNELS.listUnreadMonitorAlerts, + () => monitorAlertInbox.listUnread() + ) + ipcMain.handle( + IPC_CHANNELS.acknowledgeMonitorAlerts, + (_event, taskId: unknown) => { + if (taskId !== undefined && typeof taskId !== 'string') { + throw new Error('监控任务标识格式错误') + } + monitorAlertInbox.acknowledge(taskId) + updateUnreadIndicators() + } ) createWindow() - void monitorService.start().catch((error: unknown) => { - monitorServiceStartError = `空场监控服务启动失败:${error instanceof Error ? error.message : '未知错误'}` - console.error('[tennis-book][monitor-service]', monitorServiceStartError) - }) + void monitorService.start() + .then(() => refreshMacMenuBar()) + .catch((error: unknown) => { + monitorServiceStartError = `空场监控服务启动失败:${error instanceof Error ? error.message : '未知错误'}` + console.error('[tennis-book][monitor-service]', monitorServiceStartError) + }) + const menuBarRefreshTimer = setInterval(() => void refreshMacMenuBar(), 10_000) + app.once('before-quit', () => clearInterval(menuBarRefreshTimer)) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) @@ -193,6 +259,11 @@ app.on('before-quit', (event) => { }) }) +app.on('will-quit', () => { + macMenuBar?.destroy() + macMenuBar = null +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) diff --git a/src/main/menuBarModel.test.ts b/src/main/menuBarModel.test.ts new file mode 100644 index 0000000..0d7c6d1 --- /dev/null +++ b/src/main/menuBarModel.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import type { CourtSummary, MonitorAlert, MonitorTask } from '../shared/contracts' +import { createMonitorMenuBarModel } from './menuBarModel' + +const court: CourtSummary = { + id: 'test-court', + name: '测试球场', + shortName: '测试', + accent: '#123456', + status: 'online' +} + +function task(overrides: Partial = {}): MonitorTask { + return { + id: 'task-1', + courtId: court.id, + startTime: '08:00', + endTime: '10:00', + enabled: true, + createdAt: '2026-07-16T00:00:00.000Z', + availableCount: 0, + stats: { + totalChecks: 0, + successfulChecks: 0, + partialFailureChecks: 0, + failedChecks: 0, + alertsSent: 0, + availableObservations: 0 + }, + autoBooking: { enabled: false, status: 'disabled', attemptCount: 0 }, + ...overrides + } +} + +function alert(taskId = 'task-1'): MonitorAlert { + return { + taskId, + courtId: court.id, + targetDate: '2026-07-17', + startTime: '08:00', + endTime: '10:00', + slotLabels: ['1 号场 08:00–10:00'], + occurredAt: '2026-07-16T01:00:00.000Z' + } +} + +describe('createMonitorMenuBarModel', () => { + it('汇总运行任务、未读提醒和空场状态', () => { + const model = createMonitorMenuBarModel( + [task({ availableCount: 2 }), task({ id: 'task-2', enabled: false })], + [court], + [alert()] + ) + + expect(model.summary).toBe('2 个任务 · 1 个运行中') + expect(model.unreadSummary).toBe('1 条空场提醒待查看') + expect(model.tasks[0]).toMatchObject({ + label: '测试球场 · 未来 7 天 · 08:00–10:00', + status: '发现 2 个完整空场', + hasUnreadAlert: true + }) + expect(model.tasks[1]?.status).toBe('已暂停') + }) + + it('优先展示监控异常', () => { + const model = createMonitorMenuBarModel( + [task({ availableCount: 3, lastError: '网络失败' })], + [court], + [] + ) + + expect(model.tasks[0]?.status).toBe('检查异常 · 网络失败') + expect(model.unreadSummary).toBe('暂无未读空场提醒') + }) +}) diff --git a/src/main/menuBarModel.ts b/src/main/menuBarModel.ts new file mode 100644 index 0000000..7b1d666 --- /dev/null +++ b/src/main/menuBarModel.ts @@ -0,0 +1,49 @@ +import type { CourtSummary, MonitorAlert, MonitorTask } from '../shared/contracts' + +export interface MonitorMenuBarTask { + id: string + label: string + status: string + enabled: boolean + hasUnreadAlert: boolean +} + +export interface MonitorMenuBarModel { + summary: string + unreadSummary: string + tasks: MonitorMenuBarTask[] +} + +export function createMonitorMenuBarModel( + tasks: MonitorTask[], + courts: CourtSummary[], + unreadAlerts: MonitorAlert[] +): MonitorMenuBarModel { + const courtNames = new Map(courts.map((court) => [court.id, court.name])) + const unreadTaskIds = new Set(unreadAlerts.map((alert) => alert.taskId)) + const runningCount = tasks.filter((task) => task.enabled).length + + return { + summary: tasks.length === 0 + ? '当前没有监控任务' + : `${tasks.length} 个任务 · ${runningCount} 个运行中`, + unreadSummary: unreadAlerts.length > 0 + ? `${unreadAlerts.length} 条空场提醒待查看` + : '暂无未读空场提醒', + tasks: tasks.map((task) => ({ + id: task.id, + label: `${courtNames.get(task.courtId) ?? '未知球场'} · ${task.targetDate ?? '未来 7 天'} · ${task.startTime}–${task.endTime}`, + status: task.enabled + ? task.lastError + ? `检查异常 · ${task.lastError}` + : task.availableCount > 0 + ? `发现 ${task.availableCount} 个完整空场` + : task.lastCheckedAt + ? '运行中 · 暂无完整空场' + : '运行中 · 等待首次检查' + : '已暂停', + enabled: task.enabled, + hasUnreadAlert: unreadTaskIds.has(task.id) + })) + } +} diff --git a/src/main/monitors/MonitorAlertInbox.test.ts b/src/main/monitors/MonitorAlertInbox.test.ts new file mode 100644 index 0000000..50a075a --- /dev/null +++ b/src/main/monitors/MonitorAlertInbox.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { MonitorAlert } from '../../shared/contracts' +import { MonitorAlertInbox } from './MonitorAlertInbox' + +function alert(index: number, taskId = 'task-1'): MonitorAlert { + return { + taskId, + courtId: 'court-1', + targetDate: '2026-07-17', + startTime: '08:00', + endTime: '09:00', + slotLabels: [`${index} 号场`], + occurredAt: `2026-07-16T01:${String(index % 60).padStart(2, '0')}:00.000Z` + } +} + +describe('MonitorAlertInbox', () => { + it('取走浮层提醒后仍保留未读红点,直到对应任务被查看', () => { + const inbox = new MonitorAlertInbox() + inbox.add(alert(1)) + inbox.add(alert(2, 'task-2')) + + expect(inbox.takePending()).toHaveLength(2) + expect(inbox.takePending()).toEqual([]) + expect(inbox.listUnread()).toHaveLength(2) + + inbox.acknowledge('task-1') + expect(inbox.listUnread().map((item) => item.taskId)).toEqual(['task-2']) + }) + + it('最多保留最近 100 条提醒并支持全部已读', () => { + const inbox = new MonitorAlertInbox() + for (let index = 0; index < 105; index += 1) inbox.add(alert(index)) + + expect(inbox.takePending()).toHaveLength(100) + expect(inbox.listUnread()).toHaveLength(100) + inbox.acknowledge() + expect(inbox.listUnread()).toEqual([]) + }) +}) diff --git a/src/main/monitors/MonitorAlertInbox.ts b/src/main/monitors/MonitorAlertInbox.ts new file mode 100644 index 0000000..2e7211d --- /dev/null +++ b/src/main/monitors/MonitorAlertInbox.ts @@ -0,0 +1,35 @@ +import type { MonitorAlert } from '../../shared/contracts' + +const MAX_ALERTS = 100 + +function cloneAlert(alert: MonitorAlert): MonitorAlert { + return { ...alert, slotLabels: [...alert.slotLabels] } +} + +export class MonitorAlertInbox { + private readonly pending: MonitorAlert[] = [] + private readonly unread: MonitorAlert[] = [] + + add(alert: MonitorAlert) { + this.pending.push(cloneAlert(alert)) + this.unread.push(cloneAlert(alert)) + if (this.pending.length > MAX_ALERTS) this.pending.shift() + if (this.unread.length > MAX_ALERTS) this.unread.shift() + } + + takePending() { + return this.pending.splice(0, this.pending.length).map(cloneAlert) + } + + listUnread() { + return this.unread.map(cloneAlert) + } + + acknowledge(taskId?: string) { + for (let index = this.unread.length - 1; index >= 0; index -= 1) { + if (taskId === undefined || this.unread[index]?.taskId === taskId) { + this.unread.splice(index, 1) + } + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a3451b..7c04185 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,10 +35,19 @@ const api: TennisBookApi = { deleteMonitorTask: (taskId: string) => ipcRenderer.invoke(IPC_CHANNELS.deleteMonitorTask, taskId), takeMonitorAlerts: () => ipcRenderer.invoke(IPC_CHANNELS.takeMonitorAlerts), + listUnreadMonitorAlerts: () => + ipcRenderer.invoke(IPC_CHANNELS.listUnreadMonitorAlerts), + acknowledgeMonitorAlerts: (taskId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.acknowledgeMonitorAlerts, taskId), onMonitorAlert: (listener: () => void) => { const handler = () => listener() ipcRenderer.on(IPC_CHANNELS.monitorAlert, handler) return () => ipcRenderer.removeListener(IPC_CHANNELS.monitorAlert, handler) + }, + onShowMonitorTask: (listener: (taskId: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, taskId: string) => listener(taskId) + ipcRenderer.on(IPC_CHANNELS.showMonitorTask, handler) + return () => ipcRenderer.removeListener(IPC_CHANNELS.showMonitorTask, handler) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3ec44e9..00d70f7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -226,6 +226,7 @@ export function App() { const [monitorBusy, setMonitorBusy] = useState(false) const [monitorError, setMonitorError] = useState(null) const [monitorAlerts, setMonitorAlerts] = useState([]) + const [unreadMonitorAlerts, setUnreadMonitorAlerts] = useState([]) const [monitorDetailTaskId, setMonitorDetailTaskId] = useState(null) const [monitorDetail, setMonitorDetail] = useState(null) const [monitorDetailLoading, setMonitorDetailLoading] = useState(false) @@ -316,35 +317,74 @@ export function App() { const takeMonitorAlerts = useCallback(async () => { if (!tennisBookApi) return - const alerts = await tennisBookApi.takeMonitorAlerts() - if (alerts.length === 0) return - setMonitorAlerts((current) => { - const known = new Set(current.map((alert) => - `${alert.taskId}:${alert.targetDate}:${alert.occurredAt}` - )) - return [...current, ...alerts.filter((alert) => - !known.has(`${alert.taskId}:${alert.targetDate}:${alert.occurredAt}`) - )] - }) + try { + const alerts = await tennisBookApi.takeMonitorAlerts() + if (alerts.length === 0) return + setMonitorAlerts((current) => { + const known = new Set(current.map((alert) => + `${alert.taskId}:${alert.targetDate}:${alert.occurredAt}` + )) + return [...current, ...alerts.filter((alert) => + !known.has(`${alert.taskId}:${alert.targetDate}:${alert.occurredAt}`) + )] + }) + } catch (error) { + setMonitorError(getErrorMessage(error, '空场提醒读取失败。')) + } }, []) + const loadUnreadMonitorAlerts = useCallback(async () => { + if (!tennisBookApi) return + try { + setUnreadMonitorAlerts(await tennisBookApi.listUnreadMonitorAlerts()) + } catch (error) { + setMonitorError(getErrorMessage(error, '未读空场提醒读取失败。')) + } + }, []) + + const refreshMonitorState = useCallback(async () => { + await Promise.all([ + loadMonitorTasks(), + takeMonitorAlerts(), + loadUnreadMonitorAlerts() + ]) + }, [loadMonitorTasks, loadUnreadMonitorAlerts, takeMonitorAlerts]) + + const acknowledgeMonitorTaskAlerts = useCallback(async (taskId: string) => { + if (!tennisBookApi) return + setUnreadMonitorAlerts((current) => current.filter((alert) => alert.taskId !== taskId)) + try { + await tennisBookApi.acknowledgeMonitorAlerts(taskId) + } catch (error) { + setMonitorError(getErrorMessage(error, '空场提醒状态更新失败。')) + void loadUnreadMonitorAlerts() + } + }, [loadUnreadMonitorAlerts]) + + const openMonitorTask = useCallback((taskId: string) => { + setMonitorDetailTaskId(taskId) + void acknowledgeMonitorTaskAlerts(taskId) + }, [acknowledgeMonitorTaskAlerts]) + useEffect(() => { if (!tennisBookApi) return - void loadMonitorTasks() - void takeMonitorAlerts() + void refreshMonitorState() const refreshTimer = window.setInterval(() => { - void loadMonitorTasks() - void takeMonitorAlerts() + void refreshMonitorState() }, 3_000) const unsubscribe = tennisBookApi.onMonitorAlert(() => { - void takeMonitorAlerts() - void loadMonitorTasks() + void refreshMonitorState() }) return () => { window.clearInterval(refreshTimer) unsubscribe() } - }, [loadMonitorTasks, takeMonitorAlerts]) + }, [refreshMonitorState]) + + useEffect(() => { + if (!tennisBookApi) return + return tennisBookApi.onShowMonitorTask(openMonitorTask) + }, [openMonitorTask]) useEffect(() => { if (!tennisBookApi || !monitorDetailTaskId) { @@ -388,6 +428,10 @@ export function App() { }, [monitorAlerts, selectedCourtId, selectedDate]) const monitorAlert = monitorAlerts[0] ?? null + const unreadMonitorTaskIds = useMemo( + () => new Set(unreadMonitorAlerts.map((alert) => alert.taskId)), + [unreadMonitorAlerts] + ) const createMonitorTask = useCallback(async (event: React.FormEvent) => { event.preventDefault() @@ -647,7 +691,16 @@ export function App() {
WATCHLIST - 空场监控 + + 空场监控 + {unreadMonitorAlerts.length > 0 ? ( + {unreadMonitorAlerts.length > 99 ? '99+' : unreadMonitorAlerts.length} + ) : null} +