From 4b465843a8298f08521106b827e264ab5b5604bf Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 15 Jul 2026 18:50:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 + docs/architecture.md | 10 + package.json | 1 + .../fansibote-fuzhong/adapter.test.ts | 12 +- .../adapters/fansibote-fuzhong/adapter.ts | 22 +- src/main/adapters/registry.ts | 8 +- src/main/adapters/types.ts | 5 +- src/main/index.ts | 85 +++- src/main/monitors/MonitorService.test.ts | 390 ++++++++++++++++++ src/main/monitors/MonitorService.ts | 351 ++++++++++++++++ src/main/monitors/MonitorTaskStore.test.ts | 69 ++++ src/main/monitors/MonitorTaskStore.ts | 111 +++++ src/preload/index.ts | 16 +- src/renderer/src/App.tsx | 245 ++++++++++- src/renderer/src/styles.css | 43 ++ src/shared/contracts.ts | 44 +- 16 files changed, 1389 insertions(+), 29 deletions(-) create mode 100644 src/main/monitors/MonitorService.test.ts create mode 100644 src/main/monitors/MonitorService.ts create mode 100644 src/main/monitors/MonitorTaskStore.test.ts create mode 100644 src/main/monitors/MonitorTaskStore.ts diff --git a/AGENTS.md b/AGENTS.md index 2a41a15..74bfadb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,9 @@ - `src/main/`:Electron 主进程、第三方网络请求、凭证和适配器注册。 - `src/main/settings/`:按球场保存的本机配置与原子写入逻辑,存储位置使用 Electron `userData` 目录。 - `src/main/bookings/`:待支付锁场订单摘要与恢复逻辑;不得持久化支付签名。 +- `src/main/monitors/`:空场监控任务、10 秒轮询调度、变化去重和系统通知;只允许调用无凭证列表接口。 +- 带报名活动(如双打活动)的时段统一视为已订场地;不得计入可订数量,也不得触发空场监控提醒。 +- 空场监控日期可选;未指定日期的任务持续滚动检查主界面支持的未来 7 天数据,不能在跨日时自动失效。 - `src/main/adapters//`:单个球场的接口与预约流程实现。 - `src/preload/`:仅暴露经过白名单审核的类型安全 IPC。 - `src/renderer/`:React UI,不可使用 Node.js API,不可持有第三方密钥。 @@ -19,6 +22,8 @@ ## 命名与扩展规则 +- 桌面应用对外名称统一为 `Tennis Book`;开发模式和打包产物都必须通过 Electron 运行时名称与 `productName` 保持一致。 +- Electron `userData` 目录固定使用稳定存储 ID `tennis-book`,不得从显示名称推导;修改品牌名称不能导致配置、待支付订单或监控任务切换存储目录。 - 球场 ID 使用稳定的 `kebab-case`,适配器目录名必须与 ID 一致。 - IPC channel 使用 `domain:action`,新增 channel 时必须同时更新共享类型和 preload 白名单。 - 新球场先实现 `CourtAdapter`,再注册到适配器注册表;禁止在 React 组件里按球场 ID 写业务分支。 @@ -33,6 +38,7 @@ - 第三方令牌、Cookie、签名密钥不得进入渲染进程、日志或版本库。 - 第三方请求诊断日志只允许在 dev 模式输出;请求头和响应中的访客凭证、支付签名、预支付标识及个人信息必须递归脱敏。 - `PSPLVISITORID` 持久化在 Electron 主进程管理的本机配置中;设置弹窗可通过白名单 IPC 读取原值用于回填,但关闭弹窗后必须清除渲染层状态,其他业务接口不得返回原值。 +- 可订列表等只读接口禁止读取或携带 `PSPLVISITORID`;只有下单、释放等明确需要访客身份的写接口可以从持久化配置加载并注入该请求头。 - 每球场配置以 `courtId` 隔离;标识符在共享模型和持久化文件中使用字符串,实际请求前再按接口契约安全转换。 - 预约人昵称和手机号仅可按产品明确要求存在于当次时段查询的内存结果中;不得写日志、落盘、缓存或用于预约以外的用途。 - 外部链接只能通过主进程白名单打开;页面不得任意导航或创建窗口。 diff --git a/docs/architecture.md b/docs/architecture.md index c7e3d95..8fd809b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,8 +30,12 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb `PSPLVISITORID` 等运行时凭证按 `courtId` 独立存储在 Electron `userData` 目录。范思伯特福中福的场地 ID 固定为适配器私有常量,只在请求边界映射到第三方 `userId` 字段和 `STOREID` 请求头,不经过 IPC,也不在配置界面暴露。设置弹窗通过专用白名单 IPC 读取凭证原值并回填密码框,默认遮挡;关闭弹窗立即清除渲染层中的输入值和设置对象,其他查询及预约接口不返回凭证。 +可订列表刷新链路不读取球场凭证,`CourtAdapter.getAvailability()` 也不接收运行时设置,从类型边界保证列表请求不能携带 `PSPLVISITORID`。只有下单和释放写操作在主进程互斥区内读取最新凭证。 + 配置文件使用临时文件加重命名的方式原子替换。共享层始终用字符串表达外部 ID,避免超出 JavaScript 安全整数范围;具体适配器负责校验接口是否接受该范围并转换为请求格式。 +应用对外显示名称为 `Tennis Book`,但 Electron `userData` 固定为 `appData/tennis-book`。显示名称与持久化路径解耦,避免品牌名称或大小写变化后配置、待支付订单和监控任务看似丢失。 + ## 接入一个新球场 1. 在 `src/main/adapters//` 创建适配器。 @@ -49,3 +53,9 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb 范思伯特福中福的 `SaveVenueAppointmentV2` 只创建待支付订单并锁场,不代表支付成功。支付脚本不经过 IPC、不记录日志、不落盘;待支付订单的非敏感摘要按球场持久化,以便应用重启后仍能恢复“取消释放”入口。POST 发送前先持久化结果待确认记录,网络超时或响应解析失败时保留“尝试释放”入口,避免生成不可恢复的孤儿锁场。 `CrmAutoReleaseVenueAppoint` 使用当前球场配置与空 JSON 请求体释放当前访客的锁场,并不能绑定订单号。发送释放请求前先持久化 `release-uncertain`;若响应超时或进程中断,系统禁止自动重试,用户必须在小程序核实后显式清理本机记录。远端确认释放后先持久化 `release-confirmed` 标记,再删除摘要,避免清理失败后重复调用全局释放接口。支付脚本的过期时间只用于展示,不作为场地锁已经释放的依据。 + +## 空场监控 + +监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的今日或明日,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。 + +任务持久化上一次可订时段 ID 集合,只对新增 ID 通知;同一时段持续可订时不会重复通知,先消失再重新出现则再次通知。暂停和删除会让在途响应失效。提醒同时使用 Electron 系统通知和主进程待消费队列中的应用内提示,渲染器启动较晚或同轮出现多条提醒时也不会覆盖;提醒不自动下单。带 `enrollment` 报名活动的时段属于已订场地,即使上游同时返回可预约标记,也必须从可订统计和监控提醒中排除。 diff --git a/package.json b/package.json index ab5d0c5..0f0a6bb 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "tennis-book", + "productName": "Tennis Book", "version": "0.1.0", "private": true, "description": "Desktop workspace for aggregated tennis court booking", diff --git a/src/main/adapters/fansibote-fuzhong/adapter.test.ts b/src/main/adapters/fansibote-fuzhong/adapter.test.ts index 6c57ffe..302feb6 100644 --- a/src/main/adapters/fansibote-fuzhong/adapter.test.ts +++ b/src/main/adapters/fansibote-fuzhong/adapter.test.ts @@ -53,15 +53,14 @@ describe('范思伯特福中福响应映射', () => { }) vi.stubGlobal('fetch', fetchMock) - await fansiboteFuzhongAdapter.getAvailability(query, { - courtId: query.courtId - }) + await fansiboteFuzhongAdapter.getAvailability(query) expect(fetchMock).toHaveBeenCalledOnce() const request = fetchMock.mock.calls[0]?.[1] as RequestInit expect(request.headers).toEqual(expect.objectContaining({ STOREID: '6038652' })) + expect(request.headers).not.toHaveProperty('PSPLVISITORID') expect(JSON.parse(String(request.body))).toEqual({ dateTime: '2026-07-16', userId: 6038652, @@ -283,6 +282,13 @@ describe('范思伯特福中福响应映射', () => { }, expect.objectContaining({ id: '1784027073124705081' }) ]) + expect(result.slots.map(({ status, unavailableLabel }) => ({ + status, + unavailableLabel + }))).toEqual([ + { status: 'booked', unavailableLabel: '已订' }, + { status: 'booked', unavailableLabel: '已订' } + ]) }) it('接口字段变化时明确失败,不静默丢时段', () => { diff --git a/src/main/adapters/fansibote-fuzhong/adapter.ts b/src/main/adapters/fansibote-fuzhong/adapter.ts index b778bcc..4e98fec 100644 --- a/src/main/adapters/fansibote-fuzhong/adapter.ts +++ b/src/main/adapters/fansibote-fuzhong/adapter.ts @@ -228,19 +228,21 @@ function parseSlot(value: unknown, enrollments: ParsedEnrollment[]): BookingSlot const slotBeginDatetime = slot.beginDatetime const slotEndDatetime = slot.endDatetime - const status: BookingSlot['status'] = canBook - ? 'available' - : slot.status === 1 - ? 'booked' - : 'blocked' - const bookedBy = status === 'booked' && appointment - ? parseBookingContacts(appointment) - : [] const enrollment = enrollments.find((candidate) => candidate.classroomUid === slot.txtClassroomUid && slotBeginDatetime <= candidate.endDatetime && slotEndDatetime >= candidate.beginDatetime )?.activity + const status: BookingSlot['status'] = enrollment + ? 'booked' + : canBook + ? 'available' + : slot.status === 1 + ? 'booked' + : 'blocked' + const bookedBy = status === 'booked' && appointment + ? parseBookingContacts(appointment) + : [] return { id: `${slot.txtClassroomUid}:${slotBeginDatetime}`, @@ -298,13 +300,11 @@ export function buildAvailabilityRequestBody(date: string) { } async function getAvailability( - query: AvailabilityQuery, - settings: CourtRuntimeSettings + query: AvailabilityQuery ): Promise { if (query.courtId !== fansiboteFuzhongCourt.id) { throw new Error('球场标识与适配器不匹配') } - if (settings.courtId !== query.courtId) throw new Error('球场配置与适配器不匹配') if (!DATE_PATTERN.test(query.date)) throw new Error('预约日期格式错误') const response = await fetch(AVAILABILITY_ENDPOINT, { diff --git a/src/main/adapters/registry.ts b/src/main/adapters/registry.ts index f02475a..13d24e4 100644 --- a/src/main/adapters/registry.ts +++ b/src/main/adapters/registry.ts @@ -32,11 +32,7 @@ export class AdapterRegistry { async getAvailability(query: AvailabilityQuery) { const adapter = this.getAdapter(query.courtId) - const settings = await this.settingsStore.get( - adapter.court.id, - adapter.defaultSettings - ) - return adapter.getAvailability(query, settings) + return adapter.getAvailability(query) } async getCourtSettings(courtId: string) { @@ -125,7 +121,7 @@ export class AdapterRegistry { const availability = await adapter.getAvailability({ courtId: parsedInput.courtId, date: parsedInput.date - }, settings) + }) const slot = availability.slots.find((candidate) => candidate.id === parsedInput.slotId) if (!slot) throw new Error('目标时段已不存在,请刷新后重试') if (slot.status !== 'available' && slot.status !== 'limited') { diff --git a/src/main/adapters/types.ts b/src/main/adapters/types.ts index 8981e8f..af4e75c 100644 --- a/src/main/adapters/types.ts +++ b/src/main/adapters/types.ts @@ -25,10 +25,7 @@ export class ProviderMutationError extends Error { export interface CourtAdapter { readonly court: CourtSummary readonly defaultSettings: CourtRuntimeSettings - getAvailability( - query: AvailabilityQuery, - settings: CourtRuntimeSettings - ): Promise + getAvailability(query: AvailabilityQuery): Promise preflightWriteSettings?(settings: CourtRuntimeSettings): void createBooking?( slot: BookingSlot, diff --git a/src/main/index.ts b/src/main/index.ts index 411572c..b722367 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,13 +1,28 @@ import { join } from 'node:path' -import { app, BrowserWindow, ipcMain, shell } from 'electron' +import { mkdirSync } from 'node:fs' +import { app, BrowserWindow, ipcMain, Notification, shell } from 'electron' import { AdapterRegistry } from './adapters/registry' import { CourtSettingsStore } from './settings/CourtSettingsStore' import { PendingBookingStore } from './bookings/PendingBookingStore' +import { MonitorService } from './monitors/MonitorService' +import { MonitorTaskStore } from './monitors/MonitorTaskStore' import { IPC_CHANNELS, type AvailabilityQuery } from '../shared/contracts' +const APPLICATION_NAME = 'Tennis Book' +const USER_DATA_DIRECTORY_NAME = 'tennis-book' +const userDataPath = join(app.getPath('appData'), USER_DATA_DIRECTORY_NAME) + +mkdirSync(userDataPath, { recursive: true }) +app.setPath('userData', userDataPath) +app.setName(APPLICATION_NAME) + +let monitorService: MonitorService | null = null +let monitorServiceStartError: string | null = null +const pendingMonitorAlerts: import('../shared/contracts').MonitorAlert[] = [] + function createWindow() { const mainWindow = new BrowserWindow({ width: 1440, @@ -37,8 +52,22 @@ function createWindow() { } else { void mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + return mainWindow } +const hasSingleInstanceLock = app.requestSingleInstanceLock() + +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() +}) + app.whenReady().then(() => { const settingsStore = new CourtSettingsStore( join(app.getPath('userData'), 'court-settings.json') @@ -47,6 +76,32 @@ app.whenReady().then(() => { join(app.getPath('userData'), 'pending-bookings.json') ) const registry = new AdapterRegistry(settingsStore, pendingBookingStore) + const monitorTaskStore = new MonitorTaskStore( + join(app.getPath('userData'), 'monitor-tasks.json') + ) + monitorService = new MonitorService(registry, monitorTaskStore, (alert) => { + pendingMonitorAlerts.push(alert) + if (pendingMonitorAlerts.length > 100) pendingMonitorAlerts.shift() + const courtName = registry.listCourts().find((court) => court.id === alert.courtId)?.name ?? '球场' + const body = `${alert.targetDate} ${alert.slotLabels.join('、')}` + if (Notification.isSupported()) { + const notification = new Notification({ + title: `${courtName}有空场释放`, + body, + silent: false + }) + notification.on('click', () => { + const window = BrowserWindow.getAllWindows()[0] ?? createWindow() + if (window.isMinimized()) window.restore() + window.show() + window.focus() + }) + notification.show() + } + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(IPC_CHANNELS.monitorAlert) + } + }) ipcMain.handle(IPC_CHANNELS.listCourts, () => registry.listCourts()) ipcMain.handle( @@ -79,12 +134,40 @@ app.whenReady().then(() => { return registry.getPendingBooking(courtId) } ) + ipcMain.handle(IPC_CHANNELS.listMonitorTasks, () => { + if (monitorServiceStartError) throw new Error(monitorServiceStartError) + return monitorService!.listTasks() + }) + ipcMain.handle( + IPC_CHANNELS.createMonitorTask, + (_event, input: unknown) => monitorService!.createTask(input) + ) + ipcMain.handle( + IPC_CHANNELS.setMonitorTaskEnabled, + (_event, taskId: unknown, enabled: unknown) => + monitorService!.setEnabled(taskId, enabled) + ) + ipcMain.handle( + IPC_CHANNELS.deleteMonitorTask, + (_event, taskId: unknown) => monitorService!.deleteTask(taskId) + ) + ipcMain.handle( + IPC_CHANNELS.takeMonitorAlerts, + () => pendingMonitorAlerts.splice(0, pendingMonitorAlerts.length) + ) createWindow() + void monitorService.start().catch((error: unknown) => { + monitorServiceStartError = `空场监控服务启动失败:${error instanceof Error ? error.message : '未知错误'}` + console.error('[tennis-book][monitor-service]', monitorServiceStartError) + }) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) +} + +app.on('before-quit', () => monitorService?.stop()) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() diff --git a/src/main/monitors/MonitorService.test.ts b/src/main/monitors/MonitorService.test.ts new file mode 100644 index 0000000..9ba781c --- /dev/null +++ b/src/main/monitors/MonitorService.test.ts @@ -0,0 +1,390 @@ +import { mkdtemp } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AvailabilityDay } from '../../shared/contracts' +import type { AdapterRegistry } from '../adapters/registry' +import { MonitorService } from './MonitorService' +import { MonitorTaskStore } from './MonitorTaskStore' + +function availability( + status: 'available' | 'booked', + date = '2026-07-16' +): AvailabilityDay { + return { + courtId: 'fansibote-fuzhong', + date, + updatedAt: new Date().toISOString(), + slots: [{ + id: 'slot-08', + courtLabel: '1号风雨场', + startTime: '08:00', + endTime: '08:59', + priceCents: 8000, + status + }] + } +} + +describe('MonitorService', () => { + afterEach(() => vi.useRealTimers()) + + it('每 10 秒检查并只提醒新出现或再次释放的空场', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-service-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + const getAvailability = vi.fn() + .mockResolvedValueOnce(availability('booked')) + .mockResolvedValueOnce(availability('available')) + .mockResolvedValueOnce(availability('available')) + .mockResolvedValueOnce(availability('booked')) + .mockResolvedValueOnce(availability('available')) + const registry = { + getAvailability, + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry + const onAlert = vi.fn() + const service = new MonitorService(registry, store, onAlert) + + await service.runOnceForTest() + expect(onAlert).not.toHaveBeenCalled() + + vi.advanceTimersByTime(10_000) + await service.runOnceForTest() + expect(onAlert).toHaveBeenCalledTimes(1) + expect(onAlert).toHaveBeenLastCalledWith(expect.objectContaining({ + slotLabels: ['1号风雨场 08:00–08:59'] + })) + + vi.advanceTimersByTime(10_000) + await service.runOnceForTest() + expect(onAlert).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(10_000) + await service.runOnceForTest() + vi.advanceTimersByTime(10_000) + await service.runOnceForTest() + expect(onAlert).toHaveBeenCalledTimes(2) + expect(getAvailability).toHaveBeenCalledTimes(5) + }) + + it('时间范围外的空场不提醒', async () => { + vi.useFakeTimers() + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-range-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '09:00', + endTime: '10:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + const registry = { + getAvailability: vi.fn().mockResolvedValue(availability('available')), + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry + const onAlert = vi.fn() + const service = new MonitorService(registry, store, onAlert) + + await service.runOnceForTest() + expect(onAlert).not.toHaveBeenCalled() + }) + + it('上游误标为可订的双打活动不计入空场且不提醒', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-enrollment-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + const activitySlot = availability('available') + activitySlot.slots[0]!.enrollment = { + id: 'activity-1', + title: '【2.5双打】福中福8-9', + startTime: '08:00', + endTime: '09:00', + feeCents: 7000, + enrolledCount: 3, + capacity: 4, + participantNames: ['星', '浩哥'] + } + const onAlert = vi.fn() + const service = new MonitorService({ + getAvailability: vi.fn().mockResolvedValue(activitySlot), + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, onAlert) + + await service.runOnceForTest() + + expect((await service.listTasks())[0]?.availableCount).toBe(0) + expect(onAlert).not.toHaveBeenCalled() + }) + + it('同一球场日期的多个任务共用一次列表请求', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-group-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + const baseTask = { + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + } + await store.add({ id: 'task-1', startTime: '08:00', endTime: '09:00', ...baseTask }) + await store.add({ id: 'task-2', startTime: '08:00', endTime: '10:00', ...baseTask }) + const getAvailability = vi.fn().mockResolvedValue(availability('available')) + const service = new MonitorService({ + getAvailability, + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, vi.fn()) + + await service.runOnceForTest() + expect(getAvailability).toHaveBeenCalledTimes(1) + }) + + it('不限日期任务持续聚合未来七天、跨日滚动且同一空场不重复提醒', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-recurring-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-recurring', + courtId: 'fansibote-fuzhong', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + const getAvailability = vi.fn().mockImplementation(({ date }: { date: string }) => + Promise.resolve(availability('available', date)) + ) + const onAlert = vi.fn() + const service = new MonitorService({ + getAvailability, + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, onAlert) + + await service.runOnceForTest() + + expect(getAvailability.mock.calls.map(([query]) => query.date).sort()).toEqual([ + '2026-07-15', '2026-07-16', '2026-07-17', '2026-07-18', + '2026-07-19', '2026-07-20', '2026-07-21' + ]) + expect(onAlert).toHaveBeenCalledTimes(7) + expect(await service.listTasks()).toEqual([ + expect.objectContaining({ + targetDate: undefined, + enabled: true, + availableCount: 7 + }) + ]) + + vi.advanceTimersByTime(10_000) + await service.runOnceForTest() + expect(onAlert).toHaveBeenCalledTimes(7) + + vi.setSystemTime(new Date('2026-07-16T10:00:00.000Z')) + await service.runOnceForTest() + expect(onAlert).toHaveBeenCalledTimes(8) + expect(onAlert).toHaveBeenLastCalledWith(expect.objectContaining({ + targetDate: '2026-07-22' + })) + }) + + it('不限日期的单日请求失败时继续处理其他日期并保留失败日期去重状态', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-partial-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-recurring', + courtId: 'fansibote-fuzhong', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [ + '2026-07-15:slot-08', + '2026-07-16:slot-08' + ] + }) + const getAvailability = vi.fn().mockImplementation(({ date }: { date: string }) => { + if (date === '2026-07-15') return Promise.reject(new Error('single day failed')) + return Promise.resolve(availability( + date === '2026-07-16' || date === '2026-07-17' ? 'available' : 'booked', + date + )) + }) + const onAlert = vi.fn() + const service = new MonitorService({ + getAvailability, + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, onAlert) + + await service.runOnceForTest() + + expect(onAlert).toHaveBeenCalledOnce() + expect(onAlert).toHaveBeenCalledWith(expect.objectContaining({ + targetDate: '2026-07-17' + })) + expect((await service.listTasks())[0]).toEqual(expect.objectContaining({ + enabled: true, + availableCount: 3, + lastError: '部分日期检查失败:2026-07-15' + })) + expect((await store.list())[0]?.seenAvailableSlotIds).toEqual([ + '2026-07-15:slot-08', + '2026-07-16:slot-08', + '2026-07-17:slot-08' + ]) + }) + + it('暂停任务后丢弃仍在途的列表响应', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-pause-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + let resolveAvailability!: (value: ReturnType) => void + const response = new Promise>((resolve) => { + resolveAvailability = resolve + }) + const onAlert = vi.fn() + const service = new MonitorService({ + getAvailability: vi.fn().mockReturnValue(response), + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, onAlert) + + const checking = service.runOnceForTest() + await service.setEnabled('task-1', false) + resolveAvailability(availability('available')) + await checking + expect(onAlert).not.toHaveBeenCalled() + }) + + it('拒绝创建今日已经结束的时间段', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-expired-')) + const service = new MonitorService({ + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, new MonitorTaskStore(join(directory, 'tasks.json')), vi.fn()) + + await expect(service.createTask({ + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-15', + startTime: '08:00', + endTime: '09:00' + })).rejects.toThrow('参数格式错误') + }) + + it('任务在请求途中到达结束时间时丢弃响应', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-cross-end-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-15', + startTime: '09:00', + endTime: '10:01', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + let resolveAvailability!: (value: ReturnType) => void + const response = new Promise>((resolve) => { + resolveAvailability = resolve + }) + const onAlert = vi.fn() + const service = new MonitorService({ + getAvailability: vi.fn().mockReturnValue(response), + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, onAlert) + + const checking = service.runOnceForTest() + await Promise.resolve() + vi.advanceTimersByTime(120_000) + await service.runOnceForTest() + const released = availability('available') + released.slots[0]!.startTime = '09:00' + released.slots[0]!.endTime = '09:59' + resolveAvailability(released) + await checking + expect(onAlert).not.toHaveBeenCalled() + await expect(store.list()).resolves.toEqual([ + expect.objectContaining({ enabled: false }) + ]) + }) + + it('暂停发生在任务快照读取后时也不发起旧任务检查', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-snapshot-')) + const store = new MonitorTaskStore(join(directory, 'tasks.json')) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + }) + const staleSnapshot = await store.list() + let resolveSnapshot!: (tasks: typeof staleSnapshot) => void + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve + }) + vi.spyOn(store, 'list').mockReturnValueOnce(snapshot) + const getAvailability = vi.fn().mockResolvedValue(availability('available')) + const onAlert = vi.fn() + const service = new MonitorService({ + getAvailability, + listCourts: () => [{ id: 'fansibote-fuzhong' }] + } as unknown as AdapterRegistry, store, onAlert) + + const checking = service.runOnceForTest() + await service.setEnabled('task-1', false) + resolveSnapshot(staleSnapshot) + await checking + expect(getAvailability).not.toHaveBeenCalled() + expect(onAlert).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/monitors/MonitorService.ts b/src/main/monitors/MonitorService.ts new file mode 100644 index 0000000..2fa7ba4 --- /dev/null +++ b/src/main/monitors/MonitorService.ts @@ -0,0 +1,351 @@ +import { randomUUID } from 'node:crypto' +import type { + AvailabilityDay, + CreateMonitorTaskInput, + MonitorAlert, + MonitorTask +} from '../../shared/contracts' +import type { AdapterRegistry } from '../adapters/registry' +import { MonitorTaskStore, type StoredMonitorTask } from './MonitorTaskStore' + +const POLL_INTERVAL_MS = 10_000 +const ROLLING_DATE_COUNT = 7 +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +const TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/ + +interface RuntimeStatus { + lastCheckedAt?: string + lastError?: string + availableCount: number + generation: number + enabled: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function minutes(time: string) { + const [hour, minute] = time.split(':').map(Number) + return hour! * 60 + minute! +} + +function localDateValue(dayOffset = 0) { + const date = new Date() + date.setDate(date.getDate() + dayOffset) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function taskEndTimestamp(task: Pick) { + if (!task.targetDate) return Number.POSITIVE_INFINITY + return new Date(`${task.targetDate}T${task.endTime}:00`).getTime() +} + +function groupKey(task: Pick) { + return `${task.courtId}:${task.targetDate ?? '*'}` +} + +function targetDates(task: Pick) { + return task.targetDate + ? [task.targetDate] + : Array.from({ length: ROLLING_DATE_COUNT }, (_, index) => localDateValue(index)) +} + +function publicTask(task: StoredMonitorTask, runtime?: RuntimeStatus): MonitorTask { + return { + id: task.id, + courtId: task.courtId, + targetDate: task.targetDate, + startTime: task.startTime, + endTime: task.endTime, + enabled: task.enabled, + createdAt: task.createdAt, + lastCheckedAt: runtime?.lastCheckedAt, + lastError: runtime?.lastError, + availableCount: runtime?.availableCount ?? 0 + } +} + +export class MonitorService { + private readonly runtime = new Map() + private readonly groupNextCheckAt = new Map() + private readonly runningGroups = new Set() + private timer: NodeJS.Timeout | null = null + private serviceError: string | null = null + + constructor( + private readonly registry: AdapterRegistry, + private readonly store: MonitorTaskStore, + private readonly onAlert: (alert: MonitorAlert) => void + ) {} + + async start() { + const tasks = await this.store.list() + for (const task of tasks) this.ensureRuntime(task.id, task.enabled) + this.timer = setInterval(() => this.scheduleTick(), 1_000) + this.scheduleTick() + } + + stop() { + if (this.timer) clearInterval(this.timer) + this.timer = null + } + + async listTasks() { + if (this.serviceError) throw new Error(this.serviceError) + const tasks = await this.store.list() + return tasks.map((task) => publicTask(task, this.runtime.get(task.id))) + } + + async createTask(input: unknown) { + const parsed = this.parseCreateInput(input) + this.registry.listCourts().find((court) => court.id === parsed.courtId) ?? + (() => { throw new Error('球场不存在') })() + const task: StoredMonitorTask = { + id: randomUUID(), + ...parsed, + enabled: true, + createdAt: new Date().toISOString(), + seenAvailableSlotIds: [] + } + await this.store.addUnique(task) + this.ensureRuntime(task.id, true) + this.groupNextCheckAt.set(groupKey(task), 0) + this.scheduleTick() + return publicTask(task, this.runtime.get(task.id)) + } + + async setEnabled(taskId: unknown, enabled: unknown) { + if (typeof taskId !== 'string' || typeof enabled !== 'boolean') { + throw new Error('监控任务状态参数错误') + } + const task = await this.store.update(taskId, (current) => { + current.enabled = enabled + if (enabled) current.seenAvailableSlotIds = [] + }) + const runtime = this.ensureRuntime(taskId, enabled) + runtime.generation += 1 + runtime.enabled = enabled + if (enabled) this.groupNextCheckAt.set(groupKey(task), 0) + if (enabled) this.scheduleTick() + return publicTask(task, this.runtime.get(taskId)) + } + + async deleteTask(taskId: unknown) { + if (typeof taskId !== 'string') throw new Error('监控任务标识格式错误') + const runtime = this.runtime.get(taskId) + if (runtime) { + runtime.generation += 1 + runtime.enabled = false + } + await this.store.delete(taskId) + this.runtime.delete(taskId) + } + + async runOnceForTest() { + await this.tick() + } + + private ensureRuntime(taskId: string, enabled = false) { + const current = this.runtime.get(taskId) + const runtime = current ?? { + availableCount: 0, + generation: 0, + enabled + } + this.runtime.set(taskId, runtime) + return runtime + } + + private scheduleTick() { + void this.tick() + .then(() => { + this.serviceError = null + }) + .catch((error: unknown) => { + this.serviceError = `空场监控运行失败:${error instanceof Error ? error.message : '未知错误'}` + console.error('[tennis-book][monitor-service]', this.serviceError) + }) + } + + private async tick() { + const tasks = await this.store.list() + const now = Date.now() + await Promise.all(tasks.map(async (task) => { + const runtime = this.ensureRuntime(task.id, task.enabled) + if (task.enabled && taskEndTimestamp(task) <= now) { + runtime.generation += 1 + runtime.enabled = false + await this.store.update(task.id, (current) => { + current.enabled = false + }) + task.enabled = false + } + })) + + const groups = new Map() + for (const task of tasks.filter((candidate) => + candidate.enabled && this.ensureRuntime(candidate.id, true).enabled + )) { + const key = groupKey(task) + groups.set(key, [...(groups.get(key) ?? []), task]) + } + const availabilityRequests = new Map>() + const getAvailability = (courtId: string, date: string) => { + const key = `${courtId}:${date}` + const current = availabilityRequests.get(key) + if (current) return current + const request = this.registry.getAvailability({ courtId, date }) + availabilityRequests.set(key, request) + return request + } + await Promise.all([...groups.entries()].map(async ([key, groupTasks]) => { + if (this.runningGroups.has(key) || (this.groupNextCheckAt.get(key) ?? 0) > now) return + this.runningGroups.add(key) + this.groupNextCheckAt.set(key, now + POLL_INTERVAL_MS) + const checks = groupTasks.map((task) => { + const runtime = this.ensureRuntime(task.id, task.enabled) + return { task, runtime, generation: runtime.generation } + }).filter(({ runtime }) => runtime.enabled) + if (checks.length === 0) { + this.runningGroups.delete(key) + return + } + try { + const first = groupTasks[0]! + const dateResults = await Promise.all(targetDates(first).map(async (date) => { + try { + return { date, availability: await getAvailability(first.courtId, date) } + } catch (error) { + return { date, error } + } + })) + const availability = dateResults.flatMap((result) => + result.availability ? [result.availability] : [] + ) + const failures = dateResults.filter((result) => result.error !== undefined) + if (availability.length === 0) throw failures[0]?.error ?? new Error('列表请求失败') + await Promise.all(checks.map(({ task, runtime, generation }) => + this.applyAvailability( + task, + runtime, + generation, + availability, + failures.map(({ date }) => date) + ) + )) + } catch (error) { + for (const { task, runtime, generation } of checks) { + if (!this.isCurrent(task.id, runtime, generation)) continue + runtime.lastCheckedAt = new Date().toISOString() + runtime.lastError = error instanceof Error ? error.message : '列表请求失败' + } + } finally { + this.runningGroups.delete(key) + } + })) + } + + private async applyAvailability( + task: StoredMonitorTask, + runtime: RuntimeStatus, + generation: number, + availabilityDays: AvailabilityDay[], + failedDates: string[] = [] + ) { + if (!this.isCurrent(task.id, runtime, generation)) return + const available = availabilityDays.flatMap((availability) => + availability.slots.filter((slot) => + !slot.enrollment && + (slot.status === 'available' || slot.status === 'limited') && + minutes(slot.startTime) >= minutes(task.startTime) && + minutes(slot.endTime) <= minutes(task.endTime) + ).map((slot) => ({ + date: availability.date, + slot, + trackingId: task.targetDate ? slot.id : `${availability.date}:${slot.id}` + })) + ) + const previous = new Set(task.seenAvailableSlotIds) + const newlyAvailable = available.filter(({ trackingId }) => !previous.has(trackingId)) + const preservedFailedIds = task.targetDate + ? [] + : task.seenAvailableSlotIds.filter((id) => + failedDates.some((date) => id.startsWith(`${date}:`)) + ) + const nextIds = [ + ...available.map(({ trackingId }) => trackingId), + ...preservedFailedIds + ].sort() + if ( + nextIds.length !== task.seenAvailableSlotIds.length || + nextIds.some((id) => !previous.has(id)) + ) { + await this.store.update(task.id, (current) => { + current.seenAvailableSlotIds = nextIds + }) + } + if (!this.isCurrent(task.id, runtime, generation)) return + runtime.lastCheckedAt = new Date().toISOString() + runtime.lastError = failedDates.length > 0 + ? `部分日期检查失败:${failedDates.join('、')}` + : undefined + runtime.availableCount = available.length + preservedFailedIds.length + const alertsByDate = new Map() + for (const match of newlyAvailable) { + alertsByDate.set(match.date, [...(alertsByDate.get(match.date) ?? []), match]) + } + for (const [targetDate, matches] of alertsByDate) { + this.onAlert({ + taskId: task.id, + courtId: task.courtId, + targetDate, + startTime: task.startTime, + endTime: task.endTime, + slotLabels: matches.map(({ slot }) => + `${slot.courtLabel} ${slot.startTime}–${slot.endTime}` + ), + occurredAt: new Date().toISOString() + }) + } + } + + private isCurrent(taskId: string, runtime: RuntimeStatus, generation: number) { + return this.runtime.get(taskId) === runtime && + runtime.enabled && + runtime.generation === generation + } + + private parseCreateInput(input: unknown): CreateMonitorTaskInput { + const targetDate = isRecord(input) && input.targetDate === '' + ? undefined + : isRecord(input) + ? input.targetDate + : undefined + if ( + !isRecord(input) || + typeof input.courtId !== 'string' || + typeof input.startTime !== 'string' || + typeof input.endTime !== 'string' || + (targetDate !== undefined && typeof targetDate !== 'string') || + (typeof targetDate === 'string' && !DATE_PATTERN.test(targetDate)) || + !TIME_PATTERN.test(input.startTime) || + !TIME_PATTERN.test(input.endTime) || + (typeof targetDate === 'string' && ![localDateValue(), localDateValue(1)].includes(targetDate)) || + minutes(input.startTime) >= minutes(input.endTime) || + (typeof targetDate === 'string' && + new Date(`${targetDate}T${input.endTime}:00`).getTime() <= Date.now()) + ) { + throw new Error('监控任务参数格式错误') + } + return { + courtId: input.courtId, + ...(typeof targetDate === 'string' ? { targetDate } : {}), + startTime: input.startTime, + endTime: input.endTime + } + } +} diff --git a/src/main/monitors/MonitorTaskStore.test.ts b/src/main/monitors/MonitorTaskStore.test.ts new file mode 100644 index 0000000..485fb71 --- /dev/null +++ b/src/main/monitors/MonitorTaskStore.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { MonitorTaskStore } from './MonitorTaskStore' + +describe('MonitorTaskStore', () => { + it('持久化监控任务和已提醒的空场集合', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-')) + const filePath = join(directory, 'monitor-tasks.json') + const store = new MonitorTaskStore(filePath) + await store.add({ + id: 'task-1', + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: '2026-07-15T10:00:00.000Z', + seenAvailableSlotIds: ['slot-1'] + }) + + const reloaded = new MonitorTaskStore(filePath) + await expect(reloaded.list()).resolves.toEqual([ + expect.objectContaining({ id: 'task-1', seenAvailableSlotIds: ['slot-1'] }) + ]) + }) + + it('并发创建相同监控范围时只持久化一个任务', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-unique-')) + const store = new MonitorTaskStore(join(directory, 'monitor-tasks.json')) + const baseTask = { + courtId: 'fansibote-fuzhong', + targetDate: '2026-07-16', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: '2026-07-15T10:00:00.000Z', + seenAvailableSlotIds: [] + } + const results = await Promise.allSettled([ + store.addUnique({ id: 'task-1', ...baseTask }), + store.addUnique({ id: 'task-2', ...baseTask }) + ]) + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + await expect(store.list()).resolves.toHaveLength(1) + }) + + it('持久化没有指定日期的持续监控任务', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-recurring-')) + const filePath = join(directory, 'monitor-tasks.json') + await new MonitorTaskStore(filePath).add({ + id: 'task-recurring', + courtId: 'fansibote-fuzhong', + startTime: '08:00', + endTime: '09:00', + enabled: true, + createdAt: '2026-07-15T10:00:00.000Z', + seenAvailableSlotIds: [] + }) + + const tasks = await new MonitorTaskStore(filePath).list() + expect(tasks).toEqual([ + expect.objectContaining({ id: 'task-recurring', enabled: true }) + ]) + expect(tasks[0]).not.toHaveProperty('targetDate') + }) +}) diff --git a/src/main/monitors/MonitorTaskStore.ts b/src/main/monitors/MonitorTaskStore.ts new file mode 100644 index 0000000..1c12ccb --- /dev/null +++ b/src/main/monitors/MonitorTaskStore.ts @@ -0,0 +1,111 @@ +import { dirname } from 'node:path' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import type { MonitorTask } from '../../shared/contracts' + +export interface StoredMonitorTask extends Omit { + seenAvailableSlotIds: string[] +} + +interface MonitorFile { + version: 1 + tasks: StoredMonitorTask[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseTask(value: unknown): StoredMonitorTask { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + typeof value.courtId !== 'string' || + (value.targetDate !== undefined && typeof value.targetDate !== 'string') || + typeof value.startTime !== 'string' || + typeof value.endTime !== 'string' || + typeof value.enabled !== 'boolean' || + typeof value.createdAt !== 'string' || + !Array.isArray(value.seenAvailableSlotIds) || + !value.seenAvailableSlotIds.every((id) => typeof id === 'string') + ) { + throw new Error('本机空场监控任务格式错误') + } + return value as unknown as StoredMonitorTask +} + +export class MonitorTaskStore { + private writeQueue: Promise = Promise.resolve() + + constructor(private readonly filePath: string) {} + + async list(): Promise { + return (await this.read()).tasks + } + + async add(task: StoredMonitorTask): Promise { + return this.write((file) => { + file.tasks.push(task) + }) + } + + async addUnique(task: StoredMonitorTask): Promise { + return this.write((file) => { + const duplicate = file.tasks.some((current) => + current.courtId === task.courtId && + current.targetDate === task.targetDate && + current.startTime === task.startTime && + current.endTime === task.endTime + ) + if (duplicate) throw new Error('相同日期范围和时间段的监控任务已存在') + file.tasks.push(task) + }) + } + + async update(taskId: string, update: (task: StoredMonitorTask) => void): Promise { + let updated: StoredMonitorTask | undefined + await this.write((file) => { + const task = file.tasks.find((candidate) => candidate.id === taskId) + if (!task) throw new Error('监控任务不存在') + update(task) + updated = { ...task, seenAvailableSlotIds: [...task.seenAvailableSlotIds] } + }) + return updated! + } + + async delete(taskId: string): Promise { + return this.write((file) => { + const nextTasks = file.tasks.filter((task) => task.id !== taskId) + if (nextTasks.length === file.tasks.length) throw new Error('监控任务不存在') + file.tasks = nextTasks + }) + } + + private async write(update: (file: MonitorFile) => void): Promise { + const operation = this.writeQueue.then(async () => { + const file = await this.read() + update(file) + await mkdir(dirname(this.filePath), { recursive: true }) + const temporaryPath = `${this.filePath}.${process.pid}.tmp` + await writeFile(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600 + }) + await rename(temporaryPath, this.filePath) + }) + this.writeQueue = operation.catch(() => undefined) + return operation + } + + private async read(): Promise { + try { + const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8')) + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.tasks)) { + throw new Error('本机空场监控文件格式错误') + } + return { version: 1, tasks: value.tasks.map(parseTask) } + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') return { version: 1, tasks: [] } + throw error + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 5b994d3..d4e529d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { type AvailabilityQuery, type CancelBookingInput, type CreateBookingInput, + type CreateMonitorTaskInput, type TennisBookApi, type UpdateCourtSettingsInput } from '../shared/contracts' @@ -21,7 +22,20 @@ const api: TennisBookApi = { cancelBooking: (input: CancelBookingInput) => ipcRenderer.invoke(IPC_CHANNELS.cancelBooking, input), getPendingBooking: (courtId: string) => - ipcRenderer.invoke(IPC_CHANNELS.getPendingBooking, courtId) + ipcRenderer.invoke(IPC_CHANNELS.getPendingBooking, courtId), + listMonitorTasks: () => ipcRenderer.invoke(IPC_CHANNELS.listMonitorTasks), + createMonitorTask: (input: CreateMonitorTaskInput) => + ipcRenderer.invoke(IPC_CHANNELS.createMonitorTask, input), + setMonitorTaskEnabled: (taskId: string, enabled: boolean) => + ipcRenderer.invoke(IPC_CHANNELS.setMonitorTaskEnabled, taskId, enabled), + deleteMonitorTask: (taskId: string) => + ipcRenderer.invoke(IPC_CHANNELS.deleteMonitorTask, taskId), + takeMonitorAlerts: () => ipcRenderer.invoke(IPC_CHANNELS.takeMonitorAlerts), + onMonitorAlert: (listener: () => void) => { + const handler = () => listener() + ipcRenderer.on(IPC_CHANNELS.monitorAlert, handler) + return () => ipcRenderer.removeListener(IPC_CHANNELS.monitorAlert, handler) + } } contextBridge.exposeInMainWorld('tennisBook', api) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 29a7dfb..10fc0de 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CalendarDays, + BellRing, ChevronDown, CircleAlert, CircleCheck, @@ -9,6 +10,9 @@ import { Eye, EyeOff, MapPin, + Pause, + Play, + Plus, LockKeyhole, RefreshCw, Search, @@ -16,6 +20,7 @@ import { SlidersHorizontal, Sparkles, SunMedium, + Trash2, UnlockKeyhole, Warehouse, Wifi @@ -26,6 +31,8 @@ import type { CourtSettings, CourtStatus, CourtSummary, + MonitorAlert, + MonitorTask, PendingBooking } from '../../shared/contracts' @@ -66,7 +73,6 @@ function createDateOptions(): DateOption[] { }) } -const dateOptions = createDateOptions() const tennisBookApi = window.tennisBook const statusCopy: Record = { @@ -184,9 +190,10 @@ function SlotCell({ } export function App() { + const [dateOptions, setDateOptions] = useState(createDateOptions) const [courts, setCourts] = useState([]) const [selectedCourtId, setSelectedCourtId] = useState('') - const [selectedDate, setSelectedDate] = useState(dateOptions[0]?.value ?? '') + const [selectedDate, setSelectedDate] = useState(() => createDateOptions()[0]?.value ?? '') const [availability, setAvailability] = useState(null) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) @@ -205,6 +212,14 @@ export function App() { const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false) const [bookingBusy, setBookingBusy] = useState(false) const [bookingError, setBookingError] = useState(null) + const [monitorTasks, setMonitorTasks] = useState([]) + const [monitorDialogOpen, setMonitorDialogOpen] = useState(false) + const [monitorDate, setMonitorDate] = useState('') + const [monitorStartTime, setMonitorStartTime] = useState('08:00') + const [monitorEndTime, setMonitorEndTime] = useState('09:00') + const [monitorBusy, setMonitorBusy] = useState(false) + const [monitorError, setMonitorError] = useState(null) + const [monitorAlerts, setMonitorAlerts] = useState([]) const settingsRequestId = useRef(0) useEffect(() => { @@ -230,6 +245,18 @@ export function App() { } }, []) + useEffect(() => { + const refreshDates = () => { + const nextOptions = createDateOptions() + const nextToday = nextOptions[0]?.value ?? '' + setDateOptions((current) => current[0]?.value === nextToday ? current : nextOptions) + setSelectedDate((current) => current < nextToday ? nextToday : current) + setMonitorDate((current) => current && current < nextToday ? nextToday : current) + } + const timer = window.setInterval(refreshDates, 60_000) + return () => window.clearInterval(timer) + }, []) + useEffect(() => { if (!tennisBookApi || !selectedCourtId || !selectedDate) return let active = true @@ -265,6 +292,104 @@ export function App() { } }, [selectedCourtId]) + const loadMonitorTasks = useCallback(async () => { + if (!tennisBookApi) return + try { + setMonitorTasks(await tennisBookApi.listMonitorTasks()) + } catch (error) { + setMonitorError(getErrorMessage(error, '监控任务读取失败。')) + } + }, []) + + 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}`) + )] + }) + }, []) + + useEffect(() => { + if (!tennisBookApi) return + void loadMonitorTasks() + void takeMonitorAlerts() + const refreshTimer = window.setInterval(() => { + void loadMonitorTasks() + void takeMonitorAlerts() + }, 3_000) + const unsubscribe = tennisBookApi.onMonitorAlert(() => { + void takeMonitorAlerts() + void loadMonitorTasks() + }) + return () => { + window.clearInterval(refreshTimer) + unsubscribe() + } + }, [loadMonitorTasks, takeMonitorAlerts]) + + useEffect(() => { + if (monitorAlerts.length === 0) return + const currentAlert = monitorAlerts[0]! + if (currentAlert.courtId === selectedCourtId && currentAlert.targetDate === selectedDate) { + setRefreshToken((value) => value + 1) + } + const timer = window.setTimeout(() => { + setMonitorAlerts((current) => current.slice(1)) + }, 8_000) + return () => window.clearTimeout(timer) + }, [monitorAlerts, selectedCourtId, selectedDate]) + + const monitorAlert = monitorAlerts[0] ?? null + + const createMonitorTask = useCallback(async (event: React.FormEvent) => { + event.preventDefault() + if (!tennisBookApi || !selectedCourtId) return + setMonitorBusy(true) + setMonitorError(null) + try { + await tennisBookApi.createMonitorTask({ + courtId: selectedCourtId, + ...(monitorDate ? { targetDate: monitorDate } : {}), + startTime: monitorStartTime, + endTime: monitorEndTime + }) + setMonitorDialogOpen(false) + await loadMonitorTasks() + } catch (error) { + setMonitorError(getErrorMessage(error, '监控任务创建失败。')) + } finally { + setMonitorBusy(false) + } + }, [loadMonitorTasks, monitorDate, monitorEndTime, monitorStartTime, selectedCourtId]) + + const toggleMonitorTask = useCallback(async (task: MonitorTask) => { + if (!tennisBookApi) return + setMonitorError(null) + try { + await tennisBookApi.setMonitorTaskEnabled(task.id, !task.enabled) + await loadMonitorTasks() + } catch (error) { + setMonitorError(getErrorMessage(error, '监控任务状态更新失败。')) + } + }, [loadMonitorTasks]) + + const deleteMonitorTask = useCallback(async (taskId: string) => { + if (!tennisBookApi) return + setMonitorError(null) + try { + await tennisBookApi.deleteMonitorTask(taskId) + await loadMonitorTasks() + } catch (error) { + setMonitorError(getErrorMessage(error, '监控任务删除失败。')) + } + }, [loadMonitorTasks]) + const selectedCourt = courts.find((court) => court.id === selectedCourtId) const filteredCourts = useMemo(() => { const keyword = search.trim().toLowerCase() @@ -457,6 +582,66 @@ export function App() { /> ))} +
+
+
+ WATCHLIST + 空场监控 +
+ +
+ {monitorTasks.length === 0 ? ( + + ) : ( +
+ {monitorTasks.map((task) => ( +
+ +
+ {task.targetDate ?? '不限日期'} · {task.startTime}–{task.endTime} + {task.enabled + ? task.lastError + ? '检查失败,正在重试' + : `监控中 · ${task.targetDate ? '当前' : '未来 7 天'} ${task.availableCount} 个空场` + : '已暂停'} +
+ + +
+ ))} +
+ )} + {monitorError && !monitorDialogOpen ? {monitorError} : null} +
R
预约账户凭证仅存储于本机
@@ -632,6 +817,22 @@ export function App() { )} + {monitorAlert ? ( + + ) : null} + {settingsOpen ? (
) : null} + {monitorDialogOpen ? ( +
+
void createMonitorTask(event)}> +
+ +
COURT WATCHER

新建空场监控

+
+

应用将在后台每 10 秒刷新一次无凭证球场列表。不限日期会持续滚动检查未来 7 天,新空场出现时发送系统通知。

+
+ 监控日期 + + {dateOptions.slice(0, 2).map((option) => ( + + ))} +
+
+ + + +
+ {monitorError ?
{monitorError}
: null} +
+ + +
+
+
+ ) : null} + {bookingConfirmOpen && selectedSlot ? (
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 76e53fa..c0799c7 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -169,6 +169,24 @@ button { color: inherit; } .status-online { background: #35a16a; box-shadow: 0 0 0 3px rgba(53,161,106,.1); } .status-login-required { background: #d1a047; } .status-maintenance { background: #a1aba3; } +.monitor-hub { flex: none; margin: 6px 8px 0; padding: 11px; border: 1px solid #d6ded2; border-radius: 14px; background: rgba(255,254,249,.72); box-shadow: 0 6px 18px rgba(31,68,51,.045); } +.monitor-hub-head { display: flex; align-items: center; justify-content: space-between; } +.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-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; } +.monitor-task-list { display: flex; max-height: 154px; margin-top: 8px; overflow-y: auto; flex-direction: column; gap: 6px; } +.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 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; } +.monitor-task > button { display: grid; width: 25px; height: 25px; padding: 0; border: 0; border-radius: 7px; place-items: center; color: #75857c; background: transparent; cursor: pointer; } +.monitor-task > button:hover { color: #285e45; background: #edf3ec; } +.monitor-inline-error { display: block; margin-top: 7px; color: #ad5c4d; font-size: 8px; line-height: 1.4; } .sidebar-footer { display: grid; grid-template-columns: 30px 1fr 14px; align-items: center; gap: 9px; margin: 8px; padding: 13px 12px; border-top: 1px solid #d9dfd4; color: #829087; } .avatar { display: grid; place-items: center; width: 29px; height: 29px; border-radius: 50%; color: var(--tennis); background: var(--green); font-size: 10px; font-weight: 850; } .sidebar-footer div:nth-child(2) { display: flex; flex-direction: column; gap: 2px; } @@ -363,6 +381,31 @@ button { color: inherit; } .credential-input-wrap button:hover:not(:disabled) { color: #225c42; background: #edf3ec; } .credential-input-wrap button:focus-visible { outline: 2px solid #789981; outline-offset: 1px; } .credential-input-wrap button:disabled { cursor: default; opacity: .35; } + +.monitor-dialog { width: min(430px, calc(100vw - 40px)); padding: 24px; border: 1px solid #dce3d9; border-radius: 18px; background: var(--paper); box-shadow: 0 28px 80px rgba(22,58,42,.2); } +.monitor-dialog > p { margin: 16px 0 0; color: #718178; font-size: 10px; line-height: 1.65; } +.monitor-date-options { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; } +.monitor-date-options legend { grid-column: 1 / -1; margin-bottom: 7px; color: #40594d; font-size: 10px; font-weight: 760; } +.monitor-date-options button { display: flex; align-items: center; justify-content: space-between; padding: 11px 12px; border: 1px solid #d5ded3; border-radius: 11px; color: #738279; background: #fafbf7; cursor: pointer; } +.monitor-date-options button span { font-size: 9px; font-weight: 760; } +.monitor-date-options button strong { color: #2f4c3e; font-family: 'Newsreader Variable', serif; font-size: 15px; } +.monitor-date-options button.monitor-date-any { grid-column: 1 / -1; } +.monitor-date-options button.is-selected { border-color: #76977e; color: #174f3a; background: #edf5e9; box-shadow: 0 0 0 3px rgba(37,116,82,.07); } +.monitor-time-fields { display: grid; grid-template-columns: 1fr 20px 1fr; align-items: end; gap: 8px; margin-top: 18px; } +.monitor-time-fields label { display: flex; flex-direction: column; gap: 7px; } +.monitor-time-fields label span { color: #40594d; font-size: 10px; font-weight: 760; } +.monitor-time-fields input { height: 43px; padding: 0 11px; border: 1px solid #ccd6ca; border-radius: 10px; outline: 0; color: var(--ink); background: #fff; font-weight: 700; } +.monitor-time-fields input:focus { border-color: #789981; box-shadow: 0 0 0 3px rgba(37,116,82,.09); } +.monitor-time-fields > i { align-self: center; margin-top: 18px; color: #9aa59e; font-size: 9px; font-style: normal; text-align: center; } +.monitor-alert-toast { position: fixed; z-index: 60; top: 68px; right: 20px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; align-items: center; min-width: 350px; max-width: 480px; gap: 11px; padding: 13px; border: 1px solid #cfe0c9; border-radius: 15px; color: #1f4434; text-align: left; background: rgba(250,255,244,.97); box-shadow: 0 18px 52px rgba(22,69,46,.18); backdrop-filter: blur(16px); cursor: pointer; animation: monitorToastIn .28s ease-out both; } +.monitor-alert-toast > span { display: grid; width: 36px; height: 36px; border-radius: 11px; place-items: center; color: var(--tennis); background: var(--green); } +.monitor-alert-toast > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.monitor-alert-toast strong { font-size: 11px; } +.monitor-alert-toast small { overflow: hidden; color: #698076; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.monitor-alert-toast > i { color: #2b7954; font-size: 9px; font-style: normal; font-weight: 780; } + +@keyframes monitorPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .55; transform: scale(.78); } } +@keyframes monitorToastIn { from { opacity: 0; transform: translateY(-8px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } .settings-loading { display: grid; min-height: 94px; place-items: center; color: #7e8e84; font-size: 10px; } .settings-error { display: flex; align-items: center; gap: 7px; margin-top: 13px; padding: 10px; border: 1px solid #efcfc5; border-radius: 9px; color: #a84f3e; background: var(--coral-soft); font-size: 9px; } .settings-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; padding-top: 16px; border-top: 1px solid #e5e9e1; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index c8fe560..db13422 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -101,6 +101,36 @@ export interface PendingBooking { | 'release-confirmed' } +export interface MonitorTask { + id: string + courtId: string + targetDate?: string + startTime: string + endTime: string + enabled: boolean + createdAt: string + lastCheckedAt?: string + lastError?: string + availableCount: number +} + +export interface CreateMonitorTaskInput { + courtId: string + targetDate?: string + startTime: string + endTime: string +} + +export interface MonitorAlert { + taskId: string + courtId: string + targetDate: string + startTime: string + endTime: string + slotLabels: string[] + occurredAt: string +} + export interface TennisBookApi { listCourts(): Promise getAvailability(query: AvailabilityQuery): Promise @@ -109,6 +139,12 @@ export interface TennisBookApi { createBooking(input: CreateBookingInput): Promise cancelBooking(input: CancelBookingInput): Promise getPendingBooking(courtId: string): Promise + listMonitorTasks(): Promise + createMonitorTask(input: CreateMonitorTaskInput): Promise + setMonitorTaskEnabled(taskId: string, enabled: boolean): Promise + deleteMonitorTask(taskId: string): Promise + takeMonitorAlerts(): Promise + onMonitorAlert(listener: () => void): () => void } export const IPC_CHANNELS = { @@ -118,5 +154,11 @@ export const IPC_CHANNELS = { updateCourtSettings: 'court-settings:update', createBooking: 'bookings:create', cancelBooking: 'bookings:cancel', - getPendingBooking: 'bookings:pending' + getPendingBooking: 'bookings:pending', + listMonitorTasks: 'monitors:list', + createMonitorTask: 'monitors:create', + setMonitorTaskEnabled: 'monitors:set-enabled', + deleteMonitorTask: 'monitors:delete', + takeMonitorAlerts: 'monitors:take-alerts', + monitorAlert: 'monitors:alert' } as const