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",
|
"@types/react-dom": "18.3.5",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"electron": "35.7.5",
|
"electron": "35.7.5",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "26.0.12",
|
||||||
"electron-vite": "3.1.0",
|
"electron-vite": "3.1.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "6.4.3",
|
"vite": "6.4.3",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"electron": "35.7.5",
|
"electron": "35.7.5",
|
||||||
"electron-builder": "26.0.12",
|
"electron-builder": "26.0.12",
|
||||||
"electron-builder": "^26.0.12",
|
|
||||||
"electron-vite": "3.1.0",
|
"electron-vite": "3.1.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "6.4.3",
|
"vite": "6.4.3",
|
||||||
@@ -49,6 +48,7 @@
|
|||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.sports",
|
"category": "public.app-category.sports",
|
||||||
"icon": "build/icon-1024.png",
|
"icon": "build/icon-1024.png",
|
||||||
|
"identity": null,
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
"target": [
|
"target": [
|
||||||
"dmg",
|
"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 { PendingBookingStore } from './bookings/PendingBookingStore'
|
||||||
import { MonitorService } from './monitors/MonitorService'
|
import { MonitorService } from './monitors/MonitorService'
|
||||||
import { MonitorTaskStore } from './monitors/MonitorTaskStore'
|
import { MonitorTaskStore } from './monitors/MonitorTaskStore'
|
||||||
|
import { MonitorAlertInbox } from './monitors/MonitorAlertInbox'
|
||||||
|
import { MacMenuBarController } from './MacMenuBarController'
|
||||||
import {
|
import {
|
||||||
IPC_CHANNELS,
|
IPC_CHANNELS,
|
||||||
type AvailabilityQuery
|
type AvailabilityQuery
|
||||||
@@ -23,7 +25,22 @@ let monitorService: MonitorService | null = null
|
|||||||
let monitorServiceStartError: string | null = null
|
let monitorServiceStartError: string | null = null
|
||||||
let monitorStopPromise: Promise<void> | null = null
|
let monitorStopPromise: Promise<void> | null = null
|
||||||
let allowQuitAfterMonitorFlush = false
|
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() {
|
function createWindow() {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
@@ -63,11 +80,7 @@ if (!hasSingleInstanceLock) {
|
|||||||
app.quit()
|
app.quit()
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
const window = BrowserWindow.getAllWindows()[0]
|
showMainWindow()
|
||||||
if (!window) return
|
|
||||||
if (window.isMinimized()) window.restore()
|
|
||||||
window.show()
|
|
||||||
window.focus()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
@@ -81,9 +94,33 @@ app.whenReady().then(() => {
|
|||||||
const monitorTaskStore = new MonitorTaskStore(
|
const monitorTaskStore = new MonitorTaskStore(
|
||||||
join(app.getPath('userData'), 'monitor-tasks.json')
|
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) => {
|
monitorService = new MonitorService(registry, monitorTaskStore, (alert) => {
|
||||||
pendingMonitorAlerts.push(alert)
|
monitorAlertInbox.add(alert)
|
||||||
if (pendingMonitorAlerts.length > 100) pendingMonitorAlerts.shift()
|
updateUnreadIndicators()
|
||||||
const courtName = registry.listCourts().find((court) => court.id === alert.courtId)?.name ?? '球场'
|
const courtName = registry.listCourts().find((court) => court.id === alert.courtId)?.name ?? '球场'
|
||||||
const body = `${alert.targetDate} ${alert.slotLabels.join('、')}`
|
const body = `${alert.targetDate} ${alert.slotLabels.join('、')}`
|
||||||
if (Notification.isSupported()) {
|
if (Notification.isSupported()) {
|
||||||
@@ -93,10 +130,7 @@ app.whenReady().then(() => {
|
|||||||
silent: false
|
silent: false
|
||||||
})
|
})
|
||||||
notification.on('click', () => {
|
notification.on('click', () => {
|
||||||
const window = BrowserWindow.getAllWindows()[0] ?? createWindow()
|
showMainWindow(alert.taskId)
|
||||||
if (window.isMinimized()) window.restore()
|
|
||||||
window.show()
|
|
||||||
window.focus()
|
|
||||||
})
|
})
|
||||||
notification.show()
|
notification.show()
|
||||||
}
|
}
|
||||||
@@ -146,32 +180,64 @@ app.whenReady().then(() => {
|
|||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.createMonitorTask,
|
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(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.setMonitorTaskEnabled,
|
IPC_CHANNELS.setMonitorTaskEnabled,
|
||||||
(_event, taskId: unknown, enabled: unknown) =>
|
async (_event, taskId: unknown, enabled: unknown) => {
|
||||||
monitorService!.setEnabled(taskId, enabled)
|
const task = await monitorService!.setEnabled(taskId, enabled)
|
||||||
|
void refreshMacMenuBar()
|
||||||
|
return task
|
||||||
|
}
|
||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.setMonitorTaskAutoBooking,
|
IPC_CHANNELS.setMonitorTaskAutoBooking,
|
||||||
(_event, taskId: unknown, enabled: unknown) =>
|
async (_event, taskId: unknown, enabled: unknown) => {
|
||||||
monitorService!.setAutoBookingEnabled(taskId, enabled)
|
const task = await monitorService!.setAutoBookingEnabled(taskId, enabled)
|
||||||
|
void refreshMacMenuBar()
|
||||||
|
return task
|
||||||
|
}
|
||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.deleteMonitorTask,
|
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(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.takeMonitorAlerts,
|
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()
|
createWindow()
|
||||||
void monitorService.start().catch((error: unknown) => {
|
void monitorService.start()
|
||||||
monitorServiceStartError = `空场监控服务启动失败:${error instanceof Error ? error.message : '未知错误'}`
|
.then(() => refreshMacMenuBar())
|
||||||
console.error('[tennis-book][monitor-service]', monitorServiceStartError)
|
.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', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
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', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') app.quit()
|
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) =>
|
deleteMonitorTask: (taskId: string) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.deleteMonitorTask, taskId),
|
ipcRenderer.invoke(IPC_CHANNELS.deleteMonitorTask, taskId),
|
||||||
takeMonitorAlerts: () => ipcRenderer.invoke(IPC_CHANNELS.takeMonitorAlerts),
|
takeMonitorAlerts: () => ipcRenderer.invoke(IPC_CHANNELS.takeMonitorAlerts),
|
||||||
|
listUnreadMonitorAlerts: () =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.listUnreadMonitorAlerts),
|
||||||
|
acknowledgeMonitorAlerts: (taskId?: string) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.acknowledgeMonitorAlerts, taskId),
|
||||||
onMonitorAlert: (listener: () => void) => {
|
onMonitorAlert: (listener: () => void) => {
|
||||||
const handler = () => listener()
|
const handler = () => listener()
|
||||||
ipcRenderer.on(IPC_CHANNELS.monitorAlert, handler)
|
ipcRenderer.on(IPC_CHANNELS.monitorAlert, handler)
|
||||||
return () => ipcRenderer.removeListener(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 [monitorBusy, setMonitorBusy] = useState(false)
|
||||||
const [monitorError, setMonitorError] = useState<string | null>(null)
|
const [monitorError, setMonitorError] = useState<string | null>(null)
|
||||||
const [monitorAlerts, setMonitorAlerts] = useState<MonitorAlert[]>([])
|
const [monitorAlerts, setMonitorAlerts] = useState<MonitorAlert[]>([])
|
||||||
|
const [unreadMonitorAlerts, setUnreadMonitorAlerts] = useState<MonitorAlert[]>([])
|
||||||
const [monitorDetailTaskId, setMonitorDetailTaskId] = useState<string | null>(null)
|
const [monitorDetailTaskId, setMonitorDetailTaskId] = useState<string | null>(null)
|
||||||
const [monitorDetail, setMonitorDetail] = useState<MonitorTaskDetail | null>(null)
|
const [monitorDetail, setMonitorDetail] = useState<MonitorTaskDetail | null>(null)
|
||||||
const [monitorDetailLoading, setMonitorDetailLoading] = useState(false)
|
const [monitorDetailLoading, setMonitorDetailLoading] = useState(false)
|
||||||
@@ -316,35 +317,74 @@ export function App() {
|
|||||||
|
|
||||||
const takeMonitorAlerts = useCallback(async () => {
|
const takeMonitorAlerts = useCallback(async () => {
|
||||||
if (!tennisBookApi) return
|
if (!tennisBookApi) return
|
||||||
const alerts = await tennisBookApi.takeMonitorAlerts()
|
try {
|
||||||
if (alerts.length === 0) return
|
const alerts = await tennisBookApi.takeMonitorAlerts()
|
||||||
setMonitorAlerts((current) => {
|
if (alerts.length === 0) return
|
||||||
const known = new Set(current.map((alert) =>
|
setMonitorAlerts((current) => {
|
||||||
`${alert.taskId}:${alert.targetDate}:${alert.occurredAt}`
|
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}`)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!tennisBookApi) return
|
if (!tennisBookApi) return
|
||||||
void loadMonitorTasks()
|
void refreshMonitorState()
|
||||||
void takeMonitorAlerts()
|
|
||||||
const refreshTimer = window.setInterval(() => {
|
const refreshTimer = window.setInterval(() => {
|
||||||
void loadMonitorTasks()
|
void refreshMonitorState()
|
||||||
void takeMonitorAlerts()
|
|
||||||
}, 3_000)
|
}, 3_000)
|
||||||
const unsubscribe = tennisBookApi.onMonitorAlert(() => {
|
const unsubscribe = tennisBookApi.onMonitorAlert(() => {
|
||||||
void takeMonitorAlerts()
|
void refreshMonitorState()
|
||||||
void loadMonitorTasks()
|
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(refreshTimer)
|
window.clearInterval(refreshTimer)
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
}, [loadMonitorTasks, takeMonitorAlerts])
|
}, [refreshMonitorState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tennisBookApi) return
|
||||||
|
return tennisBookApi.onShowMonitorTask(openMonitorTask)
|
||||||
|
}, [openMonitorTask])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tennisBookApi || !monitorDetailTaskId) {
|
if (!tennisBookApi || !monitorDetailTaskId) {
|
||||||
@@ -388,6 +428,10 @@ export function App() {
|
|||||||
}, [monitorAlerts, selectedCourtId, selectedDate])
|
}, [monitorAlerts, selectedCourtId, selectedDate])
|
||||||
|
|
||||||
const monitorAlert = monitorAlerts[0] ?? null
|
const monitorAlert = monitorAlerts[0] ?? null
|
||||||
|
const unreadMonitorTaskIds = useMemo(
|
||||||
|
() => new Set(unreadMonitorAlerts.map((alert) => alert.taskId)),
|
||||||
|
[unreadMonitorAlerts]
|
||||||
|
)
|
||||||
|
|
||||||
const createMonitorTask = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
const createMonitorTask = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -647,7 +691,16 @@ export function App() {
|
|||||||
<div className="monitor-hub-head">
|
<div className="monitor-hub-head">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">WATCHLIST</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="新建空场监控"
|
aria-label="新建空场监控"
|
||||||
@@ -681,12 +734,12 @@ export function App() {
|
|||||||
aria-label={`查看 ${task.targetDate ?? '不限日期'} ${task.startTime} 到 ${task.endTime} 的监控详情`}
|
aria-label={`查看 ${task.targetDate ?? '不限日期'} ${task.startTime} 到 ${task.endTime} 的监控详情`}
|
||||||
className={`monitor-task ${task.enabled ? 'is-running' : ''}`}
|
className={`monitor-task ${task.enabled ? 'is-running' : ''}`}
|
||||||
key={task.id}
|
key={task.id}
|
||||||
onClick={() => setMonitorDetailTaskId(task.id)}
|
onClick={() => openMonitorTask(task.id)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.target !== event.currentTarget) return
|
if (event.target !== event.currentTarget) return
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setMonitorDetailTaskId(task.id)
|
openMonitorTask(task.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -694,7 +747,12 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<span className="monitor-pulse" />
|
<span className="monitor-pulse" />
|
||||||
<div>
|
<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
|
<small>{task.enabled
|
||||||
? task.lastError
|
? task.lastError
|
||||||
? '检查失败,正在重试'
|
? '检查失败,正在重试'
|
||||||
@@ -924,6 +982,7 @@ export function App() {
|
|||||||
setSelectedCourtId(monitorAlert.courtId)
|
setSelectedCourtId(monitorAlert.courtId)
|
||||||
setSelectedDate(monitorAlert.targetDate)
|
setSelectedDate(monitorAlert.targetDate)
|
||||||
setMonitorAlerts((current) => current.slice(1))
|
setMonitorAlerts((current) => current.slice(1))
|
||||||
|
void acknowledgeMonitorTaskAlerts(monitorAlert.taskId)
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -174,6 +174,22 @@ button { color: inherit; }
|
|||||||
.monitor-hub-head > div { display: flex; flex-direction: column; gap: 4px; }
|
.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 { display: flex; align-items: center; gap: 6px; color: #28483a; font-size: 11px; }
|
||||||
.monitor-hub-head strong svg { color: #2e8b5c; }
|
.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-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 { 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; }
|
.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-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 { 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 > 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-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-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; }
|
.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>
|
setMonitorTaskAutoBooking(taskId: string, enabled: boolean): Promise<MonitorTask>
|
||||||
deleteMonitorTask(taskId: string): Promise<void>
|
deleteMonitorTask(taskId: string): Promise<void>
|
||||||
takeMonitorAlerts(): Promise<MonitorAlert[]>
|
takeMonitorAlerts(): Promise<MonitorAlert[]>
|
||||||
|
listUnreadMonitorAlerts(): Promise<MonitorAlert[]>
|
||||||
|
acknowledgeMonitorAlerts(taskId?: string): Promise<void>
|
||||||
onMonitorAlert(listener: () => void): () => void
|
onMonitorAlert(listener: () => void): () => void
|
||||||
|
onShowMonitorTask(listener: (taskId: string) => void): () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
@@ -226,5 +229,8 @@ export const IPC_CHANNELS = {
|
|||||||
setMonitorTaskAutoBooking: 'monitors:set-auto-booking',
|
setMonitorTaskAutoBooking: 'monitors:set-auto-booking',
|
||||||
deleteMonitorTask: 'monitors:delete',
|
deleteMonitorTask: 'monitors:delete',
|
||||||
takeMonitorAlerts: 'monitors:take-alerts',
|
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
|
} as const
|
||||||
|
|||||||
Reference in New Issue
Block a user