feat: 支持 docker 菜单栏展示监控任务

This commit is contained in:
richarjiang
2026-07-16 11:03:32 +08:00
parent 25bca0e859
commit 9adc665ef4
12 changed files with 466 additions and 48 deletions

2
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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
}
}

View File

@@ -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) => {
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()
})

View 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:0010: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:0010: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
View 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)
}))
}
}

View 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([])
})
})

View 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)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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,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<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"
>

View File

@@ -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; }

View File

@@ -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