feat: 支持定时监控
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -11,7 +11,7 @@
|
|||||||
- `src/main/`:Electron 主进程、第三方网络请求、凭证和适配器注册。
|
- `src/main/`:Electron 主进程、第三方网络请求、凭证和适配器注册。
|
||||||
- `src/main/settings/`:按球场保存的本机配置与原子写入逻辑,存储位置使用 Electron `userData` 目录。
|
- `src/main/settings/`:按球场保存的本机配置与原子写入逻辑,存储位置使用 Electron `userData` 目录。
|
||||||
- `src/main/bookings/`:待支付锁场订单摘要与恢复逻辑;不得持久化支付签名。
|
- `src/main/bookings/`:待支付锁场订单摘要与恢复逻辑;不得持久化支付签名。
|
||||||
- `src/main/monitors/`:空场监控任务、10 秒轮询调度、整点立即检查、变化去重和系统通知;只允许调用无凭证列表接口。整点检查必须绕过常规轮询冷却,但不得与已有检查并发或在同一整点重复触发。
|
- `src/main/monitors/`:空场监控任务、10 秒轮询调度、整点立即检查、变化去重和系统通知;范思伯特继续使用无凭证列表;大湾区允许由专属适配器在主进程读取登录凭证查询列表。整点检查必须绕过常规轮询冷却,但不得与已有检查并发或在同一整点重复触发。
|
||||||
- 带报名活动(如双打活动)的时段统一视为已订场地;不得计入可订数量,也不得触发空场监控提醒。
|
- 带报名活动(如双打活动)的时段统一视为已订场地;不得计入可订数量,也不得触发空场监控提醒。
|
||||||
- 空场监控日期可选;未指定日期的任务持续滚动检查主界面支持的未来 7 天数据,不能在跨日时自动失效。
|
- 空场监控日期可选;未指定日期的任务持续滚动检查主界面支持的未来 7 天数据,不能在跨日时自动失效。
|
||||||
- 自动预定是监控任务级、用户显式开启的一次性写操作。开启前必须明确告知“只锁场不支付”并校验球场访客凭证;成功后立即转为待支付且禁止再次下单。服务端明确失败只允许重试一次,第二次失败后自动熔断;结果不确定时禁止重试并立即熔断。
|
- 自动预定是监控任务级、用户显式开启的一次性写操作。开启前必须明确告知“只锁场不支付”并校验球场访客凭证;成功后立即转为待支付且禁止再次下单。服务端明确失败只允许重试一次,第二次失败后自动熔断;结果不确定时禁止重试并立即熔断。
|
||||||
@@ -67,6 +67,13 @@
|
|||||||
## 多凭证球场与外部订单处理
|
## 多凭证球场与外部订单处理
|
||||||
|
|
||||||
- 通用登录凭证使用 `credential` 按球场存储,旧球场的 `visitorId` 保持兼容;字段标签由适配器元数据提供。凭证仅允许在设置弹窗短暂回填,关闭立即清除。
|
- 通用登录凭证使用 `credential` 按球场存储,旧球场的 `visitorId` 保持兼容;字段标签由适配器元数据提供。凭证仅允许在设置弹窗短暂回填,关闭立即清除。
|
||||||
- 需要登录的列表通过适配器独立的鉴权查询方法调用;后台监控必须使用无凭证查询路径,此类球场不得创建监控任务。
|
- 需要登录的列表通过适配器独立的鉴权查询方法调用;后台监控按适配器能力选择匿名或主进程鉴权查询;需要鉴权的球场创建或恢复任务前校验已配置凭证。
|
||||||
- 未接入取消或订单查询的球场必须提示用户到原小程序处理。清理本机记录使用独立 IPC 和明确的已处理确认,不得宣称远端取消成功;清理前仍禁止重复下单。
|
- 未接入取消或订单查询的球场必须提示用户到原小程序处理。清理本机记录使用独立 IPC 和明确的已处理确认,不得宣称远端取消成功;清理前仍禁止重复下单。
|
||||||
- `src/main/adapters/dawanqu/` 存放大湾区适配器、契约测试和脱敏 fixture;项目、区域及第三方时段参数只在适配器内处理。新增抓包只提取响应 fixture,不保存请求头或用户凭证。
|
- `src/main/adapters/dawanqu/` 存放大湾区适配器、契约测试和脱敏 fixture;项目、区域及第三方时段参数只在适配器内处理。新增抓包只提取响应 fixture,不保存请求头或用户凭证。
|
||||||
|
|
||||||
|
## 放场优先调度
|
||||||
|
|
||||||
|
- 大湾区每天北京时间 09:00 开放包含当天在内的第三天(当天 +2 日);放场规则属于球场元数据,不在公共调度器中按球场 ID 分支。
|
||||||
|
- 放场首分钟优先检查已授权自动锁场任务的目标日期,再检查其他日期。目标日期一旦返回完整连续组合,立即复用原有下单状态机,不等待其他日期响应。
|
||||||
|
- 整点有独立唤醒;已有检查尚未结束时不得并发重入,完成后立即补查新整点,不能使用整点前的缓存。
|
||||||
|
- “秒杀任务”指用户开启的一次性自动锁场。接口的优惠/秒杀价格属于另一种请求契约;未证实优惠参数时不得猜测请求体或跳过核价。
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
|
|||||||
|
|
||||||
`PSPLVISITORID` 等运行时凭证按 `courtId` 独立存储在 Electron `userData` 目录。范思伯特福中福的场地 ID 固定为适配器私有常量,只在请求边界映射到第三方 `userId` 字段和 `STOREID` 请求头,不经过 IPC,也不在配置界面暴露。设置弹窗通过专用白名单 IPC 读取凭证原值并回填密码框,默认遮挡;关闭弹窗立即清除渲染层中的输入值和设置对象,其他查询及预约接口不返回凭证。
|
`PSPLVISITORID` 等运行时凭证按 `courtId` 独立存储在 Electron `userData` 目录。范思伯特福中福的场地 ID 固定为适配器私有常量,只在请求边界映射到第三方 `userId` 字段和 `STOREID` 请求头,不经过 IPC,也不在配置界面暴露。设置弹窗通过专用白名单 IPC 读取凭证原值并回填密码框,默认遮挡;关闭弹窗立即清除渲染层中的输入值和设置对象,其他查询及预约接口不返回凭证。
|
||||||
|
|
||||||
可订列表刷新链路不读取球场凭证,`CourtAdapter.getAvailability()` 也不接收运行时设置,从类型边界保证列表请求不能携带 `PSPLVISITORID`。只有下单和释放写操作在主进程互斥区内读取最新凭证。
|
匿名可订列表刷新链路不读取球场凭证,`CourtAdapter.getAvailability()` 也不接收运行时设置,从类型边界保证列表请求不能携带 `PSPLVISITORID`。范思伯特只有下单和释放写操作读取凭证;大湾区的列表查询也通过独立鉴权方法读取本机 Authorization。
|
||||||
|
|
||||||
配置文件使用临时文件加重命名的方式原子替换。共享层始终用字符串表达外部 ID,避免超出 JavaScript 安全整数范围;具体适配器负责校验接口是否接受该范围并转换为请求格式。
|
配置文件使用临时文件加重命名的方式原子替换。共享层始终用字符串表达外部 ID,避免超出 JavaScript 安全整数范围;具体适配器负责校验接口是否接受该范围并转换为请求格式。
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
|
|||||||
|
|
||||||
## 空场监控
|
## 空场监控
|
||||||
|
|
||||||
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的今日或明日,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;本地时间进入新的整点时绕过剩余轮询冷却立即检查一次,以捕获整点释放的新场地。同一整点只触发一次,并复用现有串行调度,不与尚未结束的检查并发。应用启动和休眠后恢复也会立即调度一次。今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。
|
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的未来 7 天之一,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;本地时间进入新的整点时绕过剩余轮询冷却立即检查一次,以捕获整点释放的新场地。同一整点只触发一次,并复用现有串行调度,不与尚未结束的检查并发。应用启动和休眠后恢复也会立即调度一次。今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。
|
||||||
|
|
||||||
任务持久化上一次可订时段 ID 集合,只对新增 ID 通知;同一时段持续可订时不会重复通知,先消失再重新出现则再次通知。暂停和删除会让在途响应失效。提醒同时使用 Electron 系统通知和主进程待消费队列中的应用内提示,渲染器启动较晚或同轮出现多条提醒时也不会覆盖;默认只提醒,只有任务被用户显式授权自动预定时才会下单。带 `enrollment` 报名活动的时段属于已订场地,即使上游同时返回可预约标记,也必须从可订统计和监控提醒中排除。
|
任务持久化上一次可订时段 ID 集合,只对新增 ID 通知;同一时段持续可订时不会重复通知,先消失再重新出现则再次通知。暂停和删除会让在途响应失效。提醒同时使用 Electron 系统通知和主进程待消费队列中的应用内提示,渲染器启动较晚或同轮出现多条提醒时也不会覆盖;默认只提醒,只有任务被用户显式授权自动预定时才会下单。带 `enrollment` 报名活动的时段属于已订场地,即使上游同时返回可预约标记,也必须从可订统计和监控提醒中排除。
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
|
|||||||
|
|
||||||
## 大湾区网球场
|
## 大湾区网球场
|
||||||
|
|
||||||
`dawanqu` 使用华润文体室内网球场矩阵接口。2026-09-23 实测无凭证列表返回业务码 403,因此使用主进程鉴权查询,不开放只允许匿名请求的后台监控。`credential` 保存 Authorization 值,适配器规范为 `Wechat <token>`;不内置抓包凭证。
|
`dawanqu` 使用华润文体室内网球场矩阵接口。2026-09-23 实测无凭证列表返回业务码 403,因此手动查询和后台监控均使用主进程鉴权查询,凭证不进入任务或日志。`credential` 保存 Authorization 值,适配器规范为 `Wechat <token>`;不内置抓包凭证。
|
||||||
|
|
||||||
先重查列表,再调用 `reserve/price` 核对金额,最后调用 `reserve` 提交完整连续组合。场地与时段联合构成唯一标识,物理场地用通用 `physicalCourtId` 表达。价格或格式变化时阻止提交。提交后的超时、5xx、解析异常均按不确定处理,不自动重试。订单金额取核价结果,订单号取 `orderBusUuid`;不推测支付到期时间。
|
先重查列表,再调用 `reserve/price` 核对金额,最后调用 `reserve` 提交完整连续组合。场地与时段联合构成唯一标识,物理场地用通用 `physicalCourtId` 表达。价格或格式变化时阻止提交。提交后的超时、5xx、解析异常均按不确定处理,不自动重试。订单金额取核价结果,订单号取 `orderBusUuid`;不推测支付到期时间。
|
||||||
|
|
||||||
@@ -81,3 +81,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
|
|||||||
以 `isAbleReserve` 区分可订与不可订;`msg` 是原因提示,秒杀/优惠字段描述预约方式,不能覆盖已售状态。已开始时段显示“已开始”;不可订且无其他原因的优惠时段仍显示“已售”。可订但当前适配器未覆盖优惠/原时段转换流程时,保留可订状态,通过通用 `bookingUnavailableReason` 禁止应用内提交并提示在小程序预约。
|
以 `isAbleReserve` 区分可订与不可订;`msg` 是原因提示,秒杀/优惠字段描述预约方式,不能覆盖已售状态。已开始时段显示“已开始”;不可订且无其他原因的优惠时段仍显示“已售”。可订但当前适配器未覆盖优惠/原时段转换流程时,保留可订状态,通过通用 `bookingUnavailableReason` 禁止应用内提交并提示在小程序预约。
|
||||||
|
|
||||||
矩阵接口业务码 `2000` 配合“当前最多可提前 N 天预定场地”表示未进入开放窗口,返回空时段及统一 `notice`,不是网络失败;只识别该明确响应,不把其他 `2000` 错误吞成空列表。开放窗口以服务端当次结果为准,不在客户端写死日期或推测放场时间。
|
矩阵接口业务码 `2000` 配合“当前最多可提前 N 天预定场地”表示未进入开放窗口,返回空时段及统一 `notice`,不是网络失败;只识别该明确响应,不把其他 `2000` 错误吞成空列表。开放窗口以服务端当次结果为准,不在客户端写死日期或推测放场时间。
|
||||||
|
|
||||||
|
### 放场监控
|
||||||
|
|
||||||
|
大湾区支持与已有球场相同的提醒、一次性自动锁场、完整连续时段复核、待支付互斥和不确定结果熔断。任务可选择未来 7 天中的具体日期,或持续滚动。每天北京时间 09:00 的首分钟优先检查当天 +2 日,先服务已启用且自动预定处于 armed 的任务;命中后不等其他日期完成就锁场。此后继续原有 10 秒轮询。独立整点唤醒与串行补查避免冷却漏检,仍不与正在执行的检查重叠;网络、系统休眠和登录有效性影响实际时延。
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function runtime() {
|
|||||||
}
|
}
|
||||||
const input = () => ({ ...query, slotId: slots()[0]!.id, expectedPriceCents: 12000 })
|
const input = () => ({ ...query, slotId: slots()[0]!.id, expectedPriceCents: 12000 })
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
|
||||||
|
|
||||||
describe('大湾区列表与请求边界', () => {
|
describe('大湾区列表与请求边界', () => {
|
||||||
it('解析原始矩阵并以场地和时段联合去重', () => {
|
it('解析原始矩阵并以场地和时段联合去重', () => {
|
||||||
@@ -197,13 +197,43 @@ describe('大湾区核价、锁场与本机恢复', () => {
|
|||||||
expect(await registry.getPendingBooking(query.courtId)).not.toBeNull()
|
expect(await registry.getPendingBooking(query.courtId)).not.toBeNull()
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||||
})
|
})
|
||||||
it('拒绝新建需要凭证的后台监控', async () => {
|
it('大湾区监控和自动预定使用 Authorization,并拒绝缺失凭证', async () => {
|
||||||
const { registry } = await runtime()
|
const { registry, store: settingsStore } = await runtime()
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-monitor-'))
|
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-monitor-'))
|
||||||
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
|
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
|
||||||
const service = new MonitorService(registry, store, vi.fn())
|
const service = new MonitorService(registry, store, vi.fn(), false)
|
||||||
await expect(service.createTask({ courtId: query.courtId, startTime: '09:00', endTime: '10:00' })).rejects.toThrow('无凭证空场监控')
|
const task = await service.createTask({ courtId: query.courtId, startTime: '09:00', endTime: '10:00', autoBookingEnabled: true })
|
||||||
expect(await store.list()).toEqual([])
|
expect(task.autoBooking.status).toBe('armed')
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
await settingsStore.update({ courtId: query.courtId })
|
||||||
|
await expect(service.createTask({ courtId: query.courtId, startTime: '11:00', endTime: '12:00' })).rejects.toThrow('Authorization')
|
||||||
|
await service.setEnabled(task.id, false)
|
||||||
|
await expect(service.setEnabled(task.id, true)).rejects.toThrow('Authorization')
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
expect(await store.list()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('监控通过真实注册表鉴权查询、重查核价并只锁一次场(全部请求为 mock)', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-23T11:00:00Z'))
|
||||||
|
const { registry } = await runtime()
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-monitor-booking-'))
|
||||||
|
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
|
||||||
|
const service = new MonitorService(registry, store, vi.fn(), false)
|
||||||
|
const task = await service.createTask({ courtId: query.courtId, targetDate: query.date, startTime: '09:00', endTime: '10:00', autoBookingEnabled: true })
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(success(fixture.result))
|
||||||
|
.mockResolvedValueOnce(success(fixture.result))
|
||||||
|
.mockResolvedValueOnce(quote())
|
||||||
|
.mockResolvedValueOnce(order())
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
await service.runOnceForTest()
|
||||||
|
expect(fetchMock.mock.calls.map(([url]) => url.split('/').at(-1))).toEqual(['matrix', 'matrix', 'price', 'reserve'])
|
||||||
|
for (const [, request] of fetchMock.mock.calls) expect(request.headers.Authorization).toBe('Wechat fixture.token.signature')
|
||||||
|
expect((await store.list()).find((candidate) => candidate.id === task.id)?.autoBooking.status).toBe('pending-payment')
|
||||||
|
expect((await registry.getPendingBooking(query.courtId))?.status).toBe('pending-payment')
|
||||||
|
await service.runOnceForTest()
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||||
})
|
})
|
||||||
it('新凭证按球场持久化,旧球场 visitorId 保持兼容', async () => {
|
it('新凭证按球场持久化,旧球场 visitorId 保持兼容', async () => {
|
||||||
const { registry, store } = await runtime()
|
const { registry, store } = await runtime()
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export const dawanquAdapter: CourtAdapter = {
|
|||||||
id: COURT_ID, name: '大湾区网球场', shortName: '大湾区', indoor: true,
|
id: COURT_ID, name: '大湾区网球场', shortName: '大湾区', indoor: true,
|
||||||
accent: '#4776d0', status: 'online', credentialLabel: 'Authorization',
|
accent: '#4776d0', status: 'online', credentialLabel: 'Authorization',
|
||||||
requiresAuthenticatedAvailability: true, supportsCancellation: false,
|
requiresAuthenticatedAvailability: true, supportsCancellation: false,
|
||||||
|
releaseSchedule: { time: '09:00', dayOffset: 2, timeZone: 'Asia/Shanghai' },
|
||||||
bookingNotice: '只锁场不支付。支付、取消和订单状态请到原小程序查看;本应用暂不支持远端取消。'
|
bookingNotice: '只锁场不支付。支付、取消和订单状态请到原小程序查看;本应用暂不支持远端取消。'
|
||||||
},
|
},
|
||||||
defaultSettings: { courtId: COURT_ID },
|
defaultSettings: { courtId: COURT_ID },
|
||||||
|
|||||||
147
src/main/monitors/MonitorRelease.test.ts
Normal file
147
src/main/monitors/MonitorRelease.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { mkdtemp } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AvailabilityDay, AvailabilityQuery, PendingBooking } from '../../shared/contracts'
|
||||||
|
import type { AdapterRegistry } from '../adapters/registry'
|
||||||
|
import { dawanquAdapter } from '../adapters/dawanqu/adapter'
|
||||||
|
import { MonitorService } from './MonitorService'
|
||||||
|
import { createAutoBookingState, MonitorTaskStore } from './MonitorTaskStore'
|
||||||
|
|
||||||
|
const releasedDate = '2026-09-26'
|
||||||
|
function availability(date: string, available = false): AvailabilityDay {
|
||||||
|
return {
|
||||||
|
courtId: 'dawanqu', date, updatedAt: new Date().toISOString(),
|
||||||
|
slots: available ? [{ id: `${date}:field:09`, courtLabel: '网球1号场', startTime: '09:00', endTime: '10:00', priceCents: 12000, status: 'available' }] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void
|
||||||
|
const promise = new Promise<void>((done) => { resolve = done })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
async function setup(getAvailability: (query: AvailabilityQuery) => Promise<AvailabilityDay>, count = 1) {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'tennis-release-test-'))
|
||||||
|
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await store.add({
|
||||||
|
id: `task-${i}`, courtId: 'dawanqu', startTime: '09:00', endTime: '10:00', enabled: true,
|
||||||
|
createdAt: new Date().toISOString(), seenAvailableSlotIds: [], autoBooking: createAutoBookingState(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let pending: PendingBooking | null = null
|
||||||
|
const registry = {
|
||||||
|
listCourts: () => [dawanquAdapter.court],
|
||||||
|
getAvailability: vi.fn(getAvailability),
|
||||||
|
getCourtSettings: vi.fn(async () => ({ credentialConfigured: true, visitorIdConfigured: false })),
|
||||||
|
getPendingBooking: vi.fn(async () => pending),
|
||||||
|
createBooking: vi.fn(async () => {
|
||||||
|
pending = { courtId: 'dawanqu', orderId: 'fixture-order', venueAppointmentIds: [], amountCents: 12000, courtLabel: '网球1号场', startTime: '09:00', endTime: '10:00', status: 'pending-payment' }
|
||||||
|
return pending
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { registry, store, service: new MonitorService(registry as unknown as AdapterRegistry, store, vi.fn()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
describe('大湾区九点优先锁场', () => {
|
||||||
|
it('先查询第三天并完成锁场,不等待其他六个日期的慢响应', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T01:00:00Z'))
|
||||||
|
const slow = deferred()
|
||||||
|
const { registry, service, store } = await setup(async ({ date }) => {
|
||||||
|
if (date !== releasedDate) await slow.promise
|
||||||
|
return availability(date, date === releasedDate)
|
||||||
|
})
|
||||||
|
const tick = service.runOnceForTest()
|
||||||
|
await vi.waitFor(() => expect(registry.createBooking).toHaveBeenCalledOnce())
|
||||||
|
expect(registry.getAvailability.mock.calls[0]![0].date).toBe(releasedDate)
|
||||||
|
expect(registry.createBooking).toHaveBeenCalledWith(expect.objectContaining({ date: releasedDate, expectedStartTime: '09:00', expectedEndTime: '10:00', expectedPriceCents: 12000 }))
|
||||||
|
slow.resolve()
|
||||||
|
await tick
|
||||||
|
expect(registry.getAvailability.mock.calls.filter(([q]) => q.date === releasedDate)).toHaveLength(1)
|
||||||
|
expect((await store.list())[0]!.autoBooking.status).toBe('pending-payment')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('绕过整点前冷却,多个任务共用查询但同一球场只生成一个订单', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T00:59:55Z'))
|
||||||
|
let opened = false
|
||||||
|
const { registry, service, store } = await setup(async ({ date }) => availability(date, opened && date === releasedDate), 2)
|
||||||
|
await service.runOnceForTest()
|
||||||
|
expect(registry.createBooking).not.toHaveBeenCalled()
|
||||||
|
opened = true
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T01:00:00Z'))
|
||||||
|
await service.runOnceForTest()
|
||||||
|
expect(registry.createBooking).toHaveBeenCalledOnce()
|
||||||
|
expect(registry.getAvailability.mock.calls.filter(([q]) => q.date === releasedDate)).toHaveLength(2)
|
||||||
|
expect((await store.list()).map((task) => task.autoBooking.status)).toEqual(['pending-payment', 'blocked'])
|
||||||
|
const calls = registry.getAvailability.mock.calls.length
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T01:00:01Z'))
|
||||||
|
await service.runOnceForTest()
|
||||||
|
expect(registry.getAvailability).toHaveBeenCalledTimes(calls)
|
||||||
|
expect(registry.createBooking).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整点前请求跨过九点时不重叠,完成后立即补查新放场数据', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T00:59:59Z'))
|
||||||
|
const slow = deferred()
|
||||||
|
let firstSweep = true
|
||||||
|
const { registry, service } = await setup(async ({ date }) => {
|
||||||
|
if (firstSweep) { await slow.promise; return availability(date, date !== releasedDate) }
|
||||||
|
return availability(date, date === releasedDate)
|
||||||
|
})
|
||||||
|
await service.start()
|
||||||
|
await vi.waitFor(() => expect(registry.getAvailability).toHaveBeenCalledTimes(7))
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect(registry.getAvailability).toHaveBeenCalledTimes(7)
|
||||||
|
firstSweep = false
|
||||||
|
slow.resolve()
|
||||||
|
await vi.waitFor(() => expect(registry.createBooking).toHaveBeenCalledOnce())
|
||||||
|
expect(registry.getAvailability.mock.calls[7]![0].date).toBe(releasedDate)
|
||||||
|
await service.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('独立整点唤醒不等待未对齐的每秒心跳', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T00:59:58.750Z'))
|
||||||
|
const readTimes: number[] = []
|
||||||
|
const { registry, service } = await setup(async ({ date }) => {
|
||||||
|
readTimes.push(Date.now())
|
||||||
|
return availability(date, Date.now() >= Date.parse('2026-09-24T01:00:00Z') && date === releasedDate)
|
||||||
|
})
|
||||||
|
await service.start()
|
||||||
|
await vi.waitFor(() => expect(registry.getAvailability).toHaveBeenCalledTimes(7), { interval: 1 })
|
||||||
|
await vi.advanceTimersByTimeAsync(Date.parse('2026-09-24T01:00:00Z') - Date.now())
|
||||||
|
await vi.waitFor(() => expect(registry.createBooking).toHaveBeenCalledOnce(), { interval: 1 })
|
||||||
|
expect(readTimes[7]).toBeLessThan(Date.parse('2026-09-24T01:00:00.750Z'))
|
||||||
|
await service.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('支持提前建立第三天的自动预定任务,不需要等到前一天', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T00:00:00Z'))
|
||||||
|
const { registry, store } = await setup(async ({ date }) => availability(date), 0)
|
||||||
|
const service = new MonitorService(registry as unknown as AdapterRegistry, store, vi.fn(), false)
|
||||||
|
const task = await service.createTask({ courtId: 'dawanqu', targetDate: releasedDate, startTime: '19:00', endTime: '21:00', autoBookingEnabled: true })
|
||||||
|
expect(task.targetDate).toBe(releasedDate)
|
||||||
|
expect(task.autoBooking.status).toBe('armed')
|
||||||
|
await expect(service.createTask({ courtId: 'dawanqu', targetDate: '2026-10-10', startTime: '19:00', endTime: '21:00' })).rejects.toThrow('参数格式错误')
|
||||||
|
expect(registry.createBooking).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未支持的优惠流程只提醒,不消耗自动锁场重试次数', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-09-24T01:00:00Z'))
|
||||||
|
const { registry, service, store } = await setup(async ({ date }) => {
|
||||||
|
const day = availability(date, date === releasedDate)
|
||||||
|
day.slots.forEach((slot) => { slot.bookingUnavailableReason = '优惠场次需在原小程序预约' })
|
||||||
|
return day
|
||||||
|
})
|
||||||
|
await service.runOnceForTest()
|
||||||
|
expect(registry.createBooking).not.toHaveBeenCalled()
|
||||||
|
expect((await store.list())[0]!.autoBooking.status).toBe('armed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
MonitorTask
|
MonitorTask
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import type { AdapterRegistry } from '../adapters/registry'
|
import type { AdapterRegistry } from '../adapters/registry'
|
||||||
|
import { priorityReleaseDate } from './releaseSchedule'
|
||||||
import {
|
import {
|
||||||
findBestCompleteContinuousSequence,
|
findBestCompleteContinuousSequence,
|
||||||
physicalCourtKey,
|
physicalCourtKey,
|
||||||
@@ -165,6 +166,7 @@ export class MonitorService {
|
|||||||
private readonly criticalTelemetryWrites = new Set<string>()
|
private readonly criticalTelemetryWrites = new Set<string>()
|
||||||
private readonly autoBookingOverrides = new Map<string, MonitorAutoBookingState>()
|
private readonly autoBookingOverrides = new Map<string, MonitorAutoBookingState>()
|
||||||
private timer: NodeJS.Timeout | null = null
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private hourTimer: NodeJS.Timeout | null = null
|
||||||
private telemetryTimer: NodeJS.Timeout | null = null
|
private telemetryTimer: NodeJS.Timeout | null = null
|
||||||
private scheduledTickPromise: Promise<void> | null = null
|
private scheduledTickPromise: Promise<void> | null = null
|
||||||
private lastObservedHourKey: string | null = null
|
private lastObservedHourKey: string | null = null
|
||||||
@@ -191,6 +193,7 @@ export class MonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.timer = setInterval(() => this.scheduleTick(), 1_000)
|
this.timer = setInterval(() => this.scheduleTick(), 1_000)
|
||||||
|
this.scheduleHourWake()
|
||||||
this.telemetryTimer = setInterval(
|
this.telemetryTimer = setInterval(
|
||||||
() => void this.flushTelemetry().catch((error) => {
|
() => void this.flushTelemetry().catch((error) => {
|
||||||
console.error('[tennis-book][monitor-telemetry]', error)
|
console.error('[tennis-book][monitor-telemetry]', error)
|
||||||
@@ -203,6 +206,8 @@ export class MonitorService {
|
|||||||
async stop() {
|
async stop() {
|
||||||
this.stopping = true
|
this.stopping = true
|
||||||
if (this.timer) clearInterval(this.timer)
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
if (this.hourTimer) clearTimeout(this.hourTimer)
|
||||||
|
this.hourTimer = null
|
||||||
if (this.telemetryTimer) clearInterval(this.telemetryTimer)
|
if (this.telemetryTimer) clearInterval(this.telemetryTimer)
|
||||||
this.timer = null
|
this.timer = null
|
||||||
this.telemetryTimer = null
|
this.telemetryTimer = null
|
||||||
@@ -256,9 +261,7 @@ export class MonitorService {
|
|||||||
const parsed = this.parseCreateInput(input)
|
const parsed = this.parseCreateInput(input)
|
||||||
this.registry.listCourts().find((court) => court.id === parsed.courtId) ??
|
this.registry.listCourts().find((court) => court.id === parsed.courtId) ??
|
||||||
(() => { throw new Error('球场不存在') })()
|
(() => { throw new Error('球场不存在') })()
|
||||||
if (this.registry.listCourts().find((court) => court.id === parsed.courtId)?.requiresAuthenticatedAvailability) {
|
await this.assertMonitorCredentials(parsed.courtId)
|
||||||
throw new Error('此球场列表需要登录,暂不支持无凭证空场监控')
|
|
||||||
}
|
|
||||||
if (parsed.autoBookingEnabled) await this.assertAutoBookingCanBeEnabled(parsed.courtId)
|
if (parsed.autoBookingEnabled) await this.assertAutoBookingCanBeEnabled(parsed.courtId)
|
||||||
const createdAt = new Date().toISOString()
|
const createdAt = new Date().toISOString()
|
||||||
const telemetry = createMonitorTelemetry(createdAt)
|
const telemetry = createMonitorTelemetry(createdAt)
|
||||||
@@ -295,6 +298,11 @@ export class MonitorService {
|
|||||||
if (typeof taskId !== 'string' || typeof enabled !== 'boolean') {
|
if (typeof taskId !== 'string' || typeof enabled !== 'boolean') {
|
||||||
throw new Error('监控任务状态参数错误')
|
throw new Error('监控任务状态参数错误')
|
||||||
}
|
}
|
||||||
|
if (enabled) {
|
||||||
|
const task = (await this.store.list()).find((candidate) => candidate.id === taskId)
|
||||||
|
if (!task) throw new Error('监控任务不存在')
|
||||||
|
await this.assertMonitorCredentials(task.courtId)
|
||||||
|
}
|
||||||
const runtime = this.ensureRuntime(taskId, enabled)
|
const runtime = this.ensureRuntime(taskId, enabled)
|
||||||
const previousEnabled = runtime.enabled
|
const previousEnabled = runtime.enabled
|
||||||
runtime.generation += 1
|
runtime.generation += 1
|
||||||
@@ -400,10 +408,19 @@ export class MonitorService {
|
|||||||
return task.telemetry
|
return task.telemetry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertMonitorCredentials(courtId: string) {
|
||||||
|
const court = this.registry.listCourts().find((candidate) => candidate.id === courtId)
|
||||||
|
if (!court) throw new Error('球场不存在')
|
||||||
|
if (!court.requiresAuthenticatedAvailability) return
|
||||||
|
const settings = await this.registry.getCourtSettings(courtId)
|
||||||
|
if (!settings.credentialConfigured) throw new Error(`请先配置当前球场的 ${court.credentialLabel ?? '登录凭证'} 再开启监控`)
|
||||||
|
}
|
||||||
|
|
||||||
private async assertAutoBookingCanBeEnabled(courtId: string) {
|
private async assertAutoBookingCanBeEnabled(courtId: string) {
|
||||||
const settings = await this.registry.getCourtSettings(courtId)
|
const settings = await this.registry.getCourtSettings(courtId)
|
||||||
if (!settings.visitorIdConfigured) {
|
if (!(settings.credentialConfigured ?? settings.visitorIdConfigured)) {
|
||||||
throw new Error('开启自动预定前,请先配置当前球场的 PSPLVISITORID')
|
const label = this.registry.listCourts().find((court) => court.id === courtId)?.credentialLabel ?? 'PSPLVISITORID'
|
||||||
|
throw new Error(`开启自动预定前,请先配置当前球场的 ${label}`)
|
||||||
}
|
}
|
||||||
if (await this.registry.getPendingBooking(courtId)) {
|
if (await this.registry.getPendingBooking(courtId)) {
|
||||||
throw new Error('当前球场已有待处理订单,请先支付或释放后再开启自动预定')
|
throw new Error('当前球场已有待处理订单,请先支付或释放后再开启自动预定')
|
||||||
@@ -508,9 +525,19 @@ export class MonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleHourWake() {
|
||||||
|
if (!this.schedulingEnabled || this.stopping) return
|
||||||
|
const delay = 3_600_000 - Date.now() % 3_600_000
|
||||||
|
this.hourTimer = setTimeout(() => {
|
||||||
|
this.scheduleTick()
|
||||||
|
this.scheduleHourWake()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleTick() {
|
private scheduleTick() {
|
||||||
if (!this.schedulingEnabled || this.stopping) return
|
if (!this.schedulingEnabled || this.stopping) return
|
||||||
if (this.scheduledTickPromise) return
|
if (this.scheduledTickPromise) return
|
||||||
|
const hourAtStart = localHourKey(Date.now())
|
||||||
const operation = this.tick()
|
const operation = this.tick()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.serviceError = null
|
this.serviceError = null
|
||||||
@@ -522,6 +549,8 @@ export class MonitorService {
|
|||||||
this.scheduledTickPromise = operation
|
this.scheduledTickPromise = operation
|
||||||
void operation.finally(() => {
|
void operation.finally(() => {
|
||||||
if (this.scheduledTickPromise === operation) this.scheduledTickPromise = null
|
if (this.scheduledTickPromise === operation) this.scheduledTickPromise = null
|
||||||
|
// If a request crossed the hour, immediately start a fresh check after it drains.
|
||||||
|
if (!this.stopping && hourAtStart !== localHourKey(Date.now())) this.scheduleTick()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,10 +603,33 @@ export class MonitorService {
|
|||||||
const key = `${courtId}:${date}`
|
const key = `${courtId}:${date}`
|
||||||
const current = availabilityRequests.get(key)
|
const current = availabilityRequests.get(key)
|
||||||
if (current) return current
|
if (current) return current
|
||||||
const request = this.registry.getAvailability({ courtId, date }, true)
|
const request = this.registry.getAvailability({ courtId, date })
|
||||||
availabilityRequests.set(key, request)
|
availabilityRequests.set(key, request)
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
// Process release-day armed tasks before starting unrelated date requests.
|
||||||
|
// The per-tick request cache is shared with the normal sweep below.
|
||||||
|
const priorityTasks = [...groups.values()].flat().filter((task) => {
|
||||||
|
const date = priorityReleaseDate(this.registry.listCourts().find((court) => court.id === task.courtId), now)
|
||||||
|
return !this.runningGroups.has(groupKey(task)) && date && (!task.targetDate || task.targetDate === date) &&
|
||||||
|
task.autoBooking.enabled && task.autoBooking.status === 'armed' &&
|
||||||
|
(isNewHour || (this.groupNextCheckAt.get(groupKey(task)) ?? 0) <= now)
|
||||||
|
}).sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
|
||||||
|
for (const task of priorityTasks) {
|
||||||
|
const runtime = this.ensureRuntime(task.id, task.enabled)
|
||||||
|
const generation = runtime.generation
|
||||||
|
const date = priorityReleaseDate(this.registry.listCourts().find((court) => court.id === task.courtId), now)!
|
||||||
|
try {
|
||||||
|
const day = await getAvailability(task.courtId, date)
|
||||||
|
const previous = new Set(task.seenAvailableSlotIds)
|
||||||
|
const newlyAvailable = continuousAvailabilityMatches(task, [day]).filter(({ trackingId, legacyTrackingIds }) =>
|
||||||
|
!previous.has(trackingId) && !legacyTrackingIds.every((id) => previous.has(id))
|
||||||
|
)
|
||||||
|
await this.maybeAutoBook(task, runtime, generation, newlyAvailable)
|
||||||
|
} catch {
|
||||||
|
// Preserve the normal error accounting below; never turn a failed read into a booking.
|
||||||
|
}
|
||||||
|
}
|
||||||
await Promise.all([...groups.entries()].map(async ([key, groupTasks]) => {
|
await Promise.all([...groups.entries()].map(async ([key, groupTasks]) => {
|
||||||
if (
|
if (
|
||||||
this.runningGroups.has(key) ||
|
this.runningGroups.has(key) ||
|
||||||
@@ -602,6 +654,12 @@ export class MonitorService {
|
|||||||
return { date, error }
|
return { date, error }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
const court = this.registry.listCourts().find((candidate) => candidate.id === first.courtId)
|
||||||
|
const newlyReleasedDate = priorityReleaseDate(court, Date.now())
|
||||||
|
if (newlyReleasedDate && !priorityReleaseDate(court, now) && checks.some(({ task }) =>
|
||||||
|
task.autoBooking.enabled && task.autoBooking.status === 'armed' &&
|
||||||
|
(!task.targetDate || task.targetDate === newlyReleasedDate)
|
||||||
|
)) return // Discard pre-release snapshots; the queued hour check will fetch again.
|
||||||
const availability = dateResults.flatMap((result) =>
|
const availability = dateResults.flatMap((result) =>
|
||||||
result.availability ? [result.availability] : []
|
result.availability ? [result.availability] : []
|
||||||
)
|
)
|
||||||
@@ -788,7 +846,11 @@ export class MonitorService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = [...newlyAvailable].sort((left, right) =>
|
const releaseDate = priorityReleaseDate(this.registry.listCourts().find((court) => court.id === task.courtId), Date.now())
|
||||||
|
const bookable = newlyAvailable.filter((match) => match.slots.every((slot) => !slot.bookingUnavailableReason))
|
||||||
|
if (bookable.length === 0) return
|
||||||
|
const candidate = [...bookable].sort((left, right) =>
|
||||||
|
Number(right.date === releaseDate) - Number(left.date === releaseDate) ||
|
||||||
left.date.localeCompare(right.date) ||
|
left.date.localeCompare(right.date) ||
|
||||||
left.totalPriceCents - right.totalPriceCents ||
|
left.totalPriceCents - right.totalPriceCents ||
|
||||||
left.courtLabel.localeCompare(right.courtLabel, 'zh-CN')
|
left.courtLabel.localeCompare(right.courtLabel, 'zh-CN')
|
||||||
@@ -967,7 +1029,7 @@ export class MonitorService {
|
|||||||
(typeof targetDate === 'string' && !DATE_PATTERN.test(targetDate)) ||
|
(typeof targetDate === 'string' && !DATE_PATTERN.test(targetDate)) ||
|
||||||
!TIME_PATTERN.test(input.startTime) ||
|
!TIME_PATTERN.test(input.startTime) ||
|
||||||
!TIME_PATTERN.test(input.endTime) ||
|
!TIME_PATTERN.test(input.endTime) ||
|
||||||
(typeof targetDate === 'string' && ![localDateValue(), localDateValue(1)].includes(targetDate)) ||
|
(typeof targetDate === 'string' && !Array.from({ length: ROLLING_DATE_COUNT }, (_, index) => localDateValue(index)).includes(targetDate)) ||
|
||||||
timeMinutes(input.startTime) >= timeMinutes(input.endTime) ||
|
timeMinutes(input.startTime) >= timeMinutes(input.endTime) ||
|
||||||
(typeof targetDate === 'string' &&
|
(typeof targetDate === 'string' &&
|
||||||
new Date(`${targetDate}T${input.endTime}:00`).getTime() <= Date.now())
|
new Date(`${targetDate}T${input.endTime}:00`).getTime() <= Date.now())
|
||||||
|
|||||||
13
src/main/monitors/releaseSchedule.test.ts
Normal file
13
src/main/monitors/releaseSchedule.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { dawanquAdapter } from '../adapters/dawanqu/adapter'
|
||||||
|
import { priorityReleaseDate } from './releaseSchedule'
|
||||||
|
|
||||||
|
describe('放场日期与时区', () => {
|
||||||
|
it('北京时间九点关注包含当天在内的第三天,跨月跨年也正确', () => {
|
||||||
|
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-09-24T01:00:00Z'))).toBe('2026-09-26')
|
||||||
|
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-12-31T01:00:59Z'))).toBe('2027-01-02')
|
||||||
|
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-09-24T00:59:59Z'))).toBeUndefined()
|
||||||
|
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-09-24T01:01:00Z'))).toBeUndefined()
|
||||||
|
expect(priorityReleaseDate(undefined, Date.now())).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
16
src/main/monitors/releaseSchedule.ts
Normal file
16
src/main/monitors/releaseSchedule.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { CourtSummary } from '../../shared/contracts'
|
||||||
|
|
||||||
|
/** Release priority is based on the venue timezone, independent of the Mac timezone. */
|
||||||
|
export function priorityReleaseDate(court: CourtSummary | undefined, timestamp: number): string | undefined {
|
||||||
|
const schedule = court?.releaseSchedule
|
||||||
|
if (!schedule) return undefined
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: schedule.timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
|
||||||
|
}).formatToParts(timestamp)
|
||||||
|
const value = (type: string) => parts.find((part) => part.type === type)!.value
|
||||||
|
if (`${value('hour')}:${value('minute')}` !== schedule.time) return undefined
|
||||||
|
const date = new Date(`${value('year')}-${value('month')}-${value('day')}T00:00:00Z`)
|
||||||
|
date.setUTCDate(date.getUTCDate() + schedule.dayOffset)
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
@@ -709,7 +709,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="新建空场监控"
|
aria-label="新建空场监控"
|
||||||
disabled={!selectedCourtId || selectedCourt?.requiresAuthenticatedAvailability}
|
disabled={!selectedCourtId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMonitorDate('')
|
setMonitorDate('')
|
||||||
setMonitorAutoBooking(false)
|
setMonitorAutoBooking(false)
|
||||||
@@ -723,7 +723,7 @@ export function App() {
|
|||||||
{monitorTasks.length === 0 ? (
|
{monitorTasks.length === 0 ? (
|
||||||
<button
|
<button
|
||||||
className="monitor-empty"
|
className="monitor-empty"
|
||||||
disabled={!selectedCourtId || selectedCourt?.requiresAuthenticatedAvailability}
|
disabled={!selectedCourtId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMonitorDate('')
|
setMonitorDate('')
|
||||||
setMonitorAutoBooking(false)
|
setMonitorAutoBooking(false)
|
||||||
@@ -731,7 +731,7 @@ export function App() {
|
|||||||
setMonitorDialogOpen(true)
|
setMonitorDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>{selectedCourt?.requiresAuthenticatedAvailability ? '此球场列表需要登录' : '每 10 秒检查空场'}<br /><span>{selectedCourt?.requiresAuthenticatedAvailability ? '暂不支持无凭证空场监控' : '点击创建第一个任务'}</span></button>
|
>每 10 秒检查空场<br /><span>{selectedCourt?.releaseSchedule ? `${selectedCourt.releaseSchedule.time} 优先检查新放场日期` : '点击创建第一个任务'}</span></button>
|
||||||
) : (
|
) : (
|
||||||
<div className="monitor-task-list">
|
<div className="monitor-task-list">
|
||||||
{monitorTasks.map((task) => (
|
{monitorTasks.map((task) => (
|
||||||
@@ -1207,7 +1207,10 @@ export function App() {
|
|||||||
<span className="settings-icon"><BellRing size={17} /></span>
|
<span className="settings-icon"><BellRing size={17} /></span>
|
||||||
<div><span className="eyebrow">COURT WATCHER</span><h2>新建空场监控</h2></div>
|
<div><span className="eyebrow">COURT WATCHER</span><h2>新建空场监控</h2></div>
|
||||||
</div>
|
</div>
|
||||||
<p>应用将在后台每 10 秒刷新一次无凭证球场列表。只有同一个场地完整连续覆盖所选时间段才会命中;不限日期会持续滚动检查未来 7 天。</p>
|
<p>应用将在后台每 10 秒刷新一次球场列表;需要登录的球场使用本机保存的凭证。只有同一个场地完整连续覆盖所选时间段才会命中;不限日期会持续滚动检查未来 7 天。</p>
|
||||||
|
{selectedCourt?.releaseSchedule ? (
|
||||||
|
<p>{selectedCourt.releaseSchedule.time}(球场当地时间)优先检查含当天在内的第 {selectedCourt.releaseSchedule.dayOffset + 1} 天的场地;已开启自动预定的任务命中后立即尝试锁场,只锁场不支付。</p>
|
||||||
|
) : null}
|
||||||
<fieldset className="monitor-date-options">
|
<fieldset className="monitor-date-options">
|
||||||
<legend>监控日期</legend>
|
<legend>监控日期</legend>
|
||||||
<button
|
<button
|
||||||
@@ -1215,13 +1218,13 @@ export function App() {
|
|||||||
onClick={() => setMonitorDate('')}
|
onClick={() => setMonitorDate('')}
|
||||||
type="button"
|
type="button"
|
||||||
><span>持续</span><strong>不限日期</strong></button>
|
><span>持续</span><strong>不限日期</strong></button>
|
||||||
{dateOptions.slice(0, 2).map((option) => (
|
{dateOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
className={monitorDate === option.value ? 'is-selected' : ''}
|
className={monitorDate === option.value ? 'is-selected' : ''}
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => setMonitorDate(option.value)}
|
onClick={() => setMonitorDate(option.value)}
|
||||||
type="button"
|
type="button"
|
||||||
><span>{option.relativeLabel}</span><strong>{dateFormatter.format(option.date)}</strong></button>
|
><span>{option.relativeLabel ?? weekdayFormatter.format(option.date)}</span><strong>{dateFormatter.format(option.date)}</strong></button>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="monitor-time-fields">
|
<div className="monitor-time-fields">
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ button { color: inherit; }
|
|||||||
.credential-input-wrap button:focus-visible { outline: 2px solid #789981; outline-offset: 1px; }
|
.credential-input-wrap button:focus-visible { outline: 2px solid #789981; outline-offset: 1px; }
|
||||||
.credential-input-wrap button:disabled { cursor: default; opacity: .35; }
|
.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 { max-height: calc(100vh - 40px); overflow-y: auto; 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-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 { 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 legend { grid-column: 1 / -1; margin-bottom: 7px; color: #40594d; font-size: 10px; font-weight: 760; }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface CourtSummary {
|
|||||||
indoor?: boolean
|
indoor?: boolean
|
||||||
credentialLabel?: string
|
credentialLabel?: string
|
||||||
requiresAuthenticatedAvailability?: boolean
|
requiresAuthenticatedAvailability?: boolean
|
||||||
|
releaseSchedule?: { time: string; dayOffset: number; timeZone: string }
|
||||||
supportsCancellation?: boolean
|
supportsCancellation?: boolean
|
||||||
bookingNotice?: string
|
bookingNotice?: string
|
||||||
accent: string
|
accent: string
|
||||||
|
|||||||
Reference in New Issue
Block a user