-
Notifications
You must be signed in to change notification settings - Fork 0
Hook System
本文档详细介绍 StudyWithMiku 的钩子系统,包括事件驱动架构、触发器与 Provider 模型、引擎匹配逻辑、内置预设、服务端推送以及 Protobuf 消息定义。
钩子系统采用 事件驱动 + Provider 模式,监听番茄钟生命周期事件,自动执行通知、提示音、推送和 E-Stim 等操作。核心流程为:focusEventBus 发出事件 → useHooks 接收并转换为触发器 → hookEngine 匹配钩子 → providerRegistry 分发到具体 Provider 执行。
flowchart TB
subgraph FocusSystem["番茄钟系统"]
focusEventBus["focusEventBus<br/>transition / tick 事件"]
end
subgraph HookSystem["钩子系统"]
useHooks["useHooks.js<br/>主 Composable(单例)"]
hookEngine["hookEngine.js<br/>纯函数:映射 / 评估 / 分发"]
providerRegistry["providerRegistry.js<br/>Provider 注册表(Map 单例)"]
end
subgraph Providers["Provider 实现"]
notification["notification.js<br/>浏览器通知"]
sound["sound.js<br/>提示音播放"]
push["push.js<br/>服务端推送"]
estim["estim.js<br/>E-Stim 控制"]
end
focusEventBus -->|"on('transition')<br/>on('tick')"| useHooks
useHooks -->|"mapTransitionToTrigger()"| hookEngine
hookEngine -->|"evaluateHooks()"| hookEngine
hookEngine -->|"dispatchToProviders()"| providerRegistry
providerRegistry --> notification
providerRegistry --> sound
providerRegistry --> push
providerRegistry --> estim
src/composables/hooks/
├── constants.js # 枚举常量、触发器分组、存储键
├── hookEngine.js # 纯函数:事件映射、钩子评估、Provider 分发
├── providerRegistry.js # Provider 注册表(Map 单例)
├── useHooks.js # 主 composable(单例模式)
├── presets.js # 默认钩子与预设模板
└── providers/ # Provider 实现
├── notification.js # 浏览器 Notification API
├── sound.js # 音频播放
├── push.js # Web Push(服务端推送)
└── estim.js # E-Stim 设备控制
src/components/settings/tabs/hooks/
├── HookList.vue # 钩子列表
├── HookEditor.vue # 钩子编辑器
├── HookPresets.vue # 预设模板选择
└── HookProviderConfig.vue # Provider 配置面板
// src/composables/hooks/constants.js
export const HookTrigger = {
FOCUS_START: 'focus_start', FOCUS_PAUSE: 'focus_pause',
FOCUS_RESUME: 'focus_resume', FOCUS_COMPLETED: 'focus_completed',
FOCUS_CANCELLED: 'focus_cancelled', FOCUS_SKIPPED: 'focus_skipped',
FOCUS_TICK: 'focus_tick',
BREAK_START: 'break_start', BREAK_COMPLETED: 'break_completed',
BREAK_CANCELLED: 'break_cancelled', BREAK_SKIPPED: 'break_skipped',
BREAK_TICK: 'break_tick'
}export const HookProvider = {
NOTIFICATION: 'notification', // 浏览器通知
SOUND: 'sound', // 提示音
PUSH: 'push', // 服务端推送(Web Push)
ESTIM: 'estim' // E-Stim 设备
}| 分组 | 包含的触发器 |
|---|---|
| 专注组 |
focus_start, focus_pause, focus_resume, focus_completed, focus_cancelled, focus_skipped, focus_tick
|
| 休息组 |
break_start, break_completed, break_cancelled, break_skipped, break_tick
|
export const HOOKS_STORAGE_KEYS = {
HOOKS: 'swm_hooks', // 钩子配置列表
ESTIM_UNLOCKED: 'swm_coyote_unlocked' // E-Stim 功能解锁标志
}纯函数模块,不持有任何状态,导出三个函数。
将 focusEventBus 发出的原始事件映射为 HookTrigger 枚举值。根据 mode 决定前缀(focus 还是 shortBreak/longBreak → break),再将 action 映射为后缀:
| action | mode = focus
|
mode = shortBreak / longBreak
|
|---|---|---|
start |
focus_start |
break_start |
pause |
focus_pause |
(不适用) |
resume |
focus_resume |
(不适用) |
complete |
focus_completed |
break_completed |
cancel |
focus_cancelled |
break_cancelled |
skip |
focus_skipped |
break_skipped |
tick |
focus_tick |
break_tick |
过滤出所有匹配当前触发器的钩子,评估条件:
- enabled — 钩子必须处于启用状态
-
trigger 匹配 — 钩子的
trigger字段与当前触发器相同 -
tick 间隔检查 — 对于
focus_tick/break_tick触发器,context.elapsed必须是hook.tickInterval的非零整数倍
export const evaluateHooks = (hooks, trigger, context) => {
return hooks.filter(hook => {
if (!hook.enabled) return false
if (hook.trigger !== trigger) return false
// tick 间隔检查
if (trigger === HookTrigger.FOCUS_TICK || trigger === HookTrigger.BREAK_TICK) {
if (hook.tickInterval && hook.tickInterval > 0) {
if (context.elapsed === 0) return false
if (context.elapsed % hook.tickInterval !== 0) return false
}
}
return true
})
}遍历匹配的钩子,从 providerRegistry 获取对应 Provider,检查 isAvailable() 后调用 execute(hook, context)。
flowchart TB
Event["focusEventBus 事件<br/>{action, mode, elapsed, ...}"]
Map["mapTransitionToTrigger()"]
Evaluate["evaluateHooks()"]
CheckEnabled{"enabled?"}
CheckTrigger{"trigger 匹配?"}
CheckTick{"tick 间隔满足?"}
Dispatch["dispatchToProviders()"]
Execute["provider.execute(hook, context)"]
Event --> Map --> Evaluate
Evaluate --> CheckEnabled
CheckEnabled -->|"否"| Skip1["跳过"]
CheckEnabled -->|"是"| CheckTrigger
CheckTrigger -->|"否"| Skip2["跳过"]
CheckTrigger -->|"是"| CheckTick
CheckTick -->|"否"| Skip3["跳过"]
CheckTick -->|"是"| Dispatch --> Execute
基于 Map 的单例注册表,管理所有 Provider 的生命周期。
{
id: string, // 唯一标识,与 HookProvider 枚举值匹配
init?: () => void, // 注册时调用
destroy?: () => void, // 注销时调用
isAvailable?: () => boolean, // 是否可用(如权限检查)
execute: (hook, context) => void // 执行钩子动作(必须实现)
}| 参数 | 类型 | 说明 |
|---|---|---|
hook |
Object | 完整的钩子配置,包含 action 字段 |
context |
Object | 事件上下文:elapsed、duration、mode 等 |
| 方法 | 说明 |
|---|---|
register(provider) |
注册 Provider(必须有 id),调用 provider.init()
|
unregister(id) |
注销 Provider,调用 provider.destroy()
|
get(id) |
获取 Provider 实例 |
getAll() |
获取所有 Provider 实例数组 |
has(id) |
检查是否已注册 |
clear() |
清除所有 Provider(逐个调用 destroy()) |
flowchart LR
Register["register()"] --> Init["init()"]
Init -.->|"等待触发"| Available["isAvailable()?"]
Available -->|"true"| Execute["execute()"]
Available -->|"false"| Skip["跳过"]
Execute -.->|"注销"| Unregister["unregister()"] --> Destroy["destroy()"]
钩子系统的统一入口,采用单例模式。负责 Provider 注册、钩子加载/持久化、事件订阅和 CRUD 操作。
flowchart TB
Start["useHooks() 首次调用"]
RegBuiltin["注册 3 个内置 Provider<br/>notification / sound / push"]
CheckEstim{"swm_coyote_unlocked<br/>=== 'true' ?"}
RegEstim["注册 estim Provider"]
SkipEstim["跳过 estim"]
LoadHooks["从 localStorage 加载钩子<br/>key: swm_hooks"]
HasData{"有数据?"}
Seed["种子 DEFAULT_HOOKS(4 个内置钩子)"]
UseLoaded["使用已加载的钩子"]
Subscribe["订阅 focusEventBus<br/>on('transition') / on('tick')"]
Start --> RegBuiltin --> CheckEstim
CheckEstim -->|"是"| RegEstim --> LoadHooks
CheckEstim -->|"否"| SkipEstim --> LoadHooks
LoadHooks --> HasData
HasData -->|"否"| Seed --> Subscribe
HasData -->|"是"| UseLoaded --> Subscribe
useHooks 订阅 focusEventBus 的两种事件,在回调中依次调用 hookEngine 的三个函数完成匹配和分发:
focusEventBus.on('transition', ({ action, mode, ...rest }) => {
const trigger = mapTransitionToTrigger(action, mode)
if (!trigger) return
const matched = evaluateHooks(hooks.value, trigger, { action, mode, ...rest })
dispatchToProviders(matched, { action, mode, ...rest })
})
focusEventBus.on('tick', ({ mode, elapsed, duration, ...rest }) => {
const trigger = mapTransitionToTrigger('tick', mode)
if (!trigger) return
const matched = evaluateHooks(hooks.value, trigger, { mode, elapsed, duration, ...rest })
dispatchToProviders(matched, { mode, elapsed, duration, ...rest })
})| 名称 | 类型 | 说明 |
|---|---|---|
hooks |
Ref<Hook[]> (readonly) |
当前所有钩子列表 |
addHook(hook) |
Method → id
|
添加自定义钩子,返回生成的 ID |
updateHook(id, updates) |
Method → boolean
|
更新指定钩子的属性 |
removeHook(id) |
Method → boolean
|
删除钩子(built-in 不可删除) |
clearCustomHooks() |
Method | 清除所有自定义钩子,保留 built-in |
applyPreset(preset) |
Method | 应用预设模板,创建新钩子 |
getHooksByProvider(providerId) |
Method → Hook[]
|
按 Provider 过滤钩子 |
loadFromCloud(cloudData) |
Method | 从云端数据加载钩子(覆盖本地) |
getHooksData() |
Method → Object
|
获取钩子数据用于云同步 { hooks: [...] }
|
providerRegistry |
Object | 暴露 Provider 注册表实例 |
每次 CRUD 操作完成后,persistHooks() 会同步执行两步:
- 持久化到 localStorage(
swm_hooks) - 异步调用
uploadData('hook_settings', getHooksData())上传到云端
loadFromCloud(cloudData) 用于从云端覆盖本地钩子数据。
初次使用时自动创建,builtIn: true,不可删除。
| ID | 名称 | Provider | 触发器 | Action 参数 |
|---|---|---|---|---|
__builtin_focus_complete_notif |
专注完成通知 | notification |
focus_completed |
(默认通知) |
__builtin_break_complete_notif |
休息结束通知 | notification |
break_completed |
(默认通知) |
__builtin_focus_complete_sound |
专注完成提示音 | sound |
focus_completed |
{soundId: 'chime', volume: 0.7} |
__builtin_break_complete_sound |
休息结束提示音 | sound |
break_completed |
{soundId: 'ding', volume: 0.7} |
需要 E-Stim 功能解锁后才可见。
| 名称 | 触发器 | Action 类型 | 参数 |
|---|---|---|---|
| 暂停惩罚 | focus_pause |
pulse | channel A, 3s |
| 取消惩罚 | focus_cancelled |
pulse | channel A, 5s |
| 完成奖励 | focus_completed |
strength_set | both channels, 30 |
| 专注渐增 |
focus_tick (300s interval) |
strength_increase | channel A, +5 |
| 名称 | 触发器 | Provider |
|---|---|---|
| 专注开始通知 | focus_start |
notification |
| 休息开始提示音 | break_start |
sound |
-
id:
'notification' -
isAvailable:
Notification.permission === 'granted' -
execute: 调用
new Notification(title, { body, tag }),从hook.action读取参数,支持tag去重
-
id:
'sound' - isAvailable: 始终可用
-
execute: 根据
hook.action.soundId(如'chime'、'ding')查找预置音频,以hook.action.volume音量播放
-
id:
'push' - isAvailable: 检查 Service Worker 注册状态和推送订阅
- execute: 将推送请求发送到 FocusNotifier Durable Object,由服务端通过 Web Push 投递,客户端关闭后也能送达
-
id:
'estim' -
isAvailable:
ESTIM_UNLOCKED === 'true'且设备已连接 -
execute: 根据
hook.action.type执行操作
| type | 说明 | 参数 |
|---|---|---|
pulse |
脉冲输出 |
channel, duration_ms, patterns
|
strength_set |
设置强度 |
channel, value
|
strength_increase |
增加强度 |
channel, value
|
服务端推送允许在客户端关闭(如手机锁屏)后,仍然在番茄钟完成时发送通知。核心组件是 FocusNotifier Durable Object,通过 Cloudflare Workers 的 alarm() API 在指定时间触发推送。
flowchart TB
subgraph Client["浏览器"]
ServiceWorker["Service Worker"]
useHooks["useHooks (push provider)"]
end
subgraph CFWorkers["Cloudflare Workers"]
PushRoutes["Push 路由 /api/push/*"]
FocusNotifier["FocusNotifier DO"]
SendPush["sendPushToUser()"]
D1["D1: push_subscriptions"]
end
subgraph PushService["推送服务"]
WebPush["Web Push (FCM / APNs / Mozilla)"]
end
useHooks -->|"subscribe / schedule"| PushRoutes
PushRoutes -->|"转发到 DO"| FocusNotifier
FocusNotifier -->|"alarm()"| SendPush
SendPush --> D1
SendPush -->|"VAPID"| WebPush
WebPush --> ServiceWorker
文件: workers/focus-notifier.js
维护服务端番茄钟状态机。客户端注册定时推送后,即使关闭,DO 也会在指定时间通过 alarm() 发送推送。
DO 内部路由:
| 路由 | 说明 |
|---|---|
/schedule |
注册定时推送(设置 alarm) |
/pause |
暂停计时(取消 alarm,保存剩余时间) |
/resume |
恢复计时(重新设置 alarm) |
/cancel |
取消计时(删除 alarm 和状态) |
/update-settings |
更新推送设置(如 autoStart) |
/state |
查询当前状态 |
alarm() 处理:发送完成推送 → 计算下一阶段 → 如果 autoStart 启用则设置新 alarm,否则等待客户端操作。
文件: workers/routes/push.js
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/push/vapid-key |
无 | 获取 VAPID 公钥 |
| POST | /api/push/subscribe |
Bearer | 存储推送订阅 |
| DELETE | /api/push/subscribe |
Bearer | 删除推送订阅 |
| POST | /api/push/session/schedule |
Bearer | 注册定时推送 |
| POST | /api/push/session/pause |
Bearer | 暂停服务端计时 |
| POST | /api/push/session/resume |
Bearer | 恢复服务端计时 |
| POST | /api/push/session/cancel |
Bearer | 取消服务端计时 |
| GET | /api/push/session/state |
Bearer | 查询服务端计时状态 |
| POST | /api/push/session/update-settings |
Bearer | 更新推送设置 |
| 列 | 类型 | 说明 |
|---|---|---|
| id | text PK | 唯一标识 |
| userId | text FK | 关联用户 |
| endpoint | text | 推送端点 URL |
| p256dh | text | P-256 公钥(Base64URL) |
| auth | text | 认证密钥(Base64URL) |
| createdAt | integer | 创建时间戳 |
| userAgent | text | 用户代理字符串 |
索引: idx_push_subs_user_id (userId)、idx_push_subs_endpoint (endpoint, UNIQUE)
sequenceDiagram
participant B as 浏览器
participant SW as Service Worker
participant S as Workers
participant DO as FocusNotifier DO
participant PS as Push Service
Note over B,S: 1. 订阅推送
B->>S: GET /api/push/vapid-key
S-->>B: VAPID 公钥
B->>SW: pushManager.subscribe({applicationServerKey})
SW-->>B: PushSubscription {endpoint, keys}
B->>S: POST /api/push/subscribe
Note over B,S: 2. 注册定时推送
B->>S: POST /api/push/session/schedule {duration, mode}
S->>DO: 转发,设置 alarm(duration)
Note over B,DO: 3. 客户端可能已关闭
Note over DO,PS: 4. 推送通知
DO->>DO: alarm() 触发
DO->>S: sendPushToUser(userId)
S->>PS: Web Push (VAPID 签名)
PS->>SW: 推送消息
SW->>B: 显示通知
Note over DO: 5. 如果 autoStart,设置新 alarm
studymiku.proto 中 Hook 相关的消息定义,用于云端同步和服务端通信。
enum HookTrigger {
HOOK_TRIGGER_UNSPECIFIED = 0;
HOOK_FOCUS_START = 1; HOOK_FOCUS_PAUSE = 2; HOOK_FOCUS_RESUME = 3;
HOOK_FOCUS_COMPLETED = 4; HOOK_FOCUS_CANCELLED = 5; HOOK_FOCUS_SKIPPED = 6;
HOOK_FOCUS_TICK = 7;
HOOK_BREAK_START = 8; HOOK_BREAK_COMPLETED = 9; HOOK_BREAK_CANCELLED = 10;
HOOK_BREAK_SKIPPED = 11; HOOK_BREAK_TICK = 12;
}
enum HookProvider {
HOOK_PROVIDER_UNSPECIFIED = 0;
PROVIDER_NOTIFICATION = 1; PROVIDER_SOUND = 2;
PROVIDER_PUSH = 3; PROVIDER_ESTIM = 4;
}message NotificationAction {
string title = 1; string body = 2; optional string tag = 3;
}
message SoundAction {
string sound_id = 1; float volume = 2;
}
message PushAction {
string title = 1; string body = 2;
}
message EstimAction {
string type = 1; // pulse / strength_set / strength_increase
string channel = 2; // A / B / both
int32 value = 3;
repeated string patterns = 4;
int32 duration_ms = 5;
optional string audio_file_id = 6;
}message HookAction {
oneof action {
NotificationAction notification = 1;
SoundAction sound = 2;
PushAction push = 3;
EstimAction estim = 4;
}
}
message Hook {
string id = 1; bool enabled = 2;
string name = 3; HookProvider provider = 4;
HookTrigger trigger = 5; int32 tick_interval = 6;
bool built_in = 7; HookAction action = 8;
}
message HookSettings {
repeated Hook hooks = 1;
}| JS 字段 | Protobuf 字段 | 说明 |
|---|---|---|
hook.id |
Hook.id |
string |
hook.enabled |
Hook.enabled |
bool |
hook.name |
Hook.name |
string |
hook.provider |
Hook.provider |
HookProvider enum |
hook.trigger |
Hook.trigger |
HookTrigger enum |
hook.tickInterval |
Hook.tick_interval |
int32,0 表示不使用 |
hook.builtIn |
Hook.built_in |
bool |
hook.action |
Hook.action |
HookAction oneof |
设置界面位于 src/components/settings/tabs/hooks/ 目录,由 4 个组件组成:
| 组件 | 职责 |
|---|---|
| HookList.vue | 钩子列表,支持启用/禁用、删除,built-in 标记为不可删除 |
| HookEditor.vue | 钩子编辑器,含名称、触发器(按 TRIGGER_GROUPS 分组)、Provider、Action 参数 |
| HookPresets.vue | 预设模板面板,展示 GENERAL_PRESETS 和 ESTIM_PRESETS(后者需解锁) |
| HookProviderConfig.vue | Provider 全局配置,如推送订阅管理、E-Stim 设备连接 |
flowchart TB
SettingsPanel["设置面板 (Hooks Tab)"]
HookList["HookList.vue"]
HookEditor["HookEditor.vue"]
HookPresets["HookPresets.vue"]
HookProviderConfig["HookProviderConfig.vue"]
useHooks["useHooks()"]
SettingsPanel --> HookList
SettingsPanel --> HookPresets
SettingsPanel --> HookProviderConfig
HookList -->|"编辑"| HookEditor
HookList --> useHooks
HookEditor --> useHooks
HookPresets -->|"applyPreset"| useHooks
HookProviderConfig -->|"providerRegistry"| useHooks