feat: 支持 docker 菜单栏展示监控任务
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
57
src/main/MacMenuBarController.ts
Normal file
57
src/main/MacMenuBarController.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<void> | 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) => {
|
||||
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()
|
||||
})
|
||||
|
||||
75
src/main/menuBarModel.test.ts
Normal file
75
src/main/menuBarModel.test.ts
Normal file
@@ -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> = {}): 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('暂无未读空场提醒')
|
||||
})
|
||||
})
|
||||
49
src/main/menuBarModel.ts
Normal file
49
src/main/menuBarModel.ts
Normal file
@@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
40
src/main/monitors/MonitorAlertInbox.test.ts
Normal file
40
src/main/monitors/MonitorAlertInbox.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
35
src/main/monitors/MonitorAlertInbox.ts
Normal file
35
src/main/monitors/MonitorAlertInbox.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ export function App() {
|
||||
const [monitorBusy, setMonitorBusy] = useState(false)
|
||||
const [monitorError, setMonitorError] = useState<string | null>(null)
|
||||
const [monitorAlerts, setMonitorAlerts] = useState<MonitorAlert[]>([])
|
||||
const [unreadMonitorAlerts, setUnreadMonitorAlerts] = useState<MonitorAlert[]>([])
|
||||
const [monitorDetailTaskId, setMonitorDetailTaskId] = useState<string | null>(null)
|
||||
const [monitorDetail, setMonitorDetail] = useState<MonitorTaskDetail | null>(null)
|
||||
const [monitorDetailLoading, setMonitorDetailLoading] = useState(false)
|
||||
@@ -316,6 +317,7 @@ export function App() {
|
||||
|
||||
const takeMonitorAlerts = useCallback(async () => {
|
||||
if (!tennisBookApi) return
|
||||
try {
|
||||
const alerts = await tennisBookApi.takeMonitorAlerts()
|
||||
if (alerts.length === 0) return
|
||||
setMonitorAlerts((current) => {
|
||||
@@ -326,25 +328,63 @@ export function App() {
|
||||
!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<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
@@ -647,7 +691,16 @@ export function App() {
|
||||
<div className="monitor-hub-head">
|
||||
<div>
|
||||
<span className="eyebrow">WATCHLIST</span>
|
||||
<strong><BellRing size={13} />空场监控</strong>
|
||||
<strong>
|
||||
<BellRing size={13} />空场监控
|
||||
{unreadMonitorAlerts.length > 0 ? (
|
||||
<span
|
||||
aria-label={`${unreadMonitorAlerts.length} 条未读空场提醒`}
|
||||
className="monitor-unread-count"
|
||||
title={`${unreadMonitorAlerts.length} 条未读空场提醒`}
|
||||
>{unreadMonitorAlerts.length > 99 ? '99+' : unreadMonitorAlerts.length}</span>
|
||||
) : null}
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
aria-label="新建空场监控"
|
||||
@@ -681,12 +734,12 @@ export function App() {
|
||||
aria-label={`查看 ${task.targetDate ?? '不限日期'} ${task.startTime} 到 ${task.endTime} 的监控详情`}
|
||||
className={`monitor-task ${task.enabled ? 'is-running' : ''}`}
|
||||
key={task.id}
|
||||
onClick={() => setMonitorDetailTaskId(task.id)}
|
||||
onClick={() => openMonitorTask(task.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setMonitorDetailTaskId(task.id)
|
||||
openMonitorTask(task.id)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
@@ -694,7 +747,12 @@ export function App() {
|
||||
>
|
||||
<span className="monitor-pulse" />
|
||||
<div>
|
||||
<strong>{task.targetDate ?? '不限日期'} · {task.startTime}–{task.endTime}</strong>
|
||||
<strong>
|
||||
{task.targetDate ?? '不限日期'} · {task.startTime}–{task.endTime}
|
||||
{unreadMonitorTaskIds.has(task.id) ? (
|
||||
<i aria-label="有未读空场提醒" className="monitor-task-unread" />
|
||||
) : null}
|
||||
</strong>
|
||||
<small>{task.enabled
|
||||
? task.lastError
|
||||
? '检查失败,正在重试'
|
||||
@@ -924,6 +982,7 @@ export function App() {
|
||||
setSelectedCourtId(monitorAlert.courtId)
|
||||
setSelectedDate(monitorAlert.targetDate)
|
||||
setMonitorAlerts((current) => current.slice(1))
|
||||
void acknowledgeMonitorTaskAlerts(monitorAlert.taskId)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -174,6 +174,22 @@ button { color: inherit; }
|
||||
.monitor-hub-head > div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.monitor-hub-head strong { display: flex; align-items: center; gap: 6px; color: #28483a; font-size: 11px; }
|
||||
.monitor-hub-head strong svg { color: #2e8b5c; }
|
||||
.monitor-unread-count {
|
||||
display: inline-grid;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #fffef9;
|
||||
border-radius: 999px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: #d85345;
|
||||
box-shadow: 0 2px 6px rgba(174, 62, 49, .28);
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
.monitor-hub-head > button { display: grid; width: 27px; height: 27px; padding: 0; border: 1px solid #d9e2d6; border-radius: 8px; place-items: center; color: var(--green); background: #f8fbf5; cursor: pointer; }
|
||||
.monitor-empty { width: 100%; margin-top: 9px; padding: 10px; border: 1px dashed #cfd9cd; border-radius: 9px; color: #607369; background: #f8faf5; font-size: 9px; line-height: 1.5; cursor: pointer; }
|
||||
.monitor-empty span { color: #2e7955; font-weight: 750; }
|
||||
@@ -189,7 +205,8 @@ button { color: inherit; }
|
||||
.monitor-task-stats { display: block; margin-top: 3px; color: #97a49d; font-size: 8px; font-weight: 700; letter-spacing: .02em; }
|
||||
.monitor-task { display: grid; grid-template-columns: 7px minmax(0,1fr) 25px 25px; align-items: center; gap: 6px; padding: 8px 6px; border: 1px solid #e1e6dd; border-radius: 9px; background: #fafbf7; }
|
||||
.monitor-task > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.monitor-task strong { overflow: hidden; color: #365247; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-task strong { display: flex; align-items: center; gap: 5px; overflow: hidden; color: #365247; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-task-unread { flex: none; width: 7px; height: 7px; border: 1px solid #fff; border-radius: 50%; background: #d85345; box-shadow: 0 0 0 2px rgba(216,83,69,.12); }
|
||||
.monitor-task small { overflow: hidden; color: #87958d; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-pulse { width: 6px; height: 6px; border-radius: 50%; background: #aeb8b1; }
|
||||
.monitor-task.is-running .monitor-pulse { background: #35a16a; box-shadow: 0 0 0 3px rgba(53,161,106,.12); animation: monitorPulse 1.8s ease-in-out infinite; }
|
||||
|
||||
@@ -208,7 +208,10 @@ export interface TennisBookApi {
|
||||
setMonitorTaskAutoBooking(taskId: string, enabled: boolean): Promise<MonitorTask>
|
||||
deleteMonitorTask(taskId: string): Promise<void>
|
||||
takeMonitorAlerts(): Promise<MonitorAlert[]>
|
||||
listUnreadMonitorAlerts(): Promise<MonitorAlert[]>
|
||||
acknowledgeMonitorAlerts(taskId?: string): Promise<void>
|
||||
onMonitorAlert(listener: () => void): () => void
|
||||
onShowMonitorTask(listener: (taskId: string) => void): () => void
|
||||
}
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
@@ -226,5 +229,8 @@ export const IPC_CHANNELS = {
|
||||
setMonitorTaskAutoBooking: 'monitors:set-auto-booking',
|
||||
deleteMonitorTask: 'monitors:delete',
|
||||
takeMonitorAlerts: 'monitors:take-alerts',
|
||||
monitorAlert: 'monitors:alert'
|
||||
listUnreadMonitorAlerts: 'monitors:list-unread-alerts',
|
||||
acknowledgeMonitorAlerts: 'monitors:acknowledge-alerts',
|
||||
monitorAlert: 'monitors:alert',
|
||||
showMonitorTask: 'monitors:show-task'
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user