This commit is contained in:
richarjiang
2026-07-15 17:12:04 +08:00
commit f8c8c688a1
26 changed files with 6789 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
out/
.DS_Store
*.log
.env
.env.*
!.env.example

45
AGENTS.md Normal file
View File

@@ -0,0 +1,45 @@
# Tennis Book 开发约定
## 产品边界
- 本项目是桌面端网球场聚合预约工具,不在渲染层直接调用第三方微信小程序接口。
- 每个球场必须通过独立适配器接入;公共层只定义球场、日期、场地和时段等稳定概念。
- 不在公共模型中硬塞某个球场的专属字段。专属请求参数、签名、Cookie 和流程状态留在对应适配器内。
## 目录结构
- `src/main/`Electron 主进程、第三方网络请求、凭证和适配器注册。
- `src/main/settings/`:按球场保存的本机配置与原子写入逻辑,存储位置使用 Electron `userData` 目录。
- `src/main/bookings/`:待支付锁场订单摘要与恢复逻辑;不得持久化支付签名。
- `src/main/adapters/<court-id>/`:单个球场的接口与预约流程实现。
- `src/preload/`:仅暴露经过白名单审核的类型安全 IPC。
- `src/renderer/`React UI不可使用 Node.js API不可持有第三方密钥。
- `src/shared/`:主进程、预加载和渲染层共享的纯类型与消息契约。
- `docs/`:架构决策和球场接入说明。
## 命名与扩展规则
- 球场 ID 使用稳定的 `kebab-case`,适配器目录名必须与 ID 一致。
- IPC channel 使用 `domain:action`,新增 channel 时必须同时更新共享类型和 preload 白名单。
- 新球场先实现 `CourtAdapter`,再注册到适配器注册表;禁止在 React 组件里按球场 ID 写业务分支。
- 第三方接口字段 `userId` 在本项目语义中是“场地 ID”固定场地 ID 属于球场适配器私有常量,不得通过 IPC 或配置界面暴露。
- 场地 ID 只允许在对应适配器请求边界映射到第三方 `userId` 字段和 `STOREID` 请求头。
- 下单、取消等写操作必须由对应球场适配器独立实现;公共层只编排确认、状态和恢复,不拼装球场专属请求。
- 模拟数据只允许存在于 `demo` 适配器或明确命名的 fixture 中,接入真实 API 后不得静默回退到模拟成功。
## 安全规则
- Electron 必须保持 `contextIsolation: true``nodeIntegration: false``sandbox: true`
- 第三方令牌、Cookie、签名密钥不得进入渲染进程、日志或版本库。
- 第三方请求诊断日志只允许在 dev 模式输出;请求头和响应中的访客凭证、支付签名、预支付标识及个人信息必须递归脱敏。
- `PSPLVISITORID` 持久化在 Electron 主进程管理的本机配置中;设置弹窗可通过白名单 IPC 读取原值用于回填,但关闭弹窗后必须清除渲染层状态,其他业务接口不得返回原值。
- 每球场配置以 `courtId` 隔离;标识符在共享模型和持久化文件中使用字符串,实际请求前再按接口契约安全转换。
- 预约人昵称和手机号仅可按产品明确要求存在于当次时段查询的内存结果中;不得写日志、落盘、缓存或用于预约以外的用途。
- 外部链接只能通过主进程白名单打开;页面不得任意导航或创建窗口。
- 写操作前必须在主进程重新验证实时可用性和价格UI 必须二次确认。不得用真实接口做自动化下单或取消测试。
## 完成标准
- 每次修改至少通过 `npm run typecheck``npm run build``git diff --check`
- 影响交互或布局时必须做一次实际渲染检查。
- 接入真实球场时需要覆盖:正常数据、无可用时段、登录失效、限流、接口格式变化和网络失败。

51
docs/architecture.md Normal file
View File

@@ -0,0 +1,51 @@
# 架构说明
## 为什么选 Electron
本项目的主要复杂度不是绘制桌面窗口而是长期维护多个非统一的微信小程序接口不同签名方式、Cookie、请求头、代理、登录状态和多步骤预约流程。Electron 主进程可以直接复用 Node.js 网络生态,并与 Chromium 渲染层保持清晰隔离,能以最低的适配成本覆盖这些差异。
Tauri 的安装包更小、空闲内存通常更低,但它会把接口适配层拆成 Rust 命令或 sidecar。当前业务没有强约束要求最小包体却明确要求持续定制多个 JavaScript/HTTP 流程,因此 Electron 的开发与排障成本更低。
## 运行边界
```text
React renderer
| typed window.tennisBook API
Preload allowlist
| ipcRenderer.invoke
Electron main process
| AdapterRegistry + CourtSettingsStore
CourtAdapter A / CourtAdapter B / ...
| HTTP, cookies, signatures, booking workflow
微信小程序后端 API
```
渲染层只知道统一的 `CourtSummary``AvailabilityDay`。某个球场的原始响应、鉴权和预约步骤不能穿过 IPC 边界。
`AvailabilityDay` 可包含界面明确需要展示的预约人昵称和手机号,但适配器必须从第三方原始响应中逐项提取,不能把原始预约对象整体穿过 IPC这些信息只保留在当前查询的内存状态中不记录、不缓存、不落盘。
preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandbox而 sandboxed preload 没有 ESM 上下文;不能通过关闭沙箱来迁就 `.mjs` 构建产物。
## 每球场运行时配置
`PSPLVISITORID` 等运行时凭证按 `courtId` 独立存储在 Electron `userData` 目录。范思伯特福中福的场地 ID 固定为适配器私有常量,只在请求边界映射到第三方 `userId` 字段和 `STOREID` 请求头,不经过 IPC也不在配置界面暴露。设置弹窗通过专用白名单 IPC 读取凭证原值并回填密码框,默认遮挡;关闭弹窗立即清除渲染层中的输入值和设置对象,其他查询及预约接口不返回凭证。
配置文件使用临时文件加重命名的方式原子替换。共享层始终用字符串表达外部 ID避免超出 JavaScript 安全整数范围;具体适配器负责校验接口是否接受该范围并转换为请求格式。
## 接入一个新球场
1.`src/main/adapters/<court-id>/` 创建适配器。
2. 实现 `CourtAdapter``court``getAvailability()`
3. 在适配器内部完成原始响应到共享模型的转换。
4. 将适配器加入 `src/main/adapters/registry.ts`
5. 为登录失效、空数据和接口字段变化添加契约测试。
## 预约写操作
公共预约状态机为:`可订 -> 二次确认 -> 重新查询校验 -> 结果待确认记录 -> 下单锁场 -> 待支付 -> 取消释放/支付完成`。具体请求体、版本头、访客凭证和响应解析只存在于球场适配器中。所有写操作按球场在主进程串行化,待支付或结果不确定期间禁止修改该球场凭证。
下单前由主进程重新查询同一天的数据,并使用重新查询得到的场地 UID、时间和价格构造请求拒绝已订、排课和活动报名时段。渲染层传入的价格只作为用户确认金额如与最新价格不同则拒绝下单并要求重新确认。第三方 UID 不接受渲染层输入。
范思伯特福中福的 `SaveVenueAppointmentV2` 只创建待支付订单并锁场,不代表支付成功。支付脚本不经过 IPC、不记录日志、不落盘待支付订单的非敏感摘要按球场持久化以便应用重启后仍能恢复“取消释放”入口。POST 发送前先持久化结果待确认记录,网络超时或响应解析失败时保留“尝试释放”入口,避免生成不可恢复的孤儿锁场。
`CrmAutoReleaseVenueAppoint` 使用当前球场配置与空 JSON 请求体释放当前访客的锁场,并不能绑定订单号。发送释放请求前先持久化 `release-uncertain`;若响应超时或进程中断,系统禁止自动重试,用户必须在小程序核实后显式清理本机记录。远端确认释放后先持久化 `release-confirmed` 标记,再删除摘要,避免清理失败后重复调用全局释放接口。支付脚本的过期时间只用于展示,不作为场地锁已经释放的依据。

31
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { resolve } from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared')
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared')
}
}
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react()]
}
})

3361
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "tennis-book",
"version": "0.1.0",
"private": true,
"description": "Desktop workspace for aggregated tennis court booking",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fontsource-variable/manrope": "5.2.8",
"@fontsource-variable/newsreader": "5.2.8",
"lucide-react": "0.468.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@swc/core": "1.15.18",
"@types/node": "22.15.3",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.7.0",
"electron": "35.7.5",
"electron-vite": "3.1.0",
"typescript": "5.9.3",
"vite": "6.4.3",
"vitest": "3.2.7"
}
}

View File

@@ -0,0 +1,297 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AvailabilityQuery } from '../../../shared/contracts'
import {
buildAvailabilityRequestBody,
buildCreateBookingRequestBody,
fansiboteFuzhongAdapter,
parseCreateBookingResponse,
parseAvailabilityResponse
} from './adapter'
const query: AvailabilityQuery = {
courtId: 'fansibote-fuzhong',
date: '2026-07-16'
}
function createSlot(status: number, canApptOrNot: boolean) {
return {
classroomUid: 1775556146897330236,
txtClassroomUid: '1775556146897330236',
classRoomName: '1号风雨场',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
cost: 80,
status,
apptInfo: {
canApptOrNot,
customerNames: ['王小球'],
hadVenueAppts: [{
customerName: '王小球',
customerTel: '13800000000'
}]
}
}
}
describe('范思伯特福中福响应映射', () => {
afterEach(() => vi.unstubAllGlobals())
it('把内部场地 ID 映射为第三方 userId 字段', () => {
const body = buildAvailabilityRequestBody('2026-07-16')
expect(body).toEqual({
dateTime: '2026-07-16',
userId: 6038652,
projectUid: '1775556129325750199'
})
})
it('列表请求固定使用适配器私有的场地 ID', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ successed: true, result: { slots: [] } })
})
vi.stubGlobal('fetch', fetchMock)
await fansiboteFuzhongAdapter.getAvailability(query, {
courtId: query.courtId
})
expect(fetchMock).toHaveBeenCalledOnce()
const request = fetchMock.mock.calls[0]?.[1] as RequestInit
expect(request.headers).toEqual(expect.objectContaining({
STOREID: '6038652'
}))
expect(JSON.parse(String(request.body))).toEqual({
dateTime: '2026-07-16',
userId: 6038652,
projectUid: '1775556129325750199'
})
})
it('使用实时重新查询得到的场地引用和价格构造下单请求', () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
expect(buildCreateBookingRequestBody(slot)).toEqual({
userId: 6038652,
projectType: 0,
classroomItems: [{
classroomUid: '1775556146897330236',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
peopleNum: 1
}],
remark: '',
combinationPayments: [{ paymentMethod: 900, cost: 80 }]
})
})
it('把下单成功响应映射为待支付锁场,不暴露支付脚本给公共订单', () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
const result = parseCreateBookingResponse({
successed: true,
result: {
apptUid: '1784102970857723008',
script: {
expireTime: '2026-07-15 16:24:31',
paySign: 'private-pay-sign'
},
venueAppointUids: ['1784102970857723008']
}
}, slot, query.courtId)
expect(result.booking).toEqual({
courtId: query.courtId,
orderId: '1784102970857723008',
venueAppointmentIds: ['1784102970857723008'],
amountCents: 8000,
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-15 16:24:31',
status: 'pending-payment'
})
expect(JSON.stringify(result.booking)).not.toContain('private-pay-sign')
})
it('下单和取消都注入访客凭证,取消请求体严格为空对象', async () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-1',
script: { expireTime: '2026-07-15 16:24:31' },
venueAppointUids: ['order-1']
}
})
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ successed: true })
})
vi.stubGlobal('fetch', fetchMock)
const settings = {
courtId: query.courtId,
visitorId: 'visitor-token'
}
await fansiboteFuzhongAdapter.createBooking!(slot, settings)
await fansiboteFuzhongAdapter.cancelBooking!(settings)
const createRequest = fetchMock.mock.calls[0]?.[1] as RequestInit
const cancelRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
expect(createRequest.headers).toEqual(expect.objectContaining({
PSPLVISITORID: 'visitor-token',
STOREID: '6038652',
VERSIONINFO: 'NC|2026.07.13',
xweb_xhr: '1',
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Dest': 'empty',
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193'
}))
expect(cancelRequest.headers).toEqual(expect.objectContaining({
PSPLVISITORID: 'visitor-token',
STOREID: '6038652',
VERSIONINFO: 'NC|2026.07.13',
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html'
}))
expect(cancelRequest.body).toBe('{}')
})
it('未配置访客凭证时拒绝产生下单请求', async () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(fansiboteFuzhongAdapter.createBooking!(slot, {
courtId: query.courtId
})).rejects.toThrow('PSPLVISITORID')
expect(fetchMock).not.toHaveBeenCalled()
})
it('保留字符串 UID、价格和时间可订时段不携带预约人信息', () => {
const result = parseAvailabilityResponse(
{
successed: true,
result: { slots: [createSlot(0, true)] }
},
query
)
expect(result.slots).toEqual([
expect.objectContaining({
id: '1775556146897330236:2026-07-16 08:00:00',
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
priceCents: 8000,
status: 'available'
})
])
expect(JSON.stringify(result)).not.toContain('13800000000')
expect(JSON.stringify(result)).not.toContain('王小球')
})
it('区分已预约与排课', () => {
const result = parseAvailabilityResponse(
{
successed: true,
result: {
slots: [createSlot(1, false), createSlot(4, false)]
}
},
query
)
expect(result.slots.map(({ status, unavailableLabel, bookedBy }) => ({
status,
unavailableLabel,
bookedBy
}))).toEqual([
{
status: 'booked',
unavailableLabel: '已订',
bookedBy: [{ name: '王小球', phone: '13800000000' }]
},
{ status: 'blocked', unavailableLabel: '排课', bookedBy: undefined }
])
})
it('把跨小时活动合并到同一子场的每个对应时段', () => {
const firstSlot = createSlot(0, true)
const secondSlot = {
...createSlot(0, true),
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00'
}
const result = parseAvailabilityResponse(
{
successed: true,
result: {
slots: [firstSlot, secondSlot],
enrollSlots: [{
txtClassroomUid: '1775556146897330236',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 09:59:00',
enrollmentRule: {
uidTxt: '1784027073124705081',
title: '【2.5双打】福中福8-10',
beginDate: '2026-07-16 08:00:00',
endDate: '2026-07-16 10:00:00',
enrollmentEndDate: '2026-07-16 07:00:00',
enrollmentFee: 70,
enrollmentCount: 3,
enrollmentLimitNum: 4,
customerModels: [{ name: '星' }, { name: '浩哥' }]
}
}]
}
},
query
)
expect(result.slots.map((slot) => slot.enrollment)).toEqual([
{
id: '1784027073124705081',
title: '【2.5双打】福中福8-10',
startTime: '08:00',
endTime: '10:00',
feeCents: 7000,
enrolledCount: 3,
capacity: 4,
participantNames: ['星', '浩哥'],
enrollmentDeadline: '07:00'
},
expect.objectContaining({ id: '1784027073124705081' })
])
})
it('接口字段变化时明确失败,不静默丢时段', () => {
expect(() => parseAvailabilityResponse(
{
successed: true,
result: { slots: [{ classRoomName: '字段缺失' }] }
},
query
)).toThrow('无法识别的时段数据')
})
})

View File

@@ -0,0 +1,525 @@
import { createHash } from 'node:crypto'
import type {
AvailabilityDay,
AvailabilityQuery,
BookingContact,
BookingSlot,
CourtSummary,
EnrollmentActivity
} from '../../../shared/contracts'
import type { CourtRuntimeSettings } from '../../settings/types'
import { ProviderMutationError, type AdapterBookingResult, type CourtAdapter } from '../types'
const AVAILABILITY_ENDPOINT =
'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2'
const CREATE_BOOKING_ENDPOINT =
'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/SaveVenueAppointmentV2'
const CANCEL_BOOKING_ENDPOINT =
'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/CrmAutoReleaseVenueAppoint'
const DEFAULT_VENUE_ID = '6038652'
const PROJECT_UID = '1775556129325750199'
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} (\d{2}:\d{2}):\d{2}$/
const WECHAT_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193'
export const fansiboteFuzhongCourt: CourtSummary = {
id: 'fansibote-fuzhong',
name: '范思伯特福中福',
shortName: '范思',
accent: '#D8FF63',
status: 'online'
}
const defaultSettings: CourtRuntimeSettings = {
courtId: fansiboteFuzhongCourt.id
}
interface PospalSlot {
txtClassroomUid: unknown
classRoomName: unknown
beginDatetime: unknown
endDatetime: unknown
cost: unknown
status: unknown
apptInfo: unknown
}
interface PospalResponse {
successed?: unknown
status?: unknown
errorCode?: unknown
result?: unknown
}
interface ParsedEnrollment {
classroomUid: string
beginDatetime: string
endDatetime: string
activity: EnrollmentActivity
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const SENSITIVE_LOG_KEY = /visitor|paysign|prepay|nonce|package|token|cookie|authorization|customer|phone|tel/i
function redactForLog(value: unknown, key = ''): unknown {
if (SENSITIVE_LOG_KEY.test(key)) return '[REDACTED]'
if (Array.isArray(value)) return value.map((item) => redactForLog(item))
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactForLog(entryValue, entryKey)
])
)
}
return value
}
function logDevRequest(event: string, details: Record<string, unknown>) {
if (!import.meta.env.DEV) return
console.info(
`[tennis-book][fansibote-fuzhong][${event}]`,
JSON.stringify(redactForLog(details), null, 2)
)
}
function credentialFingerprint(visitorId: string) {
return createHash('sha256').update(visitorId).digest('hex').slice(0, 12)
}
function providerErrorDetail(payload: unknown): string | null {
if (!isRecord(payload)) return null
const keys = ['message', 'msg', 'errorMessage', 'errorMsg', 'status', 'errorCode']
const details = keys.flatMap((key) => {
const value = payload[key]
return typeof value === 'string' || typeof value === 'number'
? [`${key}=${String(value).slice(0, 200)}`]
: []
})
return details.length > 0 ? details.join(', ') : null
}
function extractTime(value: unknown): string | null {
if (typeof value !== 'string') return null
return DATE_TIME_PATTERN.exec(value)?.[1] ?? null
}
function parseBookingContacts(appointment: Record<string, unknown>): BookingContact[] {
const venueAppointments = Array.isArray(appointment.hadVenueAppts)
? appointment.hadVenueAppts
: []
const contacts: BookingContact[] = []
for (const venueAppointment of venueAppointments) {
if (!isRecord(venueAppointment)) continue
const name = typeof venueAppointment.customerName === 'string'
? venueAppointment.customerName.trim()
: ''
const phone = typeof venueAppointment.customerTel === 'string'
? venueAppointment.customerTel.trim()
: ''
if (name || phone) contacts.push({
name: name || undefined,
phone: phone || undefined
})
}
if (contacts.length === 0 && Array.isArray(appointment.customerNames)) {
for (const value of appointment.customerNames) {
if (typeof value === 'string' && value.trim()) {
contacts.push({ name: value.trim() })
}
}
}
return contacts.filter((contact, index) =>
contacts.findIndex((candidate) =>
candidate.name === contact.name && candidate.phone === contact.phone
) === index
)
}
function parseParticipantNames(value: unknown): string[] {
if (!Array.isArray(value)) return []
const names = value.flatMap((customer) => {
if (!isRecord(customer) || typeof customer.name !== 'string') return []
const name = customer.name.trim()
return name ? [name] : []
})
return [...new Set(names)]
}
function parseEnrollment(value: unknown): ParsedEnrollment {
if (!isRecord(value) || !isRecord(value.enrollmentRule)) {
throw new Error('球场接口返回了无法识别的活动数据')
}
const rule = value.enrollmentRule
const startTime = extractTime(rule.beginDate)
const endTime = extractTime(rule.endDate)
const deadline = extractTime(rule.enrollmentEndDate)
const enrolledCount = typeof rule.enrollmentCount === 'number'
? rule.enrollmentCount
: typeof rule.ParticipateCount === 'number'
? rule.ParticipateCount
: 0
if (
typeof value.txtClassroomUid !== 'string' ||
typeof value.beginDatetime !== 'string' ||
typeof value.endDatetime !== 'string' ||
typeof rule.uidTxt !== 'string' ||
typeof rule.title !== 'string' ||
startTime === null ||
endTime === null ||
typeof rule.enrollmentFee !== 'number' ||
!Number.isFinite(rule.enrollmentFee) ||
typeof rule.enrollmentLimitNum !== 'number' ||
!Number.isFinite(rule.enrollmentLimitNum)
) {
throw new Error('球场接口返回了无法识别的活动数据')
}
return {
classroomUid: value.txtClassroomUid,
beginDatetime: value.beginDatetime,
endDatetime: value.endDatetime,
activity: {
id: rule.uidTxt,
title: rule.title,
startTime,
endTime,
feeCents: Math.round(rule.enrollmentFee * 100),
enrolledCount,
capacity: rule.enrollmentLimitNum,
participantNames: parseParticipantNames(rule.customerModels),
enrollmentDeadline: deadline ?? undefined
}
}
}
function parseSlot(value: unknown, enrollments: ParsedEnrollment[]): BookingSlot {
if (!isRecord(value)) throw new Error('球场接口返回了无法识别的时段数据')
const slot = value as unknown as PospalSlot
const startTime = extractTime(slot.beginDatetime)
const endTime = extractTime(slot.endDatetime)
const appointment = isRecord(slot.apptInfo) ? slot.apptInfo : null
const canBook = appointment?.canApptOrNot
if (
typeof slot.txtClassroomUid !== 'string' ||
typeof slot.classRoomName !== 'string' ||
typeof slot.beginDatetime !== 'string' ||
typeof slot.endDatetime !== 'string' ||
startTime === null ||
endTime === null ||
typeof slot.cost !== 'number' ||
!Number.isFinite(slot.cost) ||
typeof slot.status !== 'number' ||
typeof canBook !== 'boolean'
) {
throw new Error('球场接口返回了无法识别的时段数据')
}
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
return {
id: `${slot.txtClassroomUid}:${slotBeginDatetime}`,
courtLabel: slot.classRoomName,
startTime,
endTime,
priceCents: Math.round(slot.cost * 100),
status,
unavailableLabel:
status === 'booked' ? '已订' : status === 'blocked' ? '排课' : undefined,
bookedBy: bookedBy.length > 0 ? bookedBy : undefined,
enrollment,
providerReference: {
classroomUid: slot.txtClassroomUid,
beginDatetime: slotBeginDatetime,
endDatetime: slotEndDatetime
},
sourceLabel: '范思伯特实时接口'
}
}
export function parseAvailabilityResponse(
payload: unknown,
query: AvailabilityQuery
): AvailabilityDay {
if (!isRecord(payload)) throw new Error('球场接口响应格式错误')
const response = payload as PospalResponse
if (response.successed !== true || !isRecord(response.result)) {
throw new Error('球场接口拒绝了可订时段查询')
}
const slots = response.result.slots
if (!Array.isArray(slots)) throw new Error('球场接口响应缺少时段列表')
const enrollmentSlots = response.result.enrollSlots
if (enrollmentSlots !== undefined && !Array.isArray(enrollmentSlots)) {
throw new Error('球场接口响应中的活动列表格式错误')
}
const enrollments = (enrollmentSlots ?? []).map(parseEnrollment)
return {
courtId: query.courtId,
date: query.date,
updatedAt: new Date().toISOString(),
slots: slots.map((slot) => parseSlot(slot, enrollments))
}
}
export function buildAvailabilityRequestBody(date: string) {
return {
dateTime: date,
userId: Number(DEFAULT_VENUE_ID),
projectUid: PROJECT_UID
}
}
async function getAvailability(
query: AvailabilityQuery,
settings: CourtRuntimeSettings
): Promise<AvailabilityDay> {
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, {
method: 'POST',
headers: {
Accept: '*/*',
APPTYPE: '1',
'Content-Type': 'application/json',
PSPLVISITORAUTO: 'API',
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html',
STOREID: DEFAULT_VENUE_ID,
VERSIONINFO: 'NC|2026.07.03'
},
body: JSON.stringify(buildAvailabilityRequestBody(query.date)),
signal: AbortSignal.timeout(12_000)
})
if (!response.ok) {
throw new Error(`球场接口请求失败HTTP ${response.status}`)
}
return parseAvailabilityResponse(await response.json(), query)
}
function requireVisitorId(settings: CourtRuntimeSettings): string {
const visitorId = settings.visitorId?.trim()
if (!visitorId) throw new Error('请先在球场配置中填写 PSPLVISITORID')
return visitorId
}
function createWriteHeaders(settings: CourtRuntimeSettings) {
return {
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9',
APPTYPE: '1',
'Content-Type': 'application/json',
PSPLVISITORID: requireVisitorId(settings),
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
STOREID: DEFAULT_VENUE_ID,
'User-Agent': WECHAT_USER_AGENT,
VERSIONINFO: 'NC|2026.07.13',
xweb_xhr: '1'
}
}
export function buildCreateBookingRequestBody(
slot: BookingSlot
) {
if (!slot.providerReference) throw new Error('时段缺少下单引用信息')
return {
userId: Number(DEFAULT_VENUE_ID),
projectType: 0,
classroomItems: [{
classroomUid: slot.providerReference.classroomUid,
beginDatetime: slot.providerReference.beginDatetime,
endDatetime: slot.providerReference.endDatetime,
peopleNum: 1
}],
remark: '',
combinationPayments: [{
paymentMethod: 900,
cost: slot.priceCents / 100
}]
}
}
export function parseCreateBookingResponse(
payload: unknown,
slot: BookingSlot,
courtId: string
): AdapterBookingResult {
if (!isRecord(payload)) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
if (payload.successed !== true) {
const detail = providerErrorDetail(payload)
throw new ProviderMutationError(
`球场下单接口拒绝了锁场请求${detail ? `${detail}` : ''}`,
'rejected'
)
}
if (!isRecord(payload.result)) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
const result = payload.result
if (
typeof result.apptUid !== 'string' ||
!isRecord(result.script) ||
typeof result.script.expireTime !== 'string' ||
!Array.isArray(result.venueAppointUids) ||
!result.venueAppointUids.every((value) => typeof value === 'string')
) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
return {
booking: {
courtId,
orderId: result.apptUid,
venueAppointmentIds: result.venueAppointUids,
amountCents: slot.priceCents,
courtLabel: slot.courtLabel,
startTime: slot.startTime,
endTime: slot.endTime,
expiresAt: result.script.expireTime,
status: 'pending-payment'
},
paymentScript: result.script
}
}
async function createBooking(
slot: BookingSlot,
settings: CourtRuntimeSettings
): Promise<AdapterBookingResult> {
if (slot.status !== 'available' && slot.status !== 'limited') {
throw new Error('该时段当前不可预约')
}
if (slot.enrollment) throw new Error('活动时段不能通过普通场地接口下单')
const headers = createWriteHeaders(settings)
const requestBody = buildCreateBookingRequestBody(slot)
logDevRequest('booking.request', {
url: CREATE_BOOKING_ENDPOINT,
method: 'POST',
credentialFingerprint: credentialFingerprint(headers.PSPLVISITORID),
headers,
body: requestBody
})
let response: Response
try {
response = await fetch(CREATE_BOOKING_ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(12_000)
})
} catch (error) {
logDevRequest('booking.network-error', {
message: error instanceof Error ? error.message : String(error)
})
throw new ProviderMutationError('球场下单网络响应不确定', 'uncertain')
}
let payload: unknown
try {
payload = await response.json()
} catch {
logDevRequest('booking.response', {
httpStatus: response.status,
ok: response.ok,
body: '[UNPARSEABLE]'
})
if (!response.ok) {
throw new ProviderMutationError(
`球场下单请求被拒绝HTTP ${response.status}`,
'rejected'
)
}
throw new ProviderMutationError('球场下单响应无法解析', 'uncertain')
}
logDevRequest('booking.response', {
httpStatus: response.status,
ok: response.ok,
body: payload
})
if (!response.ok) {
const detail = providerErrorDetail(payload)
throw new ProviderMutationError(
`球场下单请求被拒绝HTTP ${response.status}${detail ? `${detail}` : ''}`,
'rejected'
)
}
return parseCreateBookingResponse(payload, slot, settings.courtId)
}
async function cancelBooking(settings: CourtRuntimeSettings): Promise<void> {
const headers = createWriteHeaders(settings)
let response: Response
try {
response = await fetch(CANCEL_BOOKING_ENDPOINT, {
method: 'POST',
headers,
body: '{}',
signal: AbortSignal.timeout(12_000)
})
} catch {
throw new ProviderMutationError('取消锁场网络响应不确定', 'uncertain')
}
if (!response.ok) {
throw new ProviderMutationError(
`取消锁场请求被拒绝HTTP ${response.status}`,
'rejected'
)
}
let payload: unknown
try {
payload = await response.json()
} catch {
throw new ProviderMutationError('取消锁场响应无法解析', 'uncertain')
}
if (!isRecord(payload) || payload.successed !== true) {
throw new ProviderMutationError('取消接口拒绝释放场地', 'rejected')
}
}
export const fansiboteFuzhongAdapter: CourtAdapter = {
court: fansiboteFuzhongCourt,
defaultSettings,
getAvailability,
preflightWriteSettings: requireVisitorId,
createBooking,
cancelBooking
}

View File

@@ -0,0 +1,316 @@
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 { CourtSettingsStore } from '../settings/CourtSettingsStore'
import { PendingBookingStore } from '../bookings/PendingBookingStore'
import { AdapterRegistry } from './registry'
const courtId = 'fansibote-fuzhong'
const slotId = '1775556146897330236:2026-07-16 08:00:00'
function availabilityPayload() {
return {
successed: true,
result: {
slots: [{
txtClassroomUid: '1775556146897330236',
classRoomName: '1号风雨场',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
cost: 80,
status: 0,
apptInfo: { canApptOrNot: true }
}]
}
}
}
describe('AdapterRegistry 预约状态机', () => {
afterEach(() => vi.unstubAllGlobals())
it('重新校验后锁场,阻止重复订单,并在取消成功后释放状态', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-registry-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({
courtId,
visitorId: 'visitor-token'
})
const pendingStore = new PendingBookingStore(join(directory, 'pending.json'))
const registry = new AdapterRegistry(settingsStore, pendingStore)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-1',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-1']
}
})
})
.mockResolvedValueOnce({ ok: true, json: async () => ({ successed: true }) })
vi.stubGlobal('fetch', fetchMock)
const booking = await registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})
expect(booking.orderId).toBe('order-1')
await expect(registry.getPendingBooking(courtId)).resolves.toEqual(booking)
expect(fetchMock).toHaveBeenCalledTimes(2)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('已有待支付订单')
expect(fetchMock).toHaveBeenCalledTimes(2)
await expect(registry.updateCourtSettings({
courtId,
visitorId: 'replacement-token'
})).rejects.toThrow('请先取消释放后再修改配置')
await registry.cancelBooking({ courtId, orderId: 'order-1' })
expect(fetchMock).toHaveBeenCalledTimes(3)
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('在主进程串行化并发下单,只允许生成一个锁场订单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-concurrent-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-1',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-1']
}
})
})
vi.stubGlobal('fetch', fetchMock)
const input = { courtId, date: '2026-07-16', slotId, expectedPriceCents: 8000 }
const results = await Promise.allSettled([
registry.createBooking(input),
registry.createBooking(input)
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('最新价格与用户确认金额不一致时不发送下单请求', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-price-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const payload = availabilityPayload()
payload.result.slots[0]!.cost = 90
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => payload
})
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('价格已变化')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('响应不确定时保留恢复记录并允许用户主动尝试释放', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-uncertain-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockRejectedValueOnce(new Error('connection reset'))
.mockResolvedValueOnce({ ok: true, json: async () => ({ successed: true }) })
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('下单结果无法确认')
const uncertain = await registry.getPendingBooking(courtId)
expect(uncertain?.status).toBe('submitting-uncertain')
await registry.cancelBooking({ courtId, orderId: uncertain!.orderId })
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('缺少访客凭证时不发送下单请求,也不生成恢复死锁', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-preflight-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => availabilityPayload()
})
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('PSPLVISITORID')
expect(fetchMock).toHaveBeenCalledTimes(1)
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('释放响应不确定时不自动重试访客级释放接口', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-release-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const pendingStore = new PendingBookingStore(join(directory, 'pending.json'))
const registry = new AdapterRegistry(settingsStore, pendingStore)
await pendingStore.set({
courtId,
orderId: 'order-1',
venueAppointmentIds: ['order-1'],
amountCents: 8000,
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-16 08:10:00',
status: 'pending-payment'
})
const fetchMock = vi.fn().mockRejectedValueOnce(new Error('connection reset'))
vi.stubGlobal('fetch', fetchMock)
await expect(registry.cancelBooking({ courtId, orderId: 'order-1' }))
.rejects.toThrow('释放结果无法确认')
await expect(registry.getPendingBooking(courtId)).resolves.toMatchObject({
status: 'release-uncertain'
})
await registry.cancelBooking({ courtId, orderId: 'order-1' })
expect(fetchMock).toHaveBeenCalledTimes(1)
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('下单被服务端明确拒绝时清除预写记录且不触发释放', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-rejected-create-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({ ok: false, status: 400 })
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('被拒绝')
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('释放被服务端明确拒绝时恢复原订单状态以允许安全重试', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-rejected-release-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const pendingStore = new PendingBookingStore(join(directory, 'pending.json'))
const registry = new AdapterRegistry(settingsStore, pendingStore)
const pending = {
courtId,
orderId: 'order-1',
venueAppointmentIds: ['order-1'],
amountCents: 8000,
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-16 08:10:00',
status: 'pending-payment' as const
}
await pendingStore.set(pending)
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401 }))
await expect(registry.cancelBooking({ courtId, orderId: 'order-1' }))
.rejects.toThrow('被拒绝')
await expect(registry.getPendingBooking(courtId)).resolves.toEqual(pending)
})
it('下单请求使用刚持久化更新的 PSPLVISITORID', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-current-credential-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'old-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
await registry.updateCourtSettings({ courtId, visitorId: 'updated-token' })
await expect(registry.getCourtSettings(courtId)).resolves.toEqual({
courtId,
visitorId: 'updated-token',
visitorIdConfigured: true
})
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-current-token',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-current-token']
}
})
})
vi.stubGlobal('fetch', fetchMock)
await registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})
const createRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
expect(createRequest.headers).toEqual(expect.objectContaining({
PSPLVISITORID: 'updated-token'
}))
})
})

View File

@@ -0,0 +1,277 @@
import type {
AvailabilityQuery,
CancelBookingInput,
CreateBookingInput,
UpdateCourtSettingsInput
} from '../../shared/contracts'
import type { CourtSettingsStore } from '../settings/CourtSettingsStore'
import type { PendingBookingStore } from '../bookings/PendingBookingStore'
import { fansiboteFuzhongAdapter } from './fansibote-fuzhong/adapter'
import { ProviderMutationError, type CourtAdapter } from './types'
const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export class AdapterRegistry {
private readonly adapters = new Map(
adapters.map((adapter) => [adapter.court.id, adapter])
)
private readonly courtOperations = new Map<string, Promise<void>>()
private readonly releaseConfirmedCourts = new Set<string>()
constructor(
private readonly settingsStore: CourtSettingsStore,
private readonly pendingBookingStore: PendingBookingStore
) {}
listCourts() {
return [...this.adapters.values()].map((adapter) => adapter.court)
}
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)
}
async getCourtSettings(courtId: string) {
const adapter = this.getAdapter(courtId)
const settings = await this.settingsStore.get(courtId, adapter.defaultSettings)
return {
courtId,
visitorId: settings.visitorId,
visitorIdConfigured: Boolean(settings.visitorId)
}
}
async updateCourtSettings(input: unknown) {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
(input.visitorId !== undefined && typeof input.visitorId !== 'string')
) {
throw new Error('球场配置参数格式错误')
}
const parsedInput: UpdateCourtSettingsInput = {
courtId: input.courtId,
visitorId: input.visitorId
}
this.getAdapter(parsedInput.courtId)
return this.runExclusive(parsedInput.courtId, async () => {
const adapter = this.getAdapter(parsedInput.courtId)
if (await this.pendingBookingStore.get(parsedInput.courtId)) {
throw new Error('当前球场存在待支付订单,请先取消释放后再修改配置')
}
const current = await this.settingsStore.get(
parsedInput.courtId,
adapter.defaultSettings
)
const visitorId = parsedInput.visitorId?.trim() || current.visitorId
if (parsedInput.visitorId !== undefined && !parsedInput.visitorId.trim()) {
throw new Error('PSPLVISITORID 不能为空')
}
const saved = await this.settingsStore.update({
courtId: parsedInput.courtId,
visitorId
})
return {
courtId: saved.courtId,
visitorId: saved.visitorId,
visitorIdConfigured: Boolean(saved.visitorId)
}
})
}
async createBooking(input: unknown) {
const parsedInput = this.parseCreateBookingInput(input)
return this.runExclusive(parsedInput.courtId, () =>
this.createBookingExclusive(parsedInput)
)
}
async cancelBooking(input: unknown) {
const parsedInput = this.parseCancelBookingInput(input)
return this.runExclusive(parsedInput.courtId, () =>
this.cancelBookingExclusive(parsedInput)
)
}
async getPendingBooking(courtId: string) {
this.getAdapter(courtId)
const pending = await this.pendingBookingStore.get(courtId)
if (pending?.status === 'release-confirmed') {
await this.pendingBookingStore.clear(courtId)
return null
}
return pending
}
private async createBookingExclusive(parsedInput: CreateBookingInput) {
if (await this.pendingBookingStore.get(parsedInput.courtId)) {
throw new Error('当前球场已有待支付订单,请先取消释放')
}
const adapter = this.getAdapter(parsedInput.courtId)
if (!adapter.createBooking) throw new Error('当前球场尚未接入下单流程')
const settings = await this.settingsStore.get(
adapter.court.id,
adapter.defaultSettings
)
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') {
throw new Error('目标时段已不可预约,请刷新列表')
}
if (slot.enrollment) throw new Error('活动时段不能通过普通场地接口下单')
if (slot.priceCents !== parsedInput.expectedPriceCents) {
throw new Error('价格已变化,请刷新列表并重新确认金额')
}
adapter.preflightWriteSettings?.(settings)
const uncertainBooking = {
courtId: parsedInput.courtId,
orderId: `uncertain:${Date.now()}`,
venueAppointmentIds: [],
amountCents: slot.priceCents,
courtLabel: slot.courtLabel,
startTime: slot.startTime,
endTime: slot.endTime,
status: 'submitting-uncertain' as const
}
await this.pendingBookingStore.set(uncertainBooking)
let result
try {
result = await adapter.createBooking(slot, settings)
} catch (error) {
if (error instanceof ProviderMutationError && error.outcome === 'rejected') {
await this.pendingBookingStore.clear(parsedInput.courtId)
throw error
}
throw new Error(
`下单结果无法确认,已保留恢复记录;请刷新列表后使用“尝试释放”。${error instanceof Error ? ` 原因:${error.message}` : ''}`
)
}
try {
await this.pendingBookingStore.set(result.booking)
} catch {
try {
await this.pendingBookingStore.set({
...uncertainBooking,
status: 'release-uncertain'
})
await adapter.cancelBooking?.(settings)
this.releaseConfirmedCourts.add(parsedInput.courtId)
await this.pendingBookingStore.set({
...uncertainBooking,
status: 'release-confirmed'
})
await this.pendingBookingStore.clear(parsedInput.courtId)
this.releaseConfirmedCourts.delete(parsedInput.courtId)
} catch {
throw new Error('锁场成功但本机记录和自动释放均失败,请立即到小程序检查订单')
}
throw new Error('锁场后的本机记录失败,场地已自动释放')
}
return result.booking
}
private async cancelBookingExclusive(input: CancelBookingInput) {
const pending = await this.pendingBookingStore.get(input.courtId)
if (!pending || pending.orderId !== input.orderId) {
throw new Error('没有找到对应的待支付锁场订单')
}
if (
pending.status === 'release-confirmed' ||
this.releaseConfirmedCourts.has(input.courtId) ||
pending.status === 'release-uncertain'
) {
await this.pendingBookingStore.clear(input.courtId)
this.releaseConfirmedCourts.delete(input.courtId)
return
}
const adapter = this.getAdapter(input.courtId)
if (!adapter.cancelBooking) throw new Error('当前球场尚未接入取消流程')
const settings = await this.settingsStore.get(
adapter.court.id,
adapter.defaultSettings
)
adapter.preflightWriteSettings?.(settings)
await this.pendingBookingStore.set({ ...pending, status: 'release-uncertain' })
try {
await adapter.cancelBooking(settings)
} catch (error) {
if (error instanceof ProviderMutationError && error.outcome === 'rejected') {
await this.pendingBookingStore.set(pending)
throw error
}
throw new Error(
`释放结果无法确认;为避免重复释放新订单,已停止自动重试。请先到小程序核实,再清理本机记录。${error instanceof Error ? ` 原因:${error.message}` : ''}`
)
}
this.releaseConfirmedCourts.add(input.courtId)
await this.pendingBookingStore.set({ ...pending, status: 'release-confirmed' })
await this.pendingBookingStore.clear(input.courtId)
this.releaseConfirmedCourts.delete(input.courtId)
}
private parseCreateBookingInput(input: unknown): CreateBookingInput {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
typeof input.date !== 'string' ||
typeof input.slotId !== 'string' ||
typeof input.expectedPriceCents !== 'number' ||
!Number.isSafeInteger(input.expectedPriceCents) ||
input.expectedPriceCents < 0
) {
throw new Error('下单参数格式错误')
}
return {
courtId: input.courtId,
date: input.date,
slotId: input.slotId,
expectedPriceCents: input.expectedPriceCents
}
}
private parseCancelBookingInput(input: unknown): CancelBookingInput {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
typeof input.orderId !== 'string'
) {
throw new Error('取消锁场参数格式错误')
}
return { courtId: input.courtId, orderId: input.orderId }
}
private async runExclusive<T>(courtId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.courtOperations.get(courtId) ?? Promise.resolve()
const result = previous.catch(() => undefined).then(operation)
const tail = result.then(() => undefined, () => undefined)
this.courtOperations.set(courtId, tail)
try {
return await result
} finally {
if (this.courtOperations.get(courtId) === tail) {
this.courtOperations.delete(courtId)
}
}
}
private getAdapter(courtId: string) {
const adapter = this.adapters.get(courtId)
if (!adapter) throw new Error(`Unknown court adapter: ${courtId}`)
return adapter
}
}

View File

@@ -0,0 +1,38 @@
import type {
AvailabilityDay,
AvailabilityQuery,
BookingSlot,
PendingBooking,
CourtSummary
} from '../../shared/contracts'
import type { CourtRuntimeSettings } from '../settings/types'
export interface AdapterBookingResult {
booking: PendingBooking
paymentScript: unknown
}
export class ProviderMutationError extends Error {
constructor(
message: string,
readonly outcome: 'rejected' | 'uncertain'
) {
super(message)
this.name = 'ProviderMutationError'
}
}
export interface CourtAdapter {
readonly court: CourtSummary
readonly defaultSettings: CourtRuntimeSettings
getAvailability(
query: AvailabilityQuery,
settings: CourtRuntimeSettings
): Promise<AvailabilityDay>
preflightWriteSettings?(settings: CourtRuntimeSettings): void
createBooking?(
slot: BookingSlot,
settings: CourtRuntimeSettings
): Promise<AdapterBookingResult>
cancelBooking?(settings: CourtRuntimeSettings): Promise<void>
}

View File

@@ -0,0 +1,32 @@
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import type { PendingBooking } from '../../shared/contracts'
import { PendingBookingStore } from './PendingBookingStore'
const booking: PendingBooking = {
courtId: 'court-a',
orderId: 'order-1',
venueAppointmentIds: ['order-1'],
amountCents: 8000,
courtLabel: '1号场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-16 08:10:00',
status: 'pending-payment'
}
describe('PendingBookingStore', () => {
it('可在重启式重新实例化后恢复和清除待支付订单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-pending-'))
const filePath = join(directory, 'pending.json')
const store = new PendingBookingStore(filePath)
await store.set(booking)
const reloaded = new PendingBookingStore(filePath)
await expect(reloaded.get('court-a')).resolves.toEqual(booking)
await reloaded.clear('court-a')
await expect(reloaded.get('court-a')).resolves.toBeNull()
})
})

View File

@@ -0,0 +1,99 @@
import { dirname } from 'node:path'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import type { PendingBooking } from '../../shared/contracts'
interface BookingFile {
version: 1
courts: Record<string, PendingBooking>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseBooking(value: unknown): PendingBooking {
if (
!isRecord(value) ||
typeof value.courtId !== 'string' ||
typeof value.orderId !== 'string' ||
!Array.isArray(value.venueAppointmentIds) ||
!value.venueAppointmentIds.every((id) => typeof id === 'string') ||
typeof value.amountCents !== 'number' ||
typeof value.courtLabel !== 'string' ||
typeof value.startTime !== 'string' ||
typeof value.endTime !== 'string' ||
(value.expiresAt !== undefined && typeof value.expiresAt !== 'string') ||
![
'submitting-uncertain',
'pending-payment',
'release-uncertain',
'release-confirmed'
].includes(
String(value.status)
)
) {
throw new Error('本机待支付订单记录格式错误')
}
return value as unknown as PendingBooking
}
export class PendingBookingStore {
private writeQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
async get(courtId: string): Promise<PendingBooking | null> {
return (await this.read()).courts[courtId] ?? null
}
set(booking: PendingBooking): Promise<void> {
return this.write((file) => {
file.courts[booking.courtId] = booking
})
}
clear(courtId: string): Promise<void> {
return this.write((file) => {
delete file.courts[courtId]
})
}
private async write(update: (file: BookingFile) => void): Promise<void> {
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<BookingFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
if (!isRecord(value) || value.version !== 1 || !isRecord(value.courts)) {
throw new Error('本机待支付订单文件格式错误')
}
return {
version: 1,
courts: Object.fromEntries(
Object.entries(value.courts).map(([courtId, booking]) => [
courtId,
parseBooking(booking)
])
)
}
} catch (error) {
if (isRecord(error) && error.code === 'ENOENT') {
return { version: 1, courts: {} }
}
throw error
}
}
}

91
src/main/index.ts Normal file
View File

@@ -0,0 +1,91 @@
import { join } from 'node:path'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { AdapterRegistry } from './adapters/registry'
import { CourtSettingsStore } from './settings/CourtSettingsStore'
import { PendingBookingStore } from './bookings/PendingBookingStore'
import {
IPC_CHANNELS,
type AvailabilityQuery
} from '../shared/contracts'
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1440,
height: 920,
minWidth: 1040,
minHeight: 680,
show: false,
backgroundColor: '#f4f1e8',
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
mainWindow.once('ready-to-show', () => mainWindow.show())
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https://')) void shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event) => event.preventDefault())
if (process.env.ELECTRON_RENDERER_URL) {
void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
const settingsStore = new CourtSettingsStore(
join(app.getPath('userData'), 'court-settings.json')
)
const pendingBookingStore = new PendingBookingStore(
join(app.getPath('userData'), 'pending-bookings.json')
)
const registry = new AdapterRegistry(settingsStore, pendingBookingStore)
ipcMain.handle(IPC_CHANNELS.listCourts, () => registry.listCourts())
ipcMain.handle(
IPC_CHANNELS.getAvailability,
(_event, query: AvailabilityQuery) => registry.getAvailability(query)
)
ipcMain.handle(
IPC_CHANNELS.getCourtSettings,
(_event, courtId: unknown) => {
if (typeof courtId !== 'string') throw new Error('球场标识格式错误')
return registry.getCourtSettings(courtId)
}
)
ipcMain.handle(
IPC_CHANNELS.updateCourtSettings,
(_event, input: unknown) => registry.updateCourtSettings(input)
)
ipcMain.handle(
IPC_CHANNELS.createBooking,
(_event, input: unknown) => registry.createBooking(input)
)
ipcMain.handle(
IPC_CHANNELS.cancelBooking,
(_event, input: unknown) => registry.cancelBooking(input)
)
ipcMain.handle(
IPC_CHANNELS.getPendingBooking,
(_event, courtId: unknown) => {
if (typeof courtId !== 'string') throw new Error('球场标识格式错误')
return registry.getPendingBooking(courtId)
}
)
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

View File

@@ -0,0 +1,67 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { CourtSettingsStore } from './CourtSettingsStore'
describe('CourtSettingsStore', () => {
it('未配置时返回对应球场的默认值', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-settings-'))
const store = new CourtSettingsStore(join(directory, 'court-settings.json'))
await expect(store.get('court-a', {
courtId: 'court-a'
})).resolves.toEqual({ courtId: 'court-a' })
})
it('按球场隔离并持久化访客凭证', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-settings-'))
const filePath = join(directory, 'court-settings.json')
const store = new CourtSettingsStore(filePath)
await store.update({
courtId: 'court-a',
visitorId: 'court-a-secret'
})
await store.update({ courtId: 'court-b' })
const reloaded = new CourtSettingsStore(filePath)
await expect(reloaded.get('court-a', {
courtId: 'court-a'
})).resolves.toEqual({
courtId: 'court-a',
visitorId: 'court-a-secret'
})
await expect(reloaded.get('court-b', {
courtId: 'court-b'
})).resolves.toEqual({ courtId: 'court-b' })
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as unknown
expect(persisted).toEqual({
version: 1,
courts: {
'court-a': { visitorId: 'court-a-secret' },
'court-b': {}
}
})
})
it('忽略旧版暴露的场地 ID只迁移访客凭证', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-settings-legacy-'))
const filePath = join(directory, 'court-settings.json')
await writeFile(filePath, JSON.stringify({
version: 1,
courts: {
'court-a': { userId: '6038652', visitorId: 'legacy-secret' }
}
}))
const store = new CourtSettingsStore(filePath)
await expect(store.get('court-a', {
courtId: 'court-a'
})).resolves.toEqual({
courtId: 'court-a',
visitorId: 'legacy-secret'
})
})
})

View File

@@ -0,0 +1,81 @@
import { dirname } from 'node:path'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import type { CourtRuntimeSettings } from './types'
interface SettingsFile {
version: 1
courts: Record<string, { visitorId?: string }>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseSettingsFile(value: unknown): SettingsFile {
if (!isRecord(value) || value.version !== 1 || !isRecord(value.courts)) {
throw new Error('本机球场配置文件格式错误')
}
const courts: SettingsFile['courts'] = {}
for (const [courtId, settings] of Object.entries(value.courts)) {
if (
!isRecord(settings) ||
(settings.visitorId !== undefined && typeof settings.visitorId !== 'string')
) {
throw new Error(`球场 ${courtId} 的本机配置格式错误`)
}
courts[courtId] = {
visitorId: settings.visitorId
}
}
return { version: 1, courts }
}
export class CourtSettingsStore {
private writeQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
async get(
courtId: string,
defaults: CourtRuntimeSettings
): Promise<CourtRuntimeSettings> {
const file = await this.read()
return {
courtId,
visitorId: file.courts[courtId]?.visitorId ?? defaults.visitorId
}
}
async update(settings: CourtRuntimeSettings): Promise<CourtRuntimeSettings> {
const operation = this.writeQueue.then(async () => {
const file = await this.read()
file.courts[settings.courtId] = {
visitorId: settings.visitorId
}
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)
await operation
return settings
}
private async read(): Promise<SettingsFile> {
try {
return parseSettingsFile(JSON.parse(await readFile(this.filePath, 'utf8')))
} catch (error) {
if (isRecord(error) && error.code === 'ENOENT') {
return { version: 1, courts: {} }
}
throw error
}
}
}

View File

@@ -0,0 +1,4 @@
export interface CourtRuntimeSettings {
courtId: string
visitorId?: string
}

27
src/preload/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { contextBridge, ipcRenderer } from 'electron'
import {
IPC_CHANNELS,
type AvailabilityQuery,
type CancelBookingInput,
type CreateBookingInput,
type TennisBookApi,
type UpdateCourtSettingsInput
} from '../shared/contracts'
const api: TennisBookApi = {
listCourts: () => ipcRenderer.invoke(IPC_CHANNELS.listCourts),
getAvailability: (query: AvailabilityQuery) =>
ipcRenderer.invoke(IPC_CHANNELS.getAvailability, query),
getCourtSettings: (courtId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.getCourtSettings, courtId),
updateCourtSettings: (input: UpdateCourtSettingsInput) =>
ipcRenderer.invoke(IPC_CHANNELS.updateCourtSettings, input),
createBooking: (input: CreateBookingInput) =>
ipcRenderer.invoke(IPC_CHANNELS.createBooking, input),
cancelBooking: (input: CancelBookingInput) =>
ipcRenderer.invoke(IPC_CHANNELS.cancelBooking, input),
getPendingBooking: (courtId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.getPendingBooking, courtId)
}
contextBridge.exposeInMainWorld('tennisBook', api)

16
src/renderer/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: http://localhost:*"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tennis Book</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

792
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,792 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
CalendarDays,
ChevronDown,
CircleAlert,
CircleCheck,
Clock3,
Command,
Eye,
EyeOff,
MapPin,
LockKeyhole,
RefreshCw,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
SunMedium,
UnlockKeyhole,
Warehouse,
Wifi
} from 'lucide-react'
import type {
AvailabilityDay,
BookingSlot,
CourtSettings,
CourtStatus,
CourtSummary,
PendingBooking
} from '../../shared/contracts'
const dateFormatter = new Intl.DateTimeFormat('zh-CN', {
month: 'short',
day: 'numeric'
})
const weekdayFormatter = new Intl.DateTimeFormat('zh-CN', { weekday: 'short' })
const priceFormatter = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
maximumFractionDigits: 0
})
interface DateOption {
value: string
date: Date
relativeLabel?: string
}
function toLocalDateValue(date: Date) {
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 createDateOptions(): DateOption[] {
return Array.from({ length: 7 }, (_, index) => {
const date = new Date()
date.setHours(12, 0, 0, 0)
date.setDate(date.getDate() + index)
return {
value: toLocalDateValue(date),
date,
relativeLabel: index === 0 ? '今天' : index === 1 ? '明天' : undefined
}
})
}
const dateOptions = createDateOptions()
const tennisBookApi = window.tennisBook
const statusCopy: Record<CourtStatus, string> = {
online: '可查询',
maintenance: '维护中',
'login-required': '需登录'
}
function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback
}
function CourtIcon({ court }: { court: CourtSummary }) {
return (
<div className="court-monogram" style={{ '--court-accent': court.accent } as React.CSSProperties}>
<span>{court.shortName.slice(0, 1)}</span>
<div className="court-lines" aria-hidden="true" />
</div>
)
}
function CourtRow({
court,
selected,
onSelect
}: {
court: CourtSummary
selected: boolean
onSelect: () => void
}) {
const metadata = [
court.district,
court.distanceKm === undefined ? undefined : `${court.distanceKm} km`
].filter(Boolean).join(' · ')
return (
<button
className={`court-row ${selected ? 'is-selected' : ''}`}
onClick={onSelect}
type="button"
>
<CourtIcon court={court} />
<span className="court-row-copy">
<span className="court-row-title">
{court.name}
{court.indoor ? <Warehouse size={13} aria-label="室内" /> : null}
</span>
<span className="court-row-meta">{metadata || '场地信息待补充'}</span>
</span>
<span className={`status-dot status-${court.status}`} title={statusCopy[court.status]} />
</button>
)
}
function SlotCell({
slot,
selected,
onSelect
}: {
slot?: BookingSlot
selected: boolean
onSelect: () => void
}) {
if (!slot) return <div className="slot-cell slot-empty"></div>
const isAvailable = slot.status === 'available' || slot.status === 'limited'
const bookingContacts = slot.status === 'booked' ? slot.bookedBy ?? [] : []
const enrollment = slot.enrollment
return (
<button
className={`slot-cell slot-${slot.status} ${enrollment ? 'has-enrollment' : ''} ${selected ? 'is-selected' : ''}`}
disabled={!isAvailable}
onClick={onSelect}
type="button"
>
{enrollment ? (
<span className="enrollment-card">
<span className="enrollment-topline">
<span className="enrollment-tag"></span>
<span className="enrollment-count">{enrollment.enrolledCount}/{enrollment.capacity} </span>
</span>
<strong>{enrollment.title}</strong>
<span className="enrollment-meta">
{enrollment.startTime}{enrollment.endTime} · {priceFormatter.format(enrollment.feeCents / 100)}/
</span>
<span className="enrollment-players">
{enrollment.participantNames.length > 0
? enrollment.participantNames.join(' · ')
: '暂无报名'}
</span>
</span>
) : bookingContacts.length > 0 ? (
<span className="booking-contacts">
{bookingContacts.map((contact, index) => (
<span className="booking-contact" key={`${contact.name ?? ''}:${contact.phone ?? ''}:${index}`}>
<strong>{contact.name ?? '未留昵称'}</strong>
<small>{contact.phone ?? '未留手机号'}</small>
</span>
))}
</span>
) : (
<span className="slot-price">{priceFormatter.format(slot.priceCents / 100)}</span>
)}
{enrollment ? null : (
<span className="slot-state">
{slot.status === 'available'
? '可订'
: slot.status === 'limited'
? '紧张'
: slot.unavailableLabel ?? '不可订'}
</span>
)}
</button>
)
}
export function App() {
const [courts, setCourts] = useState<CourtSummary[]>([])
const [selectedCourtId, setSelectedCourtId] = useState('')
const [selectedDate, setSelectedDate] = useState(dateOptions[0]?.value ?? '')
const [availability, setAvailability] = useState<AvailabilityDay | null>(null)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null)
const [refreshToken, setRefreshToken] = useState(0)
const [settingsOpen, setSettingsOpen] = useState(false)
const [courtSettings, setCourtSettings] = useState<CourtSettings | null>(null)
const [visitorIdInput, setVisitorIdInput] = useState('')
const [visitorIdVisible, setVisitorIdVisible] = useState(false)
const [settingsLoading, setSettingsLoading] = useState(false)
const [settingsSaving, setSettingsSaving] = useState(false)
const [settingsError, setSettingsError] = useState<string | null>(null)
const [pendingBooking, setPendingBooking] = useState<PendingBooking | null>(null)
const [bookingConfirmOpen, setBookingConfirmOpen] = useState(false)
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false)
const [bookingBusy, setBookingBusy] = useState(false)
const [bookingError, setBookingError] = useState<string | null>(null)
const settingsRequestId = useRef(0)
useEffect(() => {
if (!tennisBookApi) {
setLoading(false)
return
}
let active = true
tennisBookApi
.listCourts()
.then((items) => {
if (!active) return
setCourts(items)
setSelectedCourtId((current) => current || items[0]?.id || '')
})
.catch(() => {
if (!active) return
setError('球场列表加载失败,请稍后重试。')
setLoading(false)
})
return () => {
active = false
}
}, [])
useEffect(() => {
if (!tennisBookApi || !selectedCourtId || !selectedDate) return
let active = true
setLoading(true)
setError(null)
setAvailability(null)
setSelectedSlotId(null)
tennisBookApi
.getAvailability({ courtId: selectedCourtId, date: selectedDate })
.then((result) => active && setAvailability(result))
.catch(() => {
if (!active) return
setAvailability(null)
setError('可订时段加载失败,请检查登录状态或网络。')
})
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [selectedCourtId, selectedDate, refreshToken])
useEffect(() => {
if (!tennisBookApi || !selectedCourtId) return
let active = true
setPendingBooking(null)
tennisBookApi.getPendingBooking(selectedCourtId)
.then((booking) => active && setPendingBooking(booking))
.catch(() => active && setBookingError('待支付订单状态读取失败。'))
return () => {
active = false
}
}, [selectedCourtId])
const selectedCourt = courts.find((court) => court.id === selectedCourtId)
const filteredCourts = useMemo(() => {
const keyword = search.trim().toLowerCase()
if (!keyword) return courts
return courts.filter((court) =>
`${court.name}${court.district ?? ''}${court.address ?? ''}`.toLowerCase().includes(keyword)
)
}, [courts, search])
const schedule = useMemo(() => {
const slots = availability?.slots ?? []
const courtLabels = [...new Set(slots.map((slot) => slot.courtLabel))]
const times = [...new Set(slots.map((slot) => slot.startTime))]
const slotMap = new Map(slots.map((slot) => [`${slot.startTime}:${slot.courtLabel}`, slot]))
return { courtLabels, times, slotMap }
}, [availability])
const availableCount = availability?.slots.filter(
(slot) => slot.status === 'available' || slot.status === 'limited'
).length ?? 0
const selectedSlot = availability?.slots.find((slot) => slot.id === selectedSlotId)
const refresh = useCallback(() => setRefreshToken((value) => value + 1), [])
const closeSettings = useCallback(() => {
settingsRequestId.current += 1
setSettingsOpen(false)
setCourtSettings(null)
setVisitorIdInput('')
setVisitorIdVisible(false)
}, [])
const openSettings = useCallback(async () => {
if (!tennisBookApi || !selectedCourtId) return
const requestId = settingsRequestId.current + 1
settingsRequestId.current = requestId
setSettingsOpen(true)
setSettingsLoading(true)
setSettingsError(null)
setCourtSettings(null)
setVisitorIdInput('')
setVisitorIdVisible(false)
try {
const settings = await tennisBookApi.getCourtSettings(selectedCourtId)
if (settingsRequestId.current !== requestId) return
setCourtSettings(settings)
setVisitorIdInput(settings.visitorId ?? '')
} catch {
if (settingsRequestId.current !== requestId) return
setSettingsError('球场配置读取失败,请稍后重试。')
} finally {
if (settingsRequestId.current === requestId) setSettingsLoading(false)
}
}, [selectedCourtId])
const saveSettings = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!tennisBookApi || !courtSettings) return
setSettingsSaving(true)
setSettingsError(null)
try {
const visitorId = visitorIdInput.trim()
const saved = await tennisBookApi.updateCourtSettings({
courtId: courtSettings.courtId,
...(visitorId ? { visitorId } : {})
})
setCourtSettings(saved)
setVisitorIdInput('')
closeSettings()
setRefreshToken((value) => value + 1)
} catch (error) {
setSettingsError(getErrorMessage(
error,
'配置保存失败,请检查访客凭证。'
))
} finally {
setSettingsSaving(false)
}
}, [closeSettings, courtSettings, visitorIdInput])
const createBooking = useCallback(async () => {
if (!tennisBookApi || !selectedSlot || !selectedCourtId) return
setBookingBusy(true)
setBookingError(null)
try {
const booking = await tennisBookApi.createBooking({
courtId: selectedCourtId,
date: selectedDate,
slotId: selectedSlot.id,
expectedPriceCents: selectedSlot.priceCents
})
setPendingBooking(booking)
setBookingConfirmOpen(false)
setSelectedSlotId(null)
setRefreshToken((value) => value + 1)
} catch (error) {
setBookingError(getErrorMessage(error, '下单锁场失败,请刷新后重试。'))
const recoveryBooking = await tennisBookApi.getPendingBooking(selectedCourtId)
if (recoveryBooking?.status === 'submitting-uncertain') {
setPendingBooking(recoveryBooking)
setBookingConfirmOpen(false)
setSelectedSlotId(null)
}
} finally {
setBookingBusy(false)
}
}, [selectedCourtId, selectedDate, selectedSlot])
const cancelBooking = useCallback(async () => {
if (!tennisBookApi || !pendingBooking) return
setBookingBusy(true)
setBookingError(null)
try {
await tennisBookApi.cancelBooking({
courtId: pendingBooking.courtId,
orderId: pendingBooking.orderId
})
setPendingBooking(null)
setCancelConfirmOpen(false)
setRefreshToken((value) => value + 1)
} catch (error) {
setBookingError(getErrorMessage(error, '取消锁场失败,请稍后重试。'))
const latestPending = await tennisBookApi.getPendingBooking(pendingBooking.courtId)
setPendingBooking(latestPending)
} finally {
setBookingBusy(false)
}
}, [pendingBooking])
if (!tennisBookApi) {
return (
<main className="fatal-error">
<span>PRELOAD ERROR</span>
<h1></h1>
<p>preload </p>
<small></small>
</main>
)
}
return (
<div className="app-shell">
<header className="titlebar">
<div className="traffic-light-space" aria-hidden="true" />
<div className="brand-mark"><span>TB</span></div>
<div className="titlebar-name">Tennis Book</div>
<div className="titlebar-center">
<span className="connection-pill"><Wifi size={12} /> 线</span>
</div>
<button
aria-label="球场接口配置"
className="icon-button titlebar-action"
disabled={!selectedCourtId}
onClick={() => void openSettings()}
title="球场接口配置"
type="button"
>
<Settings2 size={16} />
</button>
</header>
<aside className="sidebar">
<div className="sidebar-heading">
<div>
<span className="eyebrow">COURTS</span>
<h1></h1>
</div>
<button className="icon-button" type="button" aria-label="球场筛选">
<SlidersHorizontal size={16} />
</button>
</div>
<label className="search-box">
<Search size={15} />
<input
aria-label="搜索球场"
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索球场或区域"
value={search}
/>
<kbd><Command size={10} /> K</kbd>
</label>
<div className="court-count-row">
<span>{filteredCourts.length} </span>
<span className="legend"><i /> </span>
</div>
<nav className="court-list" aria-label="球场列表">
{filteredCourts.map((court) => (
<CourtRow
court={court}
key={court.id}
onSelect={() => setSelectedCourtId(court.id)}
selected={court.id === selectedCourtId}
/>
))}
</nav>
<div className="sidebar-footer">
<div className="avatar">R</div>
<div><strong></strong><span></span></div>
<ChevronDown size={14} />
</div>
</aside>
<main className="workspace">
{selectedCourt ? (
<>
<section className="court-hero">
<div className="hero-identity">
<CourtIcon court={selectedCourt} />
<div>
<div className="hero-title-row">
<h2>{selectedCourt.name}</h2>
<span className={`status-badge badge-${selectedCourt.status}`}>
{selectedCourt.status === 'online' ? <CircleCheck size={13} /> : <CircleAlert size={13} />}
{statusCopy[selectedCourt.status]}
</span>
</div>
<p><MapPin size={14} /> {selectedCourt.address ?? '地址待补充'}</p>
</div>
</div>
<div className="hero-stats">
<div><span></span><strong>{selectedCourt.surface ?? '待补充'}</strong></div>
<div><span></span><strong>{loading ? '—' : `${availableCount}`}</strong></div>
<button className="refresh-button" onClick={refresh} type="button">
<RefreshCw className={loading ? 'is-spinning' : ''} size={15} />
</button>
</div>
</section>
<section className="booking-panel">
<div className="date-strip" aria-label="选择日期">
{dateOptions.map((option) => (
<button
className={option.value === selectedDate ? 'is-selected' : ''}
key={option.value}
onClick={() => setSelectedDate(option.value)}
type="button"
>
<span>{option.relativeLabel ?? weekdayFormatter.format(option.date)}</span>
<strong>{dateFormatter.format(option.date)}</strong>
</button>
))}
<button className="calendar-button" type="button" aria-label="打开日历">
<CalendarDays size={18} />
</button>
</div>
<div className="schedule-toolbar">
<div>
<span className="section-kicker"><Sparkles size={13} /> LIVE AVAILABILITY</span>
<h3></h3>
</div>
<div className="schedule-legend">
<span><i className="available" /></span>
<span><i className="activity" /></span>
<span><i className="booked" /></span>
<span><i className="blocked" /></span>
</div>
</div>
{error ? <div className="error-state"><CircleAlert size={18} />{error}</div> : null}
{bookingError ? (
<div className="error-state booking-error">
<CircleAlert size={18} />
<span>{bookingError}</span>
<button onClick={() => setBookingError(null)} type="button"></button>
</div>
) : null}
{loading ? (
<div className="loading-grid" aria-label="正在加载时段">
{Array.from({ length: 28 }, (_, index) => <i key={index} />)}
</div>
) : (
<div className="schedule-scroll">
<div
className="schedule-grid"
style={{ '--court-columns': schedule.courtLabels.length } as React.CSSProperties}
>
<div className="grid-corner"><Clock3 size={14} /> </div>
{schedule.courtLabels.map((label) => <div className="court-column-head" key={label}>{label}</div>)}
{schedule.times.map((time) => (
<div className="schedule-row" key={time}>
<div className="time-label"><strong>{time}</strong><span>60 </span></div>
{schedule.courtLabels.map((label) => {
const slot = schedule.slotMap.get(`${time}:${label}`)
return (
<SlotCell
key={label}
onSelect={() => slot && setSelectedSlotId(slot.id)}
selected={slot?.id === selectedSlotId}
slot={slot}
/>
)
})}
</div>
))}
</div>
</div>
)}
</section>
{pendingBooking ? (
<footer className="pending-booking-bar">
<span className="pending-lock-icon"><LockKeyhole size={17} /></span>
<div className="pending-booking-copy">
<span>{pendingBooking.status === 'submitting-uncertain'
? '下单结果待确认'
: pendingBooking.status === 'release-uncertain'
? '释放结果待确认'
: '已锁场 · 待支付'}</span>
<strong>
{pendingBooking.startTime}{pendingBooking.endTime} · {pendingBooking.courtLabel}
</strong>
<small>{pendingBooking.status === 'submitting-uncertain'
? '网络响应不确定,请刷新列表后尝试释放该访客凭证下的锁场'
: pendingBooking.status === 'release-uncertain'
? '请先到小程序核实是否已释放,再清理本机记录;系统不会自动重试'
: `订单 ${pendingBooking.orderId} · 支付有效期至 ${pendingBooking.expiresAt}`}</small>
</div>
<div className="selection-price">
{priceFormatter.format(pendingBooking.amountCents / 100)}
</div>
<button
className="release-booking-button"
disabled={bookingBusy}
onClick={() => setCancelConfirmOpen(true)}
type="button"
><UnlockKeyhole size={14} />{pendingBooking.status === 'submitting-uncertain'
? '尝试释放'
: pendingBooking.status === 'release-uncertain'
? '核实后清理'
: '取消并释放'}</button>
</footer>
) : (
<footer className={`selection-bar ${selectedSlot ? 'is-visible' : ''}`}>
<div className="selection-summary">
<span></span>
<strong>{selectedSlot
? selectedSlot.enrollment
? `${selectedSlot.enrollment.title} · ${selectedSlot.courtLabel}`
: `${selectedSlot.startTime}${selectedSlot.endTime} · ${selectedSlot.courtLabel}`
: '选择一个可订时段'}</strong>
</div>
<div className="selection-price">
{selectedSlot
? priceFormatter.format(
(selectedSlot.enrollment?.feeCents ?? selectedSlot.priceCents) / 100
)
: '—'}
</div>
<button
disabled={!selectedSlot || Boolean(selectedSlot.enrollment)}
onClick={() => setBookingConfirmOpen(true)}
type="button"
>{selectedSlot?.enrollment ? '活动报名暂未接入' : '下单锁场'}</button>
</footer>
)}
</>
) : (
error ? (
<div className="empty-workspace empty-error">
<CircleAlert size={28} />
<strong></strong>
<span>{error}</span>
</div>
) : (
<div className="empty-workspace"><SunMedium size={28} /></div>
)
)}
</main>
{settingsOpen ? (
<div
className="settings-backdrop"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !settingsSaving) {
closeSettings()
}
}}
role="presentation"
>
<form className="settings-dialog" onSubmit={(event) => void saveSettings(event)}>
<div className="settings-dialog-head">
<span className="settings-icon"><Settings2 size={17} /></span>
<div>
<span className="eyebrow">COURT CONNECTION</span>
<h2></h2>
</div>
</div>
<div className="settings-court-card">
<span></span>
<strong>{selectedCourt?.name ?? '未选择球场'}</strong>
<small></small>
</div>
{settingsLoading ? (
<div className="settings-loading"></div>
) : (
<label className="settings-field">
<span className="settings-field-title">
PSPLVISITORID
<i className={courtSettings?.visitorIdConfigured ? 'is-configured' : ''}>
{courtSettings?.visitorIdConfigured ? '已配置' : '未配置'}
</i>
</span>
<span className="credential-input-wrap">
<input
autoFocus
autoComplete="off"
disabled={settingsSaving || !courtSettings}
onChange={(event) => setVisitorIdInput(event.target.value)}
placeholder="粘贴该球场的 PSPLVISITORID"
type={visitorIdVisible ? 'text' : 'password'}
value={visitorIdInput}
/>
<button
aria-label={visitorIdVisible ? '隐藏 PSPLVISITORID' : '显示 PSPLVISITORID'}
disabled={settingsSaving || !visitorIdInput}
onClick={() => setVisitorIdVisible((visible) => !visible)}
title={visitorIdVisible ? '隐藏凭证' : '显示凭证'}
type="button"
>{visitorIdVisible ? <EyeOff size={15} /> : <Eye size={15} />}</button>
</span>
<small></small>
</label>
)}
{settingsError ? (
<div className="settings-error"><CircleAlert size={15} />{settingsError}</div>
) : null}
<div className="settings-actions">
<button
disabled={settingsSaving}
onClick={closeSettings}
type="button"
></button>
<button
className="save-settings-button"
disabled={
settingsLoading ||
settingsSaving ||
!courtSettings ||
!visitorIdInput.trim()
}
type="submit"
>{settingsSaving ? '保存中…' : '保存并刷新'}</button>
</div>
</form>
</div>
) : null}
{bookingConfirmOpen && selectedSlot ? (
<div className="settings-backdrop" role="presentation">
<section className="booking-confirm-dialog" role="dialog" aria-modal="true">
<span className="confirm-lock-icon"><LockKeyhole size={20} /></span>
<span className="eyebrow">CREATE VENUE ORDER</span>
<h2></h2>
<p></p>
<div className="confirm-booking-summary">
<div><span></span><strong>{selectedCourt?.name} · {selectedSlot.courtLabel}</strong></div>
<div><span></span><strong>{selectedDate} {selectedSlot.startTime}{selectedSlot.endTime}</strong></div>
<div><span></span><strong>{priceFormatter.format(selectedSlot.priceCents / 100)}</strong></div>
</div>
{bookingError ? (
<div className="settings-error"><CircleAlert size={15} />{bookingError}</div>
) : null}
<div className="settings-actions">
<button
disabled={bookingBusy}
onClick={() => setBookingConfirmOpen(false)}
type="button"
></button>
<button
className="save-settings-button"
disabled={bookingBusy}
onClick={() => void createBooking()}
type="button"
>{bookingBusy ? '正在锁场…' : '确认下单锁场'}</button>
</div>
</section>
</div>
) : null}
{cancelConfirmOpen && pendingBooking ? (
<div className="settings-backdrop" role="presentation">
<section className="booking-confirm-dialog release-dialog" role="dialog" aria-modal="true">
<span className="confirm-lock-icon"><UnlockKeyhole size={20} /></span>
<span className="eyebrow">RELEASE VENUE HOLD</span>
<h2>{pendingBooking.status === 'release-uncertain'
? '确认已经在小程序核实?'
: '取消订单并释放场地?'}</h2>
<p>{pendingBooking.status === 'release-uncertain'
? '这里只清理本机的待确认记录,不会再次调用释放接口。请确认场地已经释放或你已自行处理。'
: '将调用该球场的释放接口。成功后当前待支付订单不能继续用于支付。'}</p>
<div className="confirm-booking-summary">
<div><span></span><strong>{pendingBooking.status === 'submitting-uncertain'
? '结果待确认'
: pendingBooking.orderId}</strong></div>
<div><span></span><strong>{pendingBooking.courtLabel}</strong></div>
<div><span></span><strong>{pendingBooking.startTime}{pendingBooking.endTime}</strong></div>
</div>
{bookingError ? (
<div className="settings-error"><CircleAlert size={15} />{bookingError}</div>
) : null}
<div className="settings-actions">
<button
disabled={bookingBusy}
onClick={() => setCancelConfirmOpen(false)}
type="button"
></button>
<button
className="danger-action-button"
disabled={bookingBusy}
onClick={() => void cancelBooking()}
type="button"
>{bookingBusy
? '处理中…'
: pendingBooking.status === 'release-uncertain'
? '已核实,清理记录'
: '确认取消并释放'}</button>
</div>
</section>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Renderer failed to start', error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<main className="fatal-error">
<span>APPLICATION ERROR</span>
<h1></h1>
<p>{this.state.error.message}</p>
<small></small>
</main>
)
}
}

15
src/renderer/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@fontsource-variable/manrope'
import '@fontsource-variable/newsreader'
import { App } from './App'
import { AppErrorBoundary } from './components/AppErrorBoundary'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</React.StrictMode>
)

399
src/renderer/src/styles.css Normal file
View File

@@ -0,0 +1,399 @@
:root {
color-scheme: light;
font-family: 'Manrope Variable', sans-serif;
color: #173328;
background: #f4f1e8;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #173328;
--ink-soft: #496157;
--muted: #7d8b83;
--canvas: #f4f1e8;
--paper: #fffef9;
--sidebar: #edf1e7;
--line: #dfe4da;
--line-strong: #cbd4c7;
--green: #174f3a;
--green-bright: #257452;
--tennis: #d7f45c;
--tennis-soft: #eff9c9;
--coral: #b95b48;
--coral-soft: #fff0eb;
--sand: #9b7541;
--sand-soft: #f8f0df;
--shadow: 0 12px 32px rgba(28, 61, 47, .08);
}
* { box-sizing: border-box; }
html, body, #root { min-width: 100%; min-height: 100%; margin: 0; }
body { overflow: hidden; }
button, input { font: inherit; }
button { color: inherit; }
.app-shell {
display: grid;
grid-template: 52px minmax(0, 1fr) / 278px minmax(0, 1fr);
height: 100vh;
background: var(--canvas);
}
.titlebar {
-webkit-app-region: drag;
z-index: 20;
grid-column: 1 / -1;
display: grid;
grid-template-columns: 72px 29px 220px 1fr 40px;
align-items: center;
height: 52px;
padding: 0 12px 0 0;
border-bottom: 1px solid var(--line);
background: rgba(255, 254, 249, .94);
backdrop-filter: blur(18px);
}
.traffic-light-space { width: 72px; }
.brand-mark {
display: grid;
place-items: center;
width: 29px;
height: 29px;
border-radius: 9px;
color: var(--tennis);
background: var(--green);
box-shadow: 0 4px 10px rgba(23, 79, 58, .18);
}
.brand-mark span { font-size: 9px; font-weight: 850; letter-spacing: -.04em; }
.titlebar-name { margin-left: 10px; color: var(--ink); font-size: 12px; font-weight: 750; }
.titlebar-center { justify-self: center; }
.connection-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid #d8e6d9;
border-radius: 999px;
color: #5e7567;
background: #f5faf1;
font-size: 9px;
font-weight: 650;
}
.connection-pill svg { color: #32905f; }
.titlebar-action, .icon-button, .refresh-button, .calendar-button { -webkit-app-region: no-drag; }
.icon-button {
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: 9px;
color: #6f8076;
background: transparent;
cursor: pointer;
transition: .15s ease;
}
.icon-button:hover { border-color: var(--line); color: var(--green); background: #f8faf5; }
.icon-button:disabled { cursor: not-allowed; opacity: .38; }
.sidebar {
display: flex;
min-height: 0;
flex-direction: column;
border-right: 1px solid #d9dfd4;
background:
linear-gradient(rgba(255,255,255,.16), rgba(255,255,255,0)),
var(--sidebar);
}
.sidebar-heading { display: flex; align-items: center; justify-content: space-between; padding: 27px 18px 16px 20px; }
.eyebrow, .section-kicker { color: #809083; font-size: 9px; font-weight: 800; letter-spacing: .16em; }
.sidebar-heading h1 { margin: 5px 0 0; font-family: 'Newsreader Variable', serif; font-size: 25px; font-weight: 560; letter-spacing: -.035em; }
.search-box {
display: flex;
align-items: center;
gap: 9px;
height: 38px;
margin: 0 14px;
padding: 0 10px;
border: 1px solid #d7ded3;
border-radius: 10px;
color: #809087;
background: rgba(255,255,255,.68);
box-shadow: 0 2px 8px rgba(31,68,51,.025);
}
.search-box:focus-within { border-color: #9db49f; box-shadow: 0 0 0 3px rgba(37,116,82,.08); }
.search-box input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--ink); background: transparent; font-size: 11px; }
.search-box input::placeholder { color: #94a197; }
.search-box kbd { display: flex; align-items: center; gap: 2px; padding: 3px 5px; border: 1px solid #dce2d8; border-radius: 5px; color: #9aa59d; background: #fafbf8; font-size: 9px; }
.court-count-row { display: flex; justify-content: space-between; padding: 19px 20px 8px; color: #809087; font-size: 9px; text-transform: uppercase; letter-spacing: .06em; }
.legend { display: flex; align-items: center; gap: 5px; }
.legend i { width: 6px; height: 6px; border-radius: 50%; background: #35a16a; box-shadow: 0 0 0 3px rgba(53,161,106,.1); }
.court-list { min-height: 0; flex: 1; overflow-y: auto; padding: 4px 8px; }
.court-row {
position: relative;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 8px;
align-items: center;
width: 100%;
gap: 11px;
padding: 10px;
border: 1px solid transparent;
border-radius: 13px;
text-align: left;
background: transparent;
cursor: pointer;
transition: transform .16s ease, border-color .16s ease, background .16s ease, box-shadow .16s ease;
}
.court-row:hover { transform: translateY(-1px); background: rgba(255,255,255,.48); }
.court-row.is-selected { border-color: #d9e2d4; background: var(--paper); box-shadow: 0 7px 20px rgba(31,68,51,.07); }
.court-row.is-selected::before { position: absolute; left: -9px; width: 3px; height: 29px; border-radius: 3px; background: var(--green); content: ''; }
.court-monogram {
--court-accent: var(--tennis);
position: relative;
display: grid;
place-items: center;
width: 42px;
height: 42px;
overflow: hidden;
border-radius: 11px;
color: var(--tennis);
background: var(--green);
}
.court-monogram > span { z-index: 1; font-family: 'Newsreader Variable', serif; font-size: 18px; font-weight: 700; }
.court-lines { position: absolute; inset: 7px; border: 1px solid rgba(215,244,92,.3); border-radius: 2px; }
.court-lines::after { position: absolute; top: 50%; left: 0; width: 100%; border-top: 1px solid rgba(215,244,92,.3); content: ''; }
.court-row-copy { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.court-row-title { display: flex; align-items: center; gap: 6px; overflow: hidden; color: #254236; font-size: 11px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.court-row-title svg { flex: none; color: #809087; }
.court-row-meta { color: #829087; font-size: 9px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #a1aba3; }
.status-online { background: #35a16a; box-shadow: 0 0 0 3px rgba(53,161,106,.1); }
.status-login-required { background: #d1a047; }
.status-maintenance { background: #a1aba3; }
.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; }
.sidebar-footer strong { color: #40594d; font-size: 10px; font-weight: 700; }
.sidebar-footer span { font-size: 8px; }
.workspace { position: relative; min-width: 0; min-height: 0; overflow: hidden; padding-bottom: 70px; background: var(--canvas); }
.workspace::before {
position: absolute;
top: -180px;
right: -100px;
width: 520px;
height: 520px;
border: 1px solid rgba(23,79,58,.05);
border-radius: 50%;
content: '';
pointer-events: none;
}
.court-hero {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 120px;
padding: 25px 30px 20px;
border-bottom: 1px solid var(--line);
background: rgba(255,254,249,.72);
}
.hero-identity { display: flex; align-items: center; gap: 15px; }
.hero-identity .court-monogram { width: 56px; height: 56px; border-radius: 15px; box-shadow: 0 8px 18px rgba(23,79,58,.15); }
.hero-identity .court-monogram > span { font-size: 24px; }
.hero-title-row { display: flex; align-items: center; gap: 10px; }
.hero-title-row h2 { margin: 0; color: var(--ink); font-family: 'Newsreader Variable', serif; font-size: 30px; font-weight: 560; letter-spacing: -.04em; }
.hero-identity p { display: flex; align-items: center; gap: 5px; margin: 6px 0 0; color: #78887e; font-size: 10px; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 999px; color: #78887e; background: var(--paper); font-size: 9px; font-weight: 650; }
.badge-online { border-color: #cfe4d4; color: #27764f; background: #f1f9ef; }
.badge-login-required { color: #a77729; }
.hero-stats { display: flex; align-items: center; gap: 22px; }
.hero-stats > div { display: flex; flex-direction: column; gap: 4px; min-width: 75px; padding-right: 22px; border-right: 1px solid var(--line); }
.hero-stats span { color: #819087; font-size: 9px; }
.hero-stats strong { color: #29473a; font-size: 11px; font-weight: 720; }
.refresh-button { display: flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px; border: 1px solid #d5ddd2; border-radius: 9px; color: #496157; background: var(--paper); font-size: 10px; font-weight: 650; cursor: pointer; transition: .15s ease; }
.refresh-button:hover { transform: translateY(-1px); border-color: #aebdaf; color: var(--green); box-shadow: 0 5px 12px rgba(31,68,51,.06); }
.is-spinning { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.booking-panel { position: relative; z-index: 1; display: flex; height: calc(100% - 120px); min-height: 0; flex-direction: column; padding: 0 30px; }
.date-strip { display: flex; flex: none; gap: 7px; padding: 16px 0; border-bottom: 1px solid var(--line); }
.date-strip > button { display: flex; min-width: 78px; height: 52px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 3px; padding: 0 12px; border: 1px solid transparent; border-radius: 11px; color: #7c8b82; background: transparent; cursor: pointer; transition: .15s ease; }
.date-strip > button:hover { border-color: #e0e4db; background: rgba(255,255,255,.55); }
.date-strip > button span { font-size: 9px; }
.date-strip > button strong { color: #496157; font-size: 11px; font-weight: 720; }
.date-strip > button.is-selected { border-color: var(--green); color: #4e675a; background: var(--tennis-soft); box-shadow: 0 5px 12px rgba(52,91,43,.06); }
.date-strip > button.is-selected strong { color: var(--green); }
.date-strip .calendar-button { min-width: 48px; align-items: center; color: #667a6e; background: rgba(255,255,255,.45); }
.schedule-toolbar { display: flex; flex: none; align-items: flex-end; justify-content: space-between; padding: 19px 0 12px; }
.section-kicker { display: flex; align-items: center; gap: 5px; color: #728479; }
.section-kicker svg { color: var(--green-bright); }
.schedule-toolbar h3 { margin: 4px 0 0; color: var(--ink); font-family: 'Newsreader Variable', serif; font-size: 23px; font-weight: 560; }
.schedule-legend { display: flex; gap: 15px; color: #718077; font-size: 9px; }
.schedule-legend span { display: flex; align-items: center; gap: 5px; }
.schedule-legend i { width: 7px; height: 7px; border-radius: 2px; }
.schedule-legend .available { background: #56a778; }
.schedule-legend .activity { background: #5d79c7; }
.schedule-legend .booked { background: #dc8e7d; }
.schedule-legend .blocked { background: #c9aa76; }
.schedule-scroll { min-height: 0; overflow: auto; padding: 0 1px 26px; }
.schedule-grid { display: grid; grid-template-columns: 72px repeat(var(--court-columns), minmax(156px, 1fr)); min-width: 1040px; gap: 7px; }
.grid-corner { position: sticky; z-index: 5; top: 0; left: 0; display: flex; align-items: center; gap: 6px; padding: 10px 7px; color: #7b8a81; background: var(--canvas); font-size: 9px; }
.court-column-head { position: sticky; z-index: 4; top: 0; padding: 10px; color: #4f675a; background: var(--canvas); font-size: 9px; font-weight: 750; text-align: center; }
.schedule-row { display: contents; }
.time-label { position: sticky; z-index: 3; left: 0; display: flex; flex-direction: column; justify-content: center; min-height: 96px; padding: 8px 7px; background: var(--canvas); }
.time-label strong { color: #2b493c; font-size: 11px; font-weight: 760; }
.time-label span { margin-top: 2px; color: #929e96; font-size: 8px; }
.slot-cell {
position: relative;
display: flex;
min-width: 0;
min-height: 96px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 10px;
overflow: hidden;
border: 1px solid #dce3d9;
border-radius: 11px;
background: var(--paper);
box-shadow: 0 2px 8px rgba(31,68,51,.025);
cursor: pointer;
opacity: 1;
transition: transform .14s ease, border-color .14s ease, box-shadow .14s ease;
}
.slot-cell:not(:disabled):hover { transform: translateY(-2px); border-color: #83a88e; box-shadow: 0 7px 16px rgba(31,68,51,.08); }
.slot-cell.is-selected { border-color: var(--green); background: var(--tennis-soft); box-shadow: 0 0 0 2px rgba(23,79,58,.08); }
.slot-price { color: #234d39; font-family: 'Newsreader Variable', serif; font-size: 17px; font-weight: 650; }
.slot-state { flex: none; padding: 3px 6px; border-radius: 999px; color: #27704d; background: #e8f5e9; font-size: 8px; font-weight: 750; }
.slot-limited { border-color: #ead4aa; background: #fffbef; }
.slot-limited .slot-state { color: #9a6e28; background: #f8e8be; }
.slot-cell.has-enrollment { align-items: stretch; border-color: #cfd8f0; background: #f2f5ff; }
.slot-cell.has-enrollment:hover { border-color: #7890cf; box-shadow: 0 7px 18px rgba(54,78,144,.12); }
.slot-cell.has-enrollment.is-selected { border-color: #4e69b4; background: #eaf0ff; box-shadow: 0 0 0 2px rgba(78,105,180,.1); }
.enrollment-card { display: flex; min-width: 0; width: 100%; flex-direction: column; gap: 5px; text-align: left; }
.enrollment-topline { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.enrollment-tag { padding: 3px 6px; border-radius: 999px; color: #fff; background: #536fb9; font-size: 8px; font-weight: 800; letter-spacing: .04em; }
.enrollment-count { color: #52658f; font-size: 8px; font-weight: 750; }
.enrollment-card > strong { display: -webkit-box; overflow: hidden; color: #293d78; font-size: 10px; font-weight: 800; line-height: 1.3; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.enrollment-meta { color: #5a6e9f; font-size: 8px; font-weight: 650; white-space: nowrap; }
.enrollment-players { overflow: hidden; color: #7382a8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.slot-booked { align-items: flex-start; border-color: #efd4cc; background: var(--coral-soft); cursor: default; }
.slot-booked .slot-state { color: #a84f3e; background: #f9d9d1; }
.booking-contacts { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 5px; }
.booking-contact { display: flex; min-width: 0; flex-direction: column; gap: 2px; text-align: left; }
.booking-contact strong { overflow: hidden; color: #743b30; font-size: 10px; font-weight: 780; text-overflow: ellipsis; white-space: nowrap; }
.booking-contact small { color: #a45d4e; font-size: 9px; font-weight: 620; letter-spacing: .015em; white-space: nowrap; }
.slot-blocked { border-color: #eadfc9; background: var(--sand-soft); cursor: default; }
.slot-blocked .slot-price { color: #876839; font-size: 15px; }
.slot-blocked .slot-state { color: #8a6632; background: #ecddbf; }
.slot-empty { justify-content: center; color: #aeb8b0; background: rgba(255,255,255,.35); box-shadow: none; }
.loading-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; overflow: hidden; }
.loading-grid i { height: 96px; border-radius: 11px; background: linear-gradient(90deg, #eeece5 20%, #faf9f4 50%, #eeece5 80%); background-size: 220% 100%; animation: shimmer 1.4s infinite linear; }
@keyframes shimmer { to { background-position: -220% 0; } }
.error-state { display: flex; align-items: center; gap: 8px; margin: 16px 0; padding: 12px; border: 1px solid #efcfc5; border-radius: 10px; color: #a84f3e; background: var(--coral-soft); font-size: 10px; }
.booking-error span { flex: 1; }
.booking-error button { padding: 3px 7px; border: 1px solid #e2b9ae; border-radius: 6px; color: #9f4c3b; background: transparent; font-size: 8px; cursor: pointer; }
.selection-bar { position: absolute; z-index: 8; right: 22px; bottom: 14px; left: 22px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 20px; min-height: 58px; padding: 8px 9px 8px 17px; border: 1px solid #d3ddd1; border-radius: 14px; background: rgba(255,254,249,.96); box-shadow: 0 16px 40px rgba(32,65,50,.14); backdrop-filter: blur(16px); transform: translateY(90px); opacity: 0; transition: transform .22s ease, opacity .22s ease; }
.selection-bar.is-visible { transform: translateY(0); opacity: 1; }
.selection-summary { display: flex; flex-direction: column; gap: 3px; }
.selection-summary span { color: #829087; font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
.selection-summary strong { color: #2a493b; font-size: 11px; font-weight: 720; }
.selection-price { color: var(--green); font-family: 'Newsreader Variable', serif; font-size: 21px; font-weight: 650; }
.selection-bar button { height: 40px; padding: 0 17px; border: 0; border-radius: 10px; color: var(--tennis); background: var(--green); font-size: 10px; font-weight: 780; cursor: pointer; box-shadow: 0 6px 14px rgba(23,79,58,.17); }
.selection-bar button:disabled { cursor: not-allowed; opacity: .48; }
.pending-booking-bar { position: absolute; z-index: 8; right: 22px; bottom: 14px; left: 22px; display: grid; grid-template-columns: 38px 1fr auto auto; align-items: center; gap: 14px; min-height: 68px; padding: 9px 10px 9px 13px; border: 1px solid #cbd9ca; border-radius: 14px; background: rgba(249,253,242,.97); box-shadow: 0 16px 40px rgba(32,65,50,.14); backdrop-filter: blur(16px); }
.pending-lock-icon { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--tennis); background: var(--green); }
.pending-booking-copy { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.pending-booking-copy > span { color: #2d7b52; font-size: 8px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.pending-booking-copy strong { color: #27473a; font-size: 11px; font-weight: 750; }
.pending-booking-copy small { overflow: hidden; color: #7d8c83; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.release-booking-button { display: flex; align-items: center; gap: 6px; height: 40px; padding: 0 13px; border: 1px solid #e4bfb6; border-radius: 10px; color: #a44f3d; background: #fff5f1; font-size: 9px; font-weight: 750; cursor: pointer; }
.release-booking-button:hover:not(:disabled) { border-color: #cc8f80; background: #ffede7; }
.release-booking-button:disabled { cursor: not-allowed; opacity: .5; }
.empty-workspace { display: grid; height: 100%; place-content: center; justify-items: center; gap: 10px; color: #78887e; font-size: 11px; }
.empty-error { color: #a84f3e; }
.empty-error strong { color: #743b30; font-family: 'Newsreader Variable', serif; font-size: 22px; font-weight: 600; }
.empty-error span { color: #8b625a; }
.fatal-error { display: grid; min-height: 100vh; place-content: center; justify-items: start; padding: 48px; background: var(--canvas); }
.fatal-error span { color: var(--green-bright); font-size: 9px; font-weight: 800; letter-spacing: .16em; }
.fatal-error h1 { margin: 8px 0; color: var(--ink); font-family: 'Newsreader Variable', serif; font-size: 32px; font-weight: 560; }
.fatal-error p { max-width: 640px; margin: 0 0 8px; color: #a84f3e; font-size: 12px; }
.fatal-error small { color: #78887e; font-size: 10px; }
.settings-backdrop {
position: fixed;
z-index: 100;
inset: 0;
display: grid;
place-items: center;
padding: 28px;
background: rgba(32, 51, 41, .18);
backdrop-filter: blur(6px);
animation: fade-in .16s ease-out;
}
.settings-dialog {
width: min(440px, 100%);
padding: 24px;
border: 1px solid #d8dfd4;
border-radius: 18px;
background: var(--paper);
box-shadow: 0 28px 80px rgba(26, 55, 42, .22);
animation: dialog-in .2s ease-out;
}
.settings-dialog-head { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.settings-dialog-head h2 { margin: 3px 0 0; color: var(--ink); font-family: 'Newsreader Variable', serif; font-size: 25px; font-weight: 600; letter-spacing: -.03em; }
.settings-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; color: var(--tennis); background: var(--green); box-shadow: 0 7px 16px rgba(23,79,58,.16); }
.settings-court-card { display: flex; flex-direction: column; gap: 4px; padding: 14px 15px; border: 1px solid #dfe6dc; border-radius: 12px; background: #f4f7f0; }
.settings-court-card span { color: #809087; font-size: 8px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.settings-court-card strong { color: #29473a; font-size: 12px; font-weight: 760; }
.settings-court-card small { color: #7e8e84; font-size: 9px; }
.settings-field { display: flex; flex-direction: column; gap: 7px; margin-top: 18px; }
.settings-field > span { color: #40594d; font-size: 10px; font-weight: 760; }
.settings-field-title { display: flex; align-items: center; justify-content: space-between; }
.settings-field-title i { padding: 3px 6px; border-radius: 999px; color: #a35a4b; background: #f9ded7; font-size: 8px; font-style: normal; font-weight: 750; }
.settings-field-title i.is-configured { color: #28744f; background: #e2f2e4; }
.settings-field input { width: 100%; height: 43px; padding: 0 12px; border: 1px solid #ccd6ca; border-radius: 10px; outline: none; color: var(--ink); background: #fff; font-size: 13px; font-weight: 650; letter-spacing: .02em; transition: .15s ease; }
.settings-field input:focus { border-color: #789981; box-shadow: 0 0 0 3px rgba(37,116,82,.09); }
.settings-field input:disabled { color: #87948b; background: #f2f3ef; }
.settings-field small { color: #86938b; font-size: 9px; line-height: 1.5; }
.settings-field > .credential-input-wrap { position: relative; display: block; color: inherit; font-size: inherit; font-weight: inherit; }
.credential-input-wrap input { padding-right: 43px; }
.credential-input-wrap button { position: absolute; top: 50%; right: 7px; display: grid; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px; place-items: center; color: #6e7f74; background: transparent; transform: translateY(-50%); cursor: pointer; transition: color .15s ease, background .15s ease; }
.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; }
.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; }
.settings-actions button { height: 37px; padding: 0 14px; border: 1px solid #d6ddd3; border-radius: 9px; color: #63756b; background: #fff; font-size: 10px; font-weight: 720; cursor: pointer; }
.settings-actions button:hover:not(:disabled) { border-color: #aebcaf; color: var(--green); }
.settings-actions .save-settings-button { min-width: 104px; border-color: var(--green); color: var(--tennis); background: var(--green); box-shadow: 0 6px 14px rgba(23,79,58,.14); }
.settings-actions button:disabled { cursor: not-allowed; opacity: .48; }
.booking-confirm-dialog { width: min(430px, 100%); padding: 27px; border: 1px solid #d8dfd4; border-radius: 19px; background: var(--paper); box-shadow: 0 28px 80px rgba(26,55,42,.22); text-align: center; animation: dialog-in .2s ease-out; }
.confirm-lock-icon { display: grid; place-items: center; width: 46px; height: 46px; margin: 0 auto 13px; border-radius: 14px; color: var(--tennis); background: var(--green); box-shadow: 0 8px 18px rgba(23,79,58,.17); }
.booking-confirm-dialog h2 { margin: 6px 0 7px; color: var(--ink); font-family: 'Newsreader Variable', serif; font-size: 25px; font-weight: 600; letter-spacing: -.035em; }
.booking-confirm-dialog > p { max-width: 330px; margin: 0 auto; color: #75857c; font-size: 10px; line-height: 1.65; }
.confirm-booking-summary { display: grid; gap: 0; margin-top: 20px; overflow: hidden; border: 1px solid #dfe6dc; border-radius: 12px; background: #f6f8f3; text-align: left; }
.confirm-booking-summary > div { display: grid; grid-template-columns: 58px 1fr; gap: 10px; align-items: center; min-height: 39px; padding: 7px 12px; border-bottom: 1px solid #e3e8e0; }
.confirm-booking-summary > div:last-child { border-bottom: 0; }
.confirm-booking-summary span { color: #839188; font-size: 9px; }
.confirm-booking-summary strong { overflow: hidden; color: #345044; font-size: 10px; font-weight: 720; text-overflow: ellipsis; white-space: nowrap; }
.booking-confirm-dialog .settings-actions { justify-content: center; }
.booking-confirm-dialog .settings-actions button { min-width: 112px; }
.release-dialog .confirm-lock-icon { color: #fff1ec; background: var(--coral); }
.settings-actions .danger-action-button { min-width: 125px; border-color: var(--coral); color: #fff; background: var(--coral); box-shadow: 0 6px 14px rgba(185,91,72,.18); }
@keyframes fade-in { from { opacity: 0; } }
@keyframes dialog-in { from { transform: translateY(8px) scale(.985); opacity: 0; } }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 9px; background: #c9d1c8; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: #aeb9af; background-clip: padding-box; }
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 250px minmax(0, 1fr); }
.hero-stats > div:first-child { display: none; }
.date-strip > button { min-width: 68px; padding: 0 9px; }
.schedule-grid { grid-template-columns: 68px repeat(var(--court-columns), minmax(150px, 1fr)); }
}

122
src/shared/contracts.ts Normal file
View File

@@ -0,0 +1,122 @@
export type CourtStatus = 'online' | 'maintenance' | 'login-required'
export interface CourtSummary {
id: string
name: string
shortName: string
district?: string
address?: string
distanceKm?: number
surface?: string
indoor?: boolean
accent: string
status: CourtStatus
}
export type SlotStatus = 'available' | 'limited' | 'booked' | 'blocked'
export interface BookingContact {
name?: string
phone?: string
}
export interface EnrollmentActivity {
id: string
title: string
startTime: string
endTime: string
feeCents: number
enrolledCount: number
capacity: number
participantNames: string[]
enrollmentDeadline?: string
}
export interface BookingSlot {
id: string
courtLabel: string
startTime: string
endTime: string
priceCents: number
status: SlotStatus
unavailableLabel?: string
bookedBy?: BookingContact[]
enrollment?: EnrollmentActivity
providerReference?: {
classroomUid: string
beginDatetime: string
endDatetime: string
}
sourceLabel?: string
}
export interface AvailabilityDay {
courtId: string
date: string
updatedAt: string
slots: BookingSlot[]
}
export interface AvailabilityQuery {
courtId: string
date: string
}
export interface CourtSettings {
courtId: string
visitorId?: string
visitorIdConfigured: boolean
}
export interface UpdateCourtSettingsInput {
courtId: string
visitorId?: string
}
export interface CreateBookingInput {
courtId: string
date: string
slotId: string
expectedPriceCents: number
}
export interface CancelBookingInput {
courtId: string
orderId: string
}
export interface PendingBooking {
courtId: string
orderId: string
venueAppointmentIds: string[]
amountCents: number
courtLabel: string
startTime: string
endTime: string
expiresAt?: string
status:
| 'submitting-uncertain'
| 'pending-payment'
| 'release-uncertain'
| 'release-confirmed'
}
export interface TennisBookApi {
listCourts(): Promise<CourtSummary[]>
getAvailability(query: AvailabilityQuery): Promise<AvailabilityDay>
getCourtSettings(courtId: string): Promise<CourtSettings>
updateCourtSettings(input: UpdateCourtSettingsInput): Promise<CourtSettings>
createBooking(input: CreateBookingInput): Promise<PendingBooking>
cancelBooking(input: CancelBookingInput): Promise<void>
getPendingBooking(courtId: string): Promise<PendingBooking | null>
}
export const IPC_CHANNELS = {
listCourts: 'courts:list',
getAvailability: 'courts:availability',
getCourtSettings: 'court-settings:get',
updateCourtSettings: 'court-settings:update',
createBooking: 'bookings:create',
cancelBooking: 'bookings:cancel',
getPendingBooking: 'bookings:pending'
} as const

9
src/shared/global.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { TennisBookApi } from './contracts'
declare global {
interface Window {
tennisBook: TennisBookApi
}
}
export {}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"jsx": "react-jsx",
"types": ["node", "vite/client"]
},
"include": ["electron.vite.config.ts", "src/**/*.ts", "src/**/*.tsx"]
}