@mzzsfy/dsh-usage-panel 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,11 +83,11 @@ usage-panel:
83
83
 
84
84
  - webhook:host 直发(Slack-compatible `{text}` + 结构化字段),超时 10 秒不重试,fire-and-forget;面板「保存并测试」返回真实投递结果
85
85
  - dsh-im:安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,面板粘贴 Bot ID 加载其已保存投递目标目录,勾选即自动保存;支持绑定多个 bot,已绑 bot 以标签展示,点标签加载该 bot 目录,× 一键取消注册(移除该 bot 全部目标);触发逻辑与 webhook 完全一致(fire-and-forget 不重试);目标的新建与平台测试仍在 dsh-im 设置页完成,此处仅选择;「测试 IM」逐目标返回真实结果
86
- - 页内 toast:host 内存投影(环形 20 条 / 60 秒过期,version 单调递增),浏览器半区长轮询挂起等待增量并经公共依赖 `@mzzsfy/dsh-toast` 展示(语义与 turn-notify 同构);localStorage 单元级认领锁保证多窗口只弹一次。**toast 依赖由 session-manager 插件代挂**,未安装 session-manager 时此通道静默不可用,其余通道不受影响
86
+ - 页内 toast:host 内存投影(环形 20 条 / 60 秒过期,version 单调递增),浏览器订阅公共依赖 `@mzzsfy/dsh-common-tools` 的 WS 总线 `@mzzsfy/dsh-usage-panel` 插件通道,首连、重连与变更时拉取快照,经其 toast 展示(语义与 turn-notify 同构);同源窗口通过 IndexedDB 事务认领同一事件,提交后仅认领成功的窗口呈现;认领记录自认领起按浏览器本地时间保留 90 秒(60 秒投影窗口 + 30 秒请求消费窗口),不按单窗旧快照删除。快照请求及后续认领共用超时信号,不比较宿主与浏览器时钟。IndexedDB 不可用时控制台通告降级,仅保证本窗口去重。公共依赖由 session-manager 插件代挂装载,为总线订阅的硬依赖。host/client 在 `apply(ctx)` 调用 `setupCommonTools(ctx)`,通道身份来自 `ctx.fiber.entry.options.name` 的完整 npm 包名;client 各实例退出时释放自己的订阅,同一 factory 的存活实例接管一次共享通知工作,最后实例退出才中止共享快照;跨 factory HMR 清理旧代
87
87
 
88
88
  ### 通知接口
89
89
 
90
- - `GET /api/usage-panel/notifications`:通知投影长轮询;`cursor` 缺省=首拉立即返回全量,`cursor` 等于当前版本时挂起至事件或超时,落后或超前(宿主重启版本回退)立即返回全量,客户端以响应 version 重置游标自愈;响应 `{units, version}`
90
+ - `GET /api/usage-panel/notifications`:通知投影即时全量快照,响应 `{units, version}`;变更通知由公共 WS 总线插件空间推送
91
91
  - `GET|POST /api/usage-panel/notify-config`:全局通知规则;`webhookUrl` 原文随响应回显
92
92
  - `POST /api/usage-panel/test-webhook` / `POST /api/usage-panel/test-im`:测试投递,返回真实结果
93
93
  - `GET /api/usage-panel/im-targets?botId=`:列出 dsh-im 该 bot 已保存投递目标
@@ -110,7 +110,7 @@ custom 端点支持自定义请求方法(GET/POST/PUT/DELETE/PATCH)、请求头(
110
110
  ## 架构与依赖
111
111
 
112
112
  - Host 半区(`src/index.js`):Node ESM,`fetch` 直连平台 API(超时 20s),通过 `webServer` 服务暴露 `/api/usage-panel/*` 路由(accounts / query / history / settings / notifications / notify-config / test-webhook / test-im / im-targets);需要 DSH 提供 `webServer` 服务与 `settings` 服务(通知规则持久化);`timer` 服务(定期轮询)为软依赖,缺失时仅停用自动轮询
113
- - Client 半区(`src/client.js`):DSH client-modules 自注册格式(`__ModuleLoader__.load`),注册 `settings.section` 槽位;需要 `slots` 服务与 `react` 18;`@mzzsfy/dsh-toast` 可选消费(动态 require,模块表缺失即页内通道停用,操作反馈降级 `console.warn`)
113
+ - Client 半区(`src/client.js`):DSH client-modules 自注册格式(`__ModuleLoader__.load`),注册 `settings.section` 槽位;需要 `slots` 服务与 `react` 18;公共依赖 `@mzzsfy/dsh-common-tools` 的 WS 总线为硬消费,toast 不可用时操作反馈降级 `console.warn`
114
114
  - 纯逻辑层(`src/parsers.mjs` / `src/poller.mjs` / `src/history.mjs` / `src/historyStore.mjs` / `src/notify.mjs`):无 IO 数据变换,`npm test` 覆盖解析、时间驱动调度与退避、快照留存、持久化守卫与规则合并 / 沿触发评估 / 投影认领 / 配置校验;趋势图 SVG 点位算法内联于 client.js(唯一实现)
115
115
 
116
116
  ## 安全提示
@@ -133,4 +133,4 @@ node scripts/dev-link.mjs dsh-usage-panel # 仓库根执行:归一 profile 依
133
133
 
134
134
  ## dsh 版本兼容
135
135
 
136
- 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:用量面板渲染(账号卡/通知规则折叠卡全控件)、API 面(pollArmed/通知投影)、激活 live。
136
+ 按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-panel",
3
3
  "description": "多平台 LLM 账号余额与额度面板:支持 DeepSeek/OpenRouter/Kimi/智谱/MiniMax/NewApi 及自定义端点,自动定期查询,趋势图与历史快照,用量/余额阈值与窗口重置多通道通知",
4
- "version": "0.8.1",
4
+ "version": "0.8.3",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -16,12 +16,19 @@
16
16
  "client": {
17
17
  "platform": "web",
18
18
  "external": [
19
- "@mzzsfy/dsh-toast/client"
19
+ "@mzzsfy/dsh-common-tools/client"
20
20
  ]
21
+ },
22
+ "compatibility": {
23
+ "dshReleases": {
24
+ "0.2.0-rc.1": "unknown",
25
+ "0.2.0-rc.2": "compatible",
26
+ "0.2.1-alpha.1": "unknown"
27
+ }
21
28
  }
22
29
  },
23
30
  "scripts": {
24
- "test": "node --test \"test/*.test.mjs\"",
31
+ "test": "node --experimental-test-module-mocks --import ./test/_mocks.common-tools.mjs --test \"test/*.test.mjs\"",
25
32
  "npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
26
33
  },
27
34
  "license": "MIT",
@@ -33,7 +40,7 @@
33
40
  "node": ">=22"
34
41
  },
35
42
  "dependencies": {
36
- "@mzzsfy/dsh-toast": "^0.1.2"
43
+ "@mzzsfy/dsh-common-tools": "^0.2.0"
37
44
  },
38
45
  "peerDependencies": {
39
46
  "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2",
package/src/client.js CHANGED
@@ -9,11 +9,14 @@ window.__ModuleLoader__.load({
9
9
  const React = require('react')
10
10
  const { useState, useEffect, useRef } = React
11
11
 
12
- // 面板反馈出口:公共依赖 @mzzsfy/dsh-toast,可选消费——占位条目由
13
- // session-manager 唯一代挂,权威方未安装时降级 console,不挂死不报错
12
+ // 公共依赖 @mzzsfy/dsh-common-tools:总线订阅硬消费(页内通知数据刷新唯一
13
+ // 通道;模块表缺失即本 client factory 抛错,由装载层判定失败,无轮询回退);
14
+ // toast 可选消费——占位条目由 session-manager 唯一代挂,权威方未安装时降级
15
+ // console,不挂死不报错
16
+ const tools = require('@mzzsfy/dsh-common-tools/client')
14
17
  let toast = null
15
18
  try {
16
- toast = require('@mzzsfy/dsh-toast/client').show
19
+ toast = tools.toast.show
17
20
  } catch {
18
21
  // 模块表无 toast → 反馈降级 console.warn
19
22
  }
@@ -88,46 +91,100 @@ window.__ModuleLoader__.load({
88
91
  }
89
92
  /* LOGIC-END */
90
93
 
91
- // 页内通知长轮询:client 激活即挂起等待,不依赖面板打开;toast 库缺失(权威代挂方
94
+ // 页内通知推送订阅:client 激活即订阅,不依赖面板打开;toast 库缺失(权威代挂方
92
95
  // session-manager 未安装)整段跳过;批内逐条认领防多窗口重复弹;
93
- // 代际令牌自愈:HMR/闭包重建首挂 abort 旧代长轮询再启新代,旧代不滞留不叠加
96
+ // 代际令牌自愈:HMR/闭包重建首挂中止旧代快照请求与总线订阅再启新代,旧代不滞留不叠加
94
97
  const NOTIFY_REQUEST_TIMEOUT_MS = 30 * 1000
95
- // 失败重连退避:指数增长至上限,防网络中断期间打爆服务端;须大于服务端挂起上限
96
- const NOTIFY_RETRY_MIN_MS = 2 * 1000
97
- const NOTIFY_RETRY_MAX_MS = 30 * 1000
98
98
  const NOTIFY_TOAST_MS = 6 * 1000
99
99
  const KEY_POLL_TOKEN = 'usage-panel:notify-poll'
100
- if (toast) {
101
- // 已有 AbortController 代即 abort 旧代长轮询;遗留非 AbortController 旧令牌
102
- // (interval 形态)无法中止,由页面刷新自然清偿
103
- if (window[KEY_POLL_TOKEN] instanceof AbortController) window[KEY_POLL_TOKEN].abort()
100
+ const KEY_POLL_DISPOSE = 'usage-panel:notify-poll:dispose'
101
+ const notificationOwners = new Set()
102
+ let activeNotificationOwner = null
103
+ let notificationWork = null
104
+ const NOTIFY_TTL_MS = 60 * 1000
105
+ const CLAIM_DB = 'dsh-usage-panel'
106
+ const CLAIM_STORE = 'claims'
107
+ const CLAIM_RETENTION_MS = NOTIFY_TTL_MS + NOTIFY_REQUEST_TIMEOUT_MS
108
+ const announcedIds = new Map()
109
+ let claimDegradeAnnounced = false
110
+ let claimDbPromise = null
111
+ function closeClaimDb() {
112
+ const pending = claimDbPromise
113
+ claimDbPromise = null
114
+ pending?.then(db => db.close(), () => {})
115
+ }
116
+ function openClaimDb() {
117
+ if (claimDbPromise) return claimDbPromise
118
+ const pending = new Promise((resolve, reject) => {
119
+ const request = window.indexedDB.open(CLAIM_DB, 1)
120
+ request.onupgradeneeded = () => { request.result.createObjectStore(CLAIM_STORE, { keyPath: 'id' }) }
121
+ request.onerror = () => reject(request.error)
122
+ request.onblocked = () => reject(new Error('通知认领库被阻塞'))
123
+ request.onsuccess = () => {
124
+ const db = request.result
125
+ if (claimDbPromise !== pending) { db.close(); reject(new Error('通知认领代已退出')); return }
126
+ db.onversionchange = () => { if (claimDbPromise === pending) closeClaimDb() }
127
+ resolve(db)
128
+ }
129
+ })
130
+ claimDbPromise = pending
131
+ pending.catch(() => { if (claimDbPromise === pending) claimDbPromise = null })
132
+ return pending
133
+ }
134
+ // 同库 readwrite 事务串行,提交终态后才呈现;HTTP LAN 同样可用。
135
+ async function claimNotification(unit, signal) {
136
+ if (signal?.aborted) return false
137
+ const now = Date.now()
138
+ try {
139
+ const db = await openClaimDb()
140
+ if (signal?.aborted) return false
141
+ const claimed = await new Promise((resolve, reject) => {
142
+ const transaction = db.transaction(CLAIM_STORE, 'readwrite')
143
+ const store = transaction.objectStore(CLAIM_STORE)
144
+ let winner = false
145
+ const abort = () => { try { transaction.abort() } catch { /* 已提交事务不能回滚 */ } }
146
+ const cleanup = () => signal?.removeEventListener('abort', abort)
147
+ signal?.addEventListener('abort', abort, { once: true })
148
+ transaction.oncomplete = () => { cleanup(); resolve(winner) }
149
+ transaction.onabort = transaction.onerror = () => { cleanup(); reject(transaction.error) }
150
+ const request = store.getAll()
151
+ request.onsuccess = () => {
152
+ const now = Date.now()
153
+ const rows = request.result
154
+ for (const row of rows) if (row.expiresAt <= now) store.delete(row.id)
155
+ if (signal?.aborted) return
156
+ if (rows.some(row => row.id === unit.id && row.expiresAt > now)) return
157
+ store.add({ id: unit.id, expiresAt: now + CLAIM_RETENTION_MS })
158
+ winner = localGet(KEY_DONE + unit.id) === null
159
+ }
160
+ })
161
+ return !signal?.aborted && claimed
162
+ } catch {
163
+ if (signal?.aborted) return false
164
+ if (!claimDegradeAnnounced) {
165
+ claimDegradeAnnounced = true
166
+ console.warn('[dsh-usage-panel] IndexedDB 不可用,跨窗口通知认领已降级')
167
+ }
168
+ for (const [id, at] of announcedIds) if (now - at >= NOTIFY_TTL_MS) announcedIds.delete(id)
169
+ if (announcedIds.has(unit.id) || !claimEvent(unit.id)) return false
170
+ announcedIds.set(unit.id, now)
171
+ return true
172
+ }
173
+ }
174
+ function createNotificationWork() {
175
+ const previousDispose = window[KEY_POLL_DISPOSE]
176
+ if (typeof previousDispose === 'function') previousDispose()
104
177
  const notifyController = new AbortController()
105
178
  window[KEY_POLL_TOKEN] = notifyController
106
- // 已见投影版本:空即未首拉;长轮询续传游标,响应后随 payload 推进
179
+ // 已见投影版本:仅作观测;丢失恢复靠订阅 onReset 全量对账,不设游标重放
107
180
  let notifyCursor = null
108
- const notifySleep = (ms, signal) => new Promise((resolve) => {
109
- if (signal.aborted) {
110
- resolve()
111
- return
112
- }
113
- const timer = setTimeout(() => {
114
- signal.removeEventListener('abort', onAbort)
115
- resolve()
116
- }, ms)
117
- const onAbort = () => {
118
- clearTimeout(timer)
119
- resolve()
120
- }
121
- signal.addEventListener('abort', onAbort, { once: true })
122
- })
123
181
  async function notifyPollOnce(signal) {
124
- let payload
182
+ let payload, requestSignal
125
183
  try {
126
- // cursor 为空即首拉,服务端立即返回全量;之后携带版本挂起等待增量
127
- const query = notifyCursor === null ? '' : '?cursor=' + notifyCursor
184
+ // 恒全量快照(变更通知走总线);组合代际中止与请求超时防悬挂
128
185
  const timeoutSignal = AbortSignal.timeout(NOTIFY_REQUEST_TIMEOUT_MS)
129
- const requestSignal = signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal])
130
- const response = await fetch('/api/usage-panel/notifications' + query, { signal: requestSignal })
186
+ requestSignal = signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal])
187
+ const response = await fetch('/api/usage-panel/notifications', { signal: requestSignal })
131
188
  if (!response.ok) {
132
189
  void response.body?.cancel()
133
190
  return false
@@ -136,7 +193,8 @@ window.__ModuleLoader__.load({
136
193
  } catch {
137
194
  return false
138
195
  }
139
- // 版本缺失即异常响应:按失败退避,防游标停滞退化成紧密首拉循环
196
+ if (requestSignal.aborted) return false
197
+ // 版本缺失即异常响应:按失败处理,由调用方决定是否通告
140
198
  if (typeof payload.version !== 'number') return false
141
199
  notifyCursor = payload.version
142
200
  const units = payload && Array.isArray(payload.units) ? payload.units : []
@@ -154,31 +212,47 @@ window.__ModuleLoader__.load({
154
212
  } catch { storageBroken = true }
155
213
  }
156
214
  for (const unit of units) {
157
- if (!claimEvent(unit.id)) continue
215
+ const claimed = await claimNotification(unit, requestSignal)
216
+ if (requestSignal.aborted) return false
217
+ if (!claimed) continue
158
218
  markDone(unit.id)
159
219
  toast(unit.text, { kind: unit.kind === 'reset' ? 'ok' : 'error', holdMs: NOTIFY_TOAST_MS })
160
220
  }
161
221
  return true
162
222
  }
163
- // 长轮询主循环:成功即立即重连(空闲期由服务端挂起兜底),失败指数退避;
164
- // 循环体严格顺序,任何时刻至多一条在途请求
165
- void (async () => {
166
- let backoffMs = NOTIFY_RETRY_MIN_MS
167
- while (!notifyController.signal.aborted) {
168
- let ok = false
169
- try {
170
- ok = await notifyPollOnce(notifyController.signal)
171
- } catch { ok = false }
172
- if (notifyController.signal.aborted) break
173
- if (ok) {
174
- backoffMs = NOTIFY_RETRY_MIN_MS
175
- await notifySleep(0, notifyController.signal)
176
- } else {
177
- await notifySleep(backoffMs, notifyController.signal)
178
- backoffMs = Math.min(backoffMs * 2, NOTIFY_RETRY_MAX_MS)
179
- }
180
- }
181
- })()
223
+ // 订阅插件空间 WS 总线:首连 onReset 即首拉,消息到达重拉;
224
+ // 公共总线重连成功再触发 onReset 全量对账自愈;快照失败不自动重试
225
+ const snapshot = tools.createSnapshotRefresh(() => notifyPollOnce(notifyController.signal))
226
+ const refresh = snapshot.refresh
227
+ const dispose = () => {
228
+ if (notificationWork?.dispose !== dispose) return
229
+ notificationWork = null
230
+ snapshot.dispose()
231
+ notifyController.abort()
232
+ closeClaimDb()
233
+ for (const item of notificationOwners) item.unsubscribe?.()
234
+ notificationOwners.clear()
235
+ activeNotificationOwner = null
236
+ if (window[KEY_POLL_TOKEN] === notifyController) delete window[KEY_POLL_TOKEN]
237
+ if (window[KEY_POLL_DISPOSE] === dispose) delete window[KEY_POLL_DISPOSE]
238
+ }
239
+ notificationWork = { refresh, dispose }
240
+ window[KEY_POLL_DISPOSE] = dispose
241
+ }
242
+ function startNotifications(bus) {
243
+ if (!toast) return
244
+ const owner = { unsubscribe: null }
245
+ if (notificationOwners.size === 0) createNotificationWork()
246
+ notificationOwners.add(owner)
247
+ if (activeNotificationOwner === null) activeNotificationOwner = owner
248
+ const refresh = () => { if (activeNotificationOwner === owner) void notificationWork.refresh() }
249
+ owner.unsubscribe = bus.subscribe(refresh, { onReset: refresh })
250
+ return () => {
251
+ if (!notificationOwners.delete(owner)) return
252
+ owner.unsubscribe()
253
+ if (activeNotificationOwner === owner) activeNotificationOwner = notificationOwners.values().next().value || null
254
+ if (notificationOwners.size === 0) notificationWork.dispose()
255
+ }
182
256
  }
183
257
 
184
258
  // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
@@ -189,13 +263,14 @@ window.__ModuleLoader__.load({
189
263
  else window.__navicIconQueue = [NAV_ICON]
190
264
 
191
265
  const CSS = [
192
- // 设计令牌:状态色语义固定,面层色走宿主变量保暗亮自适应
193
- '.up-panel { --up-warn:#d97706; --up-surface:var(--dsw-alias-surface-primary, #1b1d21);',
194
- ' --up-border:var(--dsw-alias-separator-primary, rgba(128,128,128,0.28));',
195
- ' --up-muted:var(--dsw-alias-label-secondary, rgba(160,166,178,0.9));',
196
- ' --up-ok:var(--dsw-alias-state-success-primary, #1a9e55);',
197
- ' --up-err:var(--dsw-alias-state-error-primary, #d43a3a);',
198
- ' --up-focus:var(--dsw-alias-state-focus-primary, #4c8dff);',
266
+ // 设计令牌:直接引用宿主语义别名,禁止字面 fallback(暗色主题漏色);
267
+ // surface/separator 是宿主无定义的自造别名,迁移到 bg-layer-1/border-l2
268
+ '.up-panel { --up-warn:var(--dsw-alias-state-warn-primary); --up-surface:var(--dsw-alias-bg-layer-1);',
269
+ ' --up-border:var(--dsw-alias-border-l2);',
270
+ ' --up-muted:var(--dsw-alias-label-secondary);',
271
+ ' --up-ok:var(--dsw-alias-state-success-primary);',
272
+ ' --up-err:var(--dsw-alias-state-error-primary);',
273
+ ' --up-focus:var(--dsw-alias-brand-primary);',
199
274
  ' display:flex; flex-direction:column; gap:14px; color:inherit; font-size:13px; }',
200
275
  // ---- 顶栏 ----
201
276
  '.up-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
@@ -206,11 +281,11 @@ const CSS = [
206
281
  '.up-btn { cursor:pointer; border:1px solid var(--up-border); background:transparent;',
207
282
  ' color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit;',
208
283
  ' transition:background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease, transform 0.15s ease; }',
209
- '.up-btn:hover { border-color:var(--up-focus); background:rgba(128,148,180,0.12); }',
284
+ '.up-btn:hover { border-color:var(--up-focus); background:color-mix(in srgb, var(--up-focus) 12%, transparent); }',
210
285
  '.up-btn:active { transform:translateY(1px); }',
211
286
  '.up-btn:disabled { opacity:0.45; cursor:default; transform:none; }',
212
287
  '.up-btn:focus-visible { outline:2px solid var(--up-focus); outline-offset:2px; }',
213
- '.up-btn--primary { background:var(--up-focus); border-color:var(--up-focus); color:#fff; font-weight:600; }',
288
+ '.up-btn--primary { background:var(--up-focus); border-color:var(--up-focus); color:var(--dsw-alias-bg-base); font-weight:600; }',
214
289
  '.up-btn--primary:hover { background:var(--up-focus); opacity:0.88; }',
215
290
  '.up-btn--danger { color:var(--up-err); border-color:color-mix(in srgb, var(--up-err) 45%, transparent); }',
216
291
  '.up-btn--danger:hover { border-color:var(--up-err); background:color-mix(in srgb, var(--up-err) 12%, transparent); }',
@@ -219,19 +294,23 @@ const CSS = [
219
294
  ' display:flex; flex-direction:column; gap:10px;',
220
295
  ' transition:border-color 0.15s ease, background 0.15s ease; }',
221
296
  '.up-card:hover { border-color:color-mix(in srgb, var(--up-focus) 40%, var(--up-border));',
222
- ' background:rgba(128,148,180,0.06); }',
297
+ ' background:color-mix(in srgb, var(--up-focus) 6%, transparent); }',
223
298
  '.up-card__row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
224
299
  '.up-card__name { font-weight:600; font-size:15px; }',
225
- // 平台徽章:按平台固定色相着色的胶囊 pill;custom 走缺省灰底(低调平台共用)
300
+ // 平台徽章:按平台固定色相着色的胶囊 pill(品牌区分色,规范允许的 chart 类 token,
301
+ // 集中声明);custom 走缺省灰底(低调平台共用)
302
+ '.up-panel { --up-badge-default:color-mix(in srgb, var(--dsw-alias-label-tertiary) 55%, transparent);',
303
+ ' --up-badge-deepseek:var(--dsw-alias-brand-primary); --up-badge-kimi:var(--dsw-alias-state-success-primary);',
304
+ ' --up-badge-minimax:var(--dsw-alias-state-warn-primary); --up-badge-newapi:#8b5cf6; --up-badge-zhipu:#3859ff; }',
226
305
  '.up-badge { font-size:10px; font-weight:500; padding:1px 7px; border-radius:999px;',
227
- ' line-height:1.5; border:1px solid transparent; color:#fff;',
228
- ' background:var(--up-badge-color, rgba(128,128,128,0.55)); }',
229
- '.up-badge[data-type="deepseek"] { --up-badge-color:#4d6bfe; }',
230
- '.up-badge[data-type="openrouter"] { --up-badge-color:#57565b; }',
231
- '.up-badge[data-type="kimi"] { --up-badge-color:#0d9488; }',
232
- '.up-badge[data-type="minimax"] { --up-badge-color:#ea580c; }',
233
- '.up-badge[data-type="newapi"] { --up-badge-color:#8b5cf6; }',
234
- '.up-badge[data-type="zhipu"] { --up-badge-color:#3859ff; }',
306
+ ' line-height:1.5; border:1px solid transparent; color:var(--dsw-alias-bg-base);',
307
+ ' background:var(--up-badge-color, var(--up-badge-default)); }',
308
+ '.up-badge[data-type="deepseek"] { --up-badge-color:var(--up-badge-deepseek); }',
309
+ '.up-badge[data-type="openrouter"] { --up-badge-color:var(--dsw-alias-label-tertiary); }',
310
+ '.up-badge[data-type="kimi"] { --up-badge-color:var(--up-badge-kimi); }',
311
+ '.up-badge[data-type="minimax"] { --up-badge-color:var(--up-badge-minimax); }',
312
+ '.up-badge[data-type="newapi"] { --up-badge-color:var(--up-badge-newapi); }',
313
+ '.up-badge[data-type="zhipu"] { --up-badge-color:var(--up-badge-zhipu); }',
235
314
  // ---- 读数行:标签 + 渐变进度条 + 等宽数字 ----
236
315
  '.up-reading { display:flex; flex-direction:column; gap:6px; }',
237
316
  '.up-row { display:flex; align-items:center; gap:10px; position:relative; }',
@@ -239,10 +318,10 @@ const CSS = [
239
318
  '.up-bar { flex:1; min-width:120px; max-width:320px; height:8px; border-radius:999px; overflow:hidden; flex:none;',
240
319
  ' background:var(--up-border); }',
241
320
  '.up-bar__fill { display:block; height:100%; border-radius:999px;',
242
- ' background:linear-gradient(90deg, var(--up-ok), color-mix(in srgb, var(--up-ok) 72%, #3ddc84));',
321
+ ' background:linear-gradient(90deg, var(--up-ok), color-mix(in srgb, var(--up-ok) 72%, var(--dsw-alias-bg-base)));',
243
322
  ' transition:width 0.4s ease; }',
244
- '.up-bar__fill--warn { background:linear-gradient(90deg, var(--up-warn), #f0a13e); }',
245
- '.up-bar__fill--crit { background:linear-gradient(90deg, var(--up-err), #f0574f); }',
323
+ '.up-bar__fill--warn { background:linear-gradient(90deg, var(--up-warn), color-mix(in srgb, var(--up-warn) 60%, var(--dsw-alias-bg-base))); }',
324
+ '.up-bar__fill--crit { background:linear-gradient(90deg, var(--up-err), color-mix(in srgb, var(--up-err) 60%, var(--dsw-alias-bg-base))); }',
246
325
  '.up-pct { font-variant-numeric:tabular-nums; font-weight:600; font-size:13px; min-width:44px; text-align:right; }',
247
326
  // ---- 数字与状态色 ----
248
327
  '.up-meta { color:var(--up-muted); font-size:12px; line-height:1.6; }',
@@ -256,30 +335,32 @@ const CSS = [
256
335
  '.up-dot--off { background:var(--up-err); box-shadow:0 0 0 3px color-mix(in srgb, var(--up-err) 18%, transparent); }',
257
336
  // ---- 悬浮提示(读数明细) ----
258
337
  '.up-tip { position:absolute; top:calc(100% + 8px); left:0; visibility:hidden; opacity:0;',
259
- ' transition:opacity 0.15s ease; background:rgba(22,24,28,0.94); color:#f0f1f3;',
338
+ ' transition:opacity 0.15s ease; background:color-mix(in srgb, var(--dsw-alias-label-primary) 94%, transparent); color:var(--dsw-alias-bg-base);',
260
339
  ' font-size:11px; line-height:1.7; padding:7px 11px; border-radius:9px; white-space:nowrap; text-align:left;',
261
- ' z-index:40; pointer-events:none; box-shadow:0 6px 20px rgba(0,0,0,0.35); }',
340
+ ' z-index:40; pointer-events:none; box-shadow:var(--dsw-shadow-lv2); }',
262
341
  '.up-row:hover .up-tip { visibility:visible; opacity:1; }',
263
342
  // ---- 趋势弹层:毛玻璃 ----
264
343
  '.up-trend { position:absolute; top:calc(100% + 8px); left:0; visibility:hidden; opacity:0;',
265
- ' transition:opacity 0.15s ease; background:rgba(24,26,31,0.86); color:#f0f1f3;',
344
+ ' transition:opacity 0.15s ease; background:color-mix(in srgb, var(--dsw-alias-label-primary) 86%, transparent); color:var(--dsw-alias-bg-base);',
266
345
  ' backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);',
267
- ' border:1px solid rgba(255,255,255,0.12);',
346
+ ' border:1px solid color-mix(in srgb, var(--dsw-alias-bg-base) 12%, transparent);',
268
347
  ' font-size:11px; line-height:1.6; padding:10px 12px; border-radius:12px; z-index:41;',
269
- ' box-shadow:0 10px 36px rgba(0,0,0,0.45); }',
348
+ ' box-shadow:var(--dsw-shadow-lv3); }',
270
349
  '.up-trend__title { font-weight:600; margin-bottom:4px; font-size:12px; }',
271
350
  '.up-trend__chart { margin:2px 0 6px; }',
272
351
  '.up-trend__chart text { fill:currentColor; font-size:9px; }',
352
+ '.up-trend__line--ok { stroke:var(--up-ok); }',
353
+ '.up-trend__line--err { stroke:var(--up-err); }',
273
354
  '.up-trend__point:hover circle { r:4; }',
274
355
  '.up-card:hover .up-trend { visibility:visible; opacity:1; }',
275
356
  '.up-card:hover { cursor:default; }',
276
357
  // ---- 对话框:毛玻璃遮罩 + 提升卡片 ----
277
- '.up-dialog-mask { position:fixed; inset:0; background:rgba(0,0,0,0.5); z-index:60;',
358
+ '.up-dialog-mask { position:fixed; inset:0; background:color-mix(in srgb, var(--dsw-alias-label-primary) 50%, transparent); z-index:60;',
278
359
  ' backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);',
279
360
  ' display:flex; align-items:center; justify-content:center; }',
280
361
  '.up-dialog { background:var(--up-surface); color:inherit; border:1px solid var(--up-border); border-radius:14px;',
281
362
  ' padding:18px 20px; max-width:680px; width:90%; max-height:80vh; overflow:auto;',
282
- ' display:flex; flex-direction:column; gap:10px; box-shadow:0 16px 48px rgba(0,0,0,0.5); }',
363
+ ' display:flex; flex-direction:column; gap:10px; box-shadow:var(--dsw-shadow-lv3); }',
283
364
  '.up-dialog table { border-collapse:collapse; width:100%; font-size:12px; }',
284
365
  '.up-dialog th, .up-dialog td { text-align:left; padding:4px 10px;',
285
366
  ' border-bottom:1px solid var(--up-border); }',
@@ -297,7 +378,7 @@ const CSS = [
297
378
  '.up-field input:hover, .up-field select:hover, .up-field textarea:hover { border-color:color-mix(in srgb, var(--up-focus) 45%, var(--up-border)); }',
298
379
  '.up-field input:focus-visible, .up-field select:focus-visible, .up-field textarea:focus-visible {',
299
380
  ' outline:none; border-color:var(--up-focus); box-shadow:0 0 0 3px color-mix(in srgb, var(--up-focus) 22%, transparent); }',
300
- '.up-field textarea { font-family:ui-monospace, Consolas, monospace; font-size:12px; min-height:52px; resize:vertical; }',
381
+ '.up-field textarea { font-family:var(--ds-font-family-code); font-size:12px; min-height:52px; resize:vertical; }',
301
382
  '.up-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; }',
302
383
  // ---- 通知与提示 ----
303
384
  '.up-notice { font-size:12px; padding:7px 11px; border-radius:9px;',
@@ -338,7 +419,7 @@ const CSS = [
338
419
  '.up-switch__track { width:30px; height:16px; border-radius:999px; flex:none; position:relative;',
339
420
  ' background:var(--up-border); transition:background 0.15s ease; }',
340
421
  '.up-switch__thumb { position:absolute; top:2px; left:2px; width:12px; height:12px; border-radius:50%;',
341
- ' background:#fff; transition:left 0.15s ease; }',
422
+ ' background:var(--dsw-alias-bg-base); transition:left 0.15s ease; }',
342
423
  '.up-switch input[type="checkbox"]:checked + .up-switch__track { background:var(--up-ok); }',
343
424
  '.up-switch input[type="checkbox"]:checked + .up-switch__track .up-switch__thumb { left:16px; }',
344
425
  '.up-switch input[type="checkbox"]:focus-visible + .up-switch__track { outline:2px solid var(--up-focus); outline-offset:2px; }',
@@ -862,9 +943,9 @@ function TrendChart(props) {
862
943
  mode === 'abs'
863
944
  ? h('polyline', { points: polyline, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 })
864
945
  : plotted.map((p, index) => h('line', {
865
- key: String(index), className: 'up-trend__point',
946
+ key: String(index), className: 'up-trend__point ' + (p.v >= 0 ? 'up-trend__line--ok' : 'up-trend__line--err'),
866
947
  x1: p.x, x2: p.x, y1: SPARK_HEIGHT / 2, y2: p.y,
867
- stroke: p.v >= 0 ? '#1a9e55' : '#d43a3a', strokeWidth: 2,
948
+ strokeWidth: 2,
868
949
  }, h('title', null, fmtTimeFull(p.t) + ' ' + (p.v >= 0 ? '+' : '') + p.v))),
869
950
  mode === 'abs' ? plotted.map((p, index) => h('circle', {
870
951
  key: String(index), className: 'up-trend__point', cx: p.x, cy: p.y, r: 2, fill: 'currentColor',
@@ -1009,6 +1090,10 @@ function NotifyConfigCard() {
1009
1090
  }
1010
1091
  function fail(err) {
1011
1092
  notify('保存失败:' + (err && err.message ? err.message : String(err)), 'error')
1093
+ // 失败权威回填:保存被拒时本卡展示态可能已偏离服务端(前序写/他人写生效),拉权威态收敛
1094
+ api('/api/usage-panel/notify-config')
1095
+ .then((res) => { if (res && res.notify) setConfig(res.notify) })
1096
+ .catch(() => {})
1012
1097
  }
1013
1098
  function patch(part) {
1014
1099
  api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify(part) })
@@ -1429,6 +1514,8 @@ function UsagePanelApp() {
1429
1514
  return {
1430
1515
  inject: ['slots'],
1431
1516
  apply(ctx) {
1517
+ const bus = tools.setupCommonTools(ctx)
1518
+ ctx.effect(() => startNotifications(bus), 'usage-panel notifications')
1432
1519
  ctx.slots.inject('settings.section', () =>
1433
1520
  ctx.slots.register(
1434
1521
  { name: 'settings.section', id: 'usage-panel', order: 40, label: '账号余额' },
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { setupCommonTools, createChangeNotifier } from '@mzzsfy/dsh-common-tools/host'
1
2
  // 用量面板 Host 半区:多平台余额查询 + 定期轮询 + 历史快照落盘。webServer 路由供浏览器半区调用,
2
3
  // fetch 直连平台 API,配置持久化在 ~/.dsh/dsh-usage-panel/accounts.json,快照在 history.json。
3
4
 
@@ -42,8 +43,6 @@ const MAX_ACCOUNTS = 20
42
43
  // 单轮轮询的受限并发:串行执行时单账号 20s 超时即可拖满整轮(pollInFlight 互斥
43
44
  // 使后续 tick 全部跳过,轮询事实停摆),并发上限同时压住上游瞬时压力
44
45
  const POLL_CONCURRENCY = 4
45
- // 通知长轮询服务端挂起上限,与 turn-notify 同值;客户端超时须大于此值
46
- const LONG_POLL_WAIT_MS = 25 * 1000
47
46
  // 数据目录支持 env 注入(测试隔离);缺省落 ~/.dsh/dsh-usage-panel
48
47
  const DATA_DIR = process.env.DSH_USAGE_PANEL_DATA_DIR || join(homedir(), '.dsh', 'dsh-usage-panel')
49
48
  const DATA_FILE = join(DATA_DIR, 'accounts.json')
@@ -339,9 +338,14 @@ async function queryAccount(account) {
339
338
 
340
339
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
341
340
  // timer 为软依赖:不进 inject 声明,服务缺失时仅停用自动轮询,面板手动查询不受影响
341
+ // 推送总线:页内通知刷新统一走公共依赖 common-tools,客户端长轮询已废除(不留双路径)
342
342
  export const inject = ['webServer']
343
343
 
344
344
  export function apply(ctx, configRef) {
345
+ const tools = setupCommonTools(ctx)
346
+ const changes = createChangeNotifier(() => tools.publish())
347
+ ctx.effect(() => () => changes.dispose(), '通知变更合并')
348
+ const notifyChange = changes.refresh
345
349
  let config = null
346
350
  let loadPromise = null
347
351
  let writeChain = Promise.resolve()
@@ -361,6 +365,26 @@ export function apply(ctx, configRef) {
361
365
  return resolvedNotifySettings(value && typeof value === 'object' ? value.notify : undefined)
362
366
  }
363
367
 
368
+ // 通知配置串行写链:读改写整体入链,任一失败不吞链(catch 后换行续链)
369
+ let notifyConfigWriteChain = Promise.resolve()
370
+
371
+ function persistNotifyPatch(patch) {
372
+ // 读出当前值做浅合并后整体写回,不依赖宿主写入的嵌套合并语义
373
+ const merged = { ...readNotifySettings(), ...patch }
374
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
375
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
376
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
377
+ const settings = ctx.get('settings')
378
+ requireOk(settings !== undefined, 'settings 服务不可用')
379
+ if (typeof settings.register === 'function' && typeof settings.update === 'function') {
380
+ return settings.update(NAMESPACE, { notify: merged })
381
+ }
382
+ const editor = ctx.get('configEditor')
383
+ const entry = ctx.fiber?.entry
384
+ requireOk(editor && typeof editor.edit === 'function' && entry, 'configEditor 服务不可用')
385
+ return editor.edit(entry, (current) => ({ ...current, notify: merged }))
386
+ }
387
+
364
388
  function ensureHistory() {
365
389
  return historyStore.ensure().then((sequences) => {
366
390
  history = sequences
@@ -424,8 +448,11 @@ export function apply(ctx, configRef) {
424
448
  notifySeq += outcome.events.length
425
449
  account.notifyState = outcome.state
426
450
  for (const event of outcome.events) {
427
- // toast 开关在投影侧消费:关闭即浏览器半区轮询不到,出站通道不受影响
428
- if (globalNotify.toast) projection.push(event)
451
+ // toast 开关在投影侧消费:关闭即浏览器半区订阅不到,出站通道不受影响
452
+ if (globalNotify.toast) {
453
+ projection.push(event)
454
+ notifyChange()
455
+ }
429
456
  void sendWebhook({ url: globalNotify.webhookUrl, payload: buildWebhookPayload(event) })
430
457
  deliverIm(event, globalNotify)
431
458
  }
@@ -616,20 +643,12 @@ export function apply(ctx, configRef) {
616
643
  sendJson(res, 405, { error: 'method not allowed' })
617
644
  return
618
645
  }
619
- // 长轮询:cursor 缺省=首拉立即返回;仅版本恰等时挂起至事件 push / 超时;
620
- // cursor 超前(宿主重启版本回退)立即返回全量,客户端以响应 version 重置游标自愈
621
- const url = new URL(req.url, 'http://localhost')
622
- const rawCursor = Number(url.searchParams.get('cursor'))
623
- const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
624
- if (url.searchParams.has('cursor') && projection.version() === cursor) {
625
- await projection.wait(cursor, LONG_POLL_WAIT_MS)
626
- }
646
+ // 快照:恒全量返回(变更通知走总线,不再长轮询挂起);丢失恢复靠订阅 onReset 全量对账
627
647
  sendJson(res, 200, { units: projection.list(), version: projection.version() })
628
648
  },
629
649
  })
630
650
  return () => {
631
651
  disposeRoute()
632
- // 唤醒全部挂起长轮询,以当前版本收尾,防卸载后连接悬挂
633
652
  projection.dispose()
634
653
  }
635
654
  }, 'usage-panel notifications route')
@@ -647,21 +666,12 @@ export function apply(ctx, configRef) {
647
666
  const body = JSON.parse(await readBody(req))
648
667
  const check = validateNotifyPatch(body)
649
668
  requireOk(check.ok, check.reason)
650
- // 读出当前值做浅合并后整体写回,不依赖宿主写入的嵌套合并语义
651
- const merged = { ...readNotifySettings(), ...check.patch }
652
- // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
653
- // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
654
- // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
655
- const settings = ctx.get('settings')
656
- requireOk(settings !== undefined, 'settings 服务不可用')
657
- if (typeof settings.register === 'function' && typeof settings.update === 'function') {
658
- await settings.update(NAMESPACE, { notify: merged })
659
- } else {
660
- const editor = ctx.get('configEditor')
661
- const entry = ctx.fiber?.entry
662
- requireOk(editor && typeof editor.edit === 'function' && entry, 'configEditor 服务不可用')
663
- await editor.edit(entry, (current) => ({ ...current, notify: merged }))
664
- }
669
+ // 读改写经串行链执行:重叠请求各自基于旧快照合并会互相覆盖
670
+ // (先写的 enabled 被后写基于旧 enabled=false 的合并抹掉),链式
671
+ // 追加保证任一写读取的总是前序写完成后的最新值;共享链以 catch
672
+ // 续接,单次失败只回报本次请求(抛错由 guardedRoute 统一转 400),
673
+ // 不阻断后续写
674
+ await notifyConfigWriteChain.then(() => persistNotifyPatch(check.patch))
665
675
  sendJson(res, 200, { ok: true, notify: publicNotify(readNotifySettings()) })
666
676
  }),
667
677
  }),
@@ -0,0 +1,20 @@
1
+ // common-tools host 桩(经 --import 预载,先于被测模块实例化拦截 ESM):
2
+ // publish 记入 busStub.published(断言点零改);onPresence 注册即报 0,
3
+ // busStub.presence 供测试手动驱动连接计数
4
+ import { mock } from 'node:test'
5
+
6
+ export const busStub = { published: [], presence: new Set() }
7
+ export function resetBusStub() {
8
+ busStub.published = []
9
+ busStub.presence = new Set()
10
+ }
11
+
12
+ mock.module('@mzzsfy/dsh-common-tools/host', {
13
+ namedExports: {
14
+ createChangeNotifier: refresh => ({ refresh, dispose() {} }),
15
+ setupCommonTools: () => ({
16
+ publish: (payload = null) => busStub.published.push(payload),
17
+ onPresence(fn) { busStub.presence.add(fn); fn(0); return () => busStub.presence.delete(fn) },
18
+ }),
19
+ },
20
+ })
@@ -0,0 +1,83 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ function fixture() {
8
+ let definition
9
+ const window = {
10
+ __ModuleLoader__: { load: value => { definition = value } },
11
+ localStorage: { length: 0, getItem: () => null },
12
+ }
13
+ const subscriptions = new Set(), requests = [], shown = []
14
+ const tools = {
15
+ createSnapshotRefresh: refresh => ({ refresh, dispose() {} }),
16
+ toast: { show: (...args) => shown.push(args) },
17
+ setupCommonTools(ctx) {
18
+ assert.equal(ctx.fiber.entry.options.name, '@mzzsfy/dsh-usage-panel')
19
+ return { subscribe(onMessage, { onReset } = {}) {
20
+ const item = { onMessage, onReset }
21
+ subscriptions.add(item)
22
+ return () => subscriptions.delete(item)
23
+ } }
24
+ },
25
+ }
26
+ const fetch = (path, options) => new Promise(resolve => requests.push({ path, options, resolve }))
27
+ new Function('window', 'fetch', source)(window, fetch)
28
+ const instantiate = () => {
29
+ const effects = [], cleanups = []
30
+ const mod = definition.factory(name => name === 'react' ? {} : tools)
31
+ const ctx = {
32
+ fiber: { entry: { options: { name: '@mzzsfy/dsh-usage-panel' } } },
33
+ effect(fn) { effects.push(fn) },
34
+ slots: { inject() {}, register() {} },
35
+ }
36
+ return { apply() { mod.apply(ctx); for (const effect of effects) { const off = effect(); if (typeof off === 'function') cleanups.push(off) } },
37
+ dispose() { for (const off of cleanups.reverse()) off() } }
38
+ }
39
+ return { window, subscriptions, requests, shown, instantiate,
40
+ emit() { for (const item of subscriptions) item.onReset?.() } }
41
+ }
42
+
43
+ const flush = () => new Promise(resolve => setImmediate(resolve))
44
+
45
+ test('Given 尚未激活的 client When factory 物化 Then 不订阅或拉取通知快照', () => {
46
+ const f = fixture()
47
+ f.instantiate()
48
+ assert.equal(f.subscriptions.size, 0)
49
+ assert.equal(f.requests.length, 0)
50
+ })
51
+
52
+ test('Given ctx 已激活且快照在途 When 卸载 Then 退订并中止请求且不呈现迟到快照', async () => {
53
+ const f = fixture(), plugin = f.instantiate()
54
+ plugin.apply()
55
+ assert.equal(f.subscriptions.size, 1)
56
+ f.emit()
57
+ assert.equal(f.requests.length, 1)
58
+ const request = f.requests[0]
59
+ plugin.dispose()
60
+ assert.equal(f.subscriptions.size, 0)
61
+ assert.equal(request.options.signal.aborted, true)
62
+ f.emit()
63
+ assert.equal(f.requests.length, 1)
64
+ request.resolve({ ok: true, json: async () => ({ version: 1, units: [{ id: 'late', text: '旧代通知' }] }) })
65
+ await flush()
66
+ assert.equal(f.shown.length, 0)
67
+ assert.equal(Object.hasOwn(f.window, 'usage-panel:notify-poll'), false)
68
+ assert.equal(Object.hasOwn(f.window, 'usage-panel:notify-poll:dispose'), false)
69
+ })
70
+
71
+ test('Given 旧代激活 When 新代 apply Then 仅保留新代订阅且旧代卸载不清新代令牌', () => {
72
+ const f = fixture(), old = f.instantiate()
73
+ old.apply(); f.emit()
74
+ const current = f.instantiate()
75
+ current.apply()
76
+ assert.equal(f.requests[0].options.signal.aborted, true)
77
+ assert.equal(f.subscriptions.size, 1)
78
+ const token = f.window['usage-panel:notify-poll']
79
+ old.dispose()
80
+ assert.equal(f.window['usage-panel:notify-poll'], token)
81
+ current.dispose()
82
+ assert.equal(f.subscriptions.size, 0)
83
+ })
@@ -1,31 +1,174 @@
1
- // 页内通知长轮询 BDD:client 半区以顺序长轮询消费通知投影——
2
- // 任何时刻至多一条在途请求,失败指数退避,HMR/闭包重建重复装载时
3
- // 新代首挂 abort 旧代长轮询再启新代——旧代不滞留、不叠加、不告警。
4
- // 轮询段运行于 factory 闭包内(IO),守卫以源码契约断言锁定形态。
5
-
6
1
  import { test } from 'node:test'
7
2
  import assert from 'node:assert/strict'
8
3
  import { readFileSync } from 'node:fs'
9
- import { join, dirname } from 'node:path'
10
- import { fileURLToPath } from 'node:url'
11
4
 
12
- const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
13
- const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ const flush = () => new Promise(resolve => setImmediate(resolve))
8
+ const storageFixture = () => {
9
+ const rows = new Map()
10
+ return { get length() { return rows.size }, key: index => [...rows.keys()][index] ?? null,
11
+ getItem: key => rows.get(key) ?? null, setItem: (key, value) => rows.set(key, value), removeItem: key => rows.delete(key) }
12
+ }
13
+
14
+ function clientFixture({ storage = storageFixture(), indexedDB, abortSignalImpl = AbortSignal } = {}) {
15
+ let definition, payload = { version: 0, units: [] }
16
+ const shown = [], subscriptions = new Set(), pending = new Set()
17
+ const window = { localStorage: storage, indexedDB, __ModuleLoader__: { load: value => { definition = value } } }
18
+ const tools = {
19
+ createSnapshotRefresh: refresh => ({ refresh() {
20
+ const work = refresh(); pending.add(work); work.finally(() => pending.delete(work)); return work
21
+ }, dispose() {} }),
22
+ toast: { show: text => shown.push(text) },
23
+ setupCommonTools: () => ({ subscribe(onMessage, { onReset }) {
24
+ subscriptions.add(onReset); return () => subscriptions.delete(onReset)
25
+ } }),
26
+ }
27
+ const fetch = async () => ({ ok: true, json: async () => payload })
28
+ new Function('window', 'fetch', 'AbortSignal', source)(window, fetch, abortSignalImpl)
29
+ function generation() {
30
+ const mod = definition.factory(name => name === 'react' ? {} : tools)
31
+ return { apply() {
32
+ const cleanups = []
33
+ mod.apply({ fiber: { entry: { options: { name: '@mzzsfy/dsh-usage-panel' } } },
34
+ slots: { inject() {}, register() {} }, effect(fn) { const off = fn(); if (typeof off === 'function') cleanups.push(off) } })
35
+ return () => { while (cleanups.length) cleanups.pop()() }
36
+ } }
37
+ }
38
+ return { shown, window, generation, set(payloadNext) { payload = payloadNext },
39
+ poll() { for (const refresh of subscriptions) refresh(); return Promise.all([...pending]) } }
40
+ }
14
41
 
15
- test('代际令牌承载 AbortController:新代启动前中止旧代长轮询', () => {
16
- assert.match(source, /window\[KEY_POLL_TOKEN\] instanceof AbortController/, '缺少旧代 AbortController 判定')
17
- assert.match(source, /window\[KEY_POLL_TOKEN\]\.abort\(\)/, '缺少旧代长轮询中止')
18
- assert.match(source, /window\[KEY_POLL_TOKEN\] = notifyController/, '控制器未交接给令牌')
42
+ // 同库事务串行,只有提交后才发布写入;事务取消丢弃整个草稿。
43
+ function indexedDbFixture() {
44
+ let rows = new Map(), initialized = false, queue = Promise.resolve(), gate = null
45
+ const connections = []
46
+ const factory = { open() {
47
+ const request = {}
48
+ setImmediate(() => {
49
+ const db = { closed: false, createObjectStore() {}, close() { this.closed = true }, transaction() {
50
+ if (this.closed) throw new Error('closed')
51
+ const tx = {}, operations = []
52
+ let draft, aborted = false
53
+ tx.abort = () => { aborted = true }
54
+ const requestFor = run => {
55
+ const request = {}; operations.push(() => { request.result = run(); request.onsuccess?.() }); return request
56
+ }
57
+ tx.objectStore = () => ({ getAll: () => requestFor(() => [...draft.values()]),
58
+ delete: id => requestFor(() => draft.delete(id)), add: row => requestFor(() => {
59
+ if (draft.has(row.id)) throw new Error('ConstraintError')
60
+ draft.set(row.id, row)
61
+ }) })
62
+ queue = queue.then(async () => {
63
+ if (gate) await gate.promise
64
+ draft = new Map(rows)
65
+ try {
66
+ if (aborted) throw new Error('AbortError')
67
+ while (operations.length) operations.shift()()
68
+ rows = draft; tx.oncomplete?.()
69
+ } catch (error) { tx.error = error; tx.onabort?.() }
70
+ })
71
+ return tx
72
+ } }
73
+ connections.push(db); request.result = db
74
+ if (!initialized) { initialized = true; request.onupgradeneeded?.() }
75
+ request.onsuccess?.()
76
+ })
77
+ return request
78
+ } }
79
+ return { factory, connections, get rows() { return rows },
80
+ hold() { gate = {}; gate.promise = new Promise(resolve => { gate.resolve = resolve }) },
81
+ release() { const current = gate; gate = null; current?.resolve() } }
82
+ }
83
+
84
+ test('Given usage双窗共享storage When 新通知后旧空快照抵达再追平 Then 总呈现一次', async () => {
85
+ const storage = storageFixture(), idb = indexedDbFixture()
86
+ const a = clientFixture({ storage, indexedDB: idb.factory }), b = clientFixture({ storage, indexedDB: idb.factory })
87
+ const offA = a.generation().apply(), offB = b.generation().apply()
88
+ const payload = { version: 1, units: [{ id: 'new', text: '余额提醒', kind: 'balance', ts: Date.now() }] }
89
+ try {
90
+ a.set(payload); await a.poll(); await flush(); await flush()
91
+ await b.poll(); await flush(); await flush()
92
+ b.set(payload); await b.poll(); await flush(); await flush()
93
+ assert.equal(a.shown.length + b.shown.length, 1)
94
+ } finally { offA(); offB() }
19
95
  })
20
96
 
21
- test('消费形态为顺序长轮询:setInterval 短轮询不得回归', () => {
22
- assert.ok(!source.includes('setInterval'), '残留 interval 短轮询形态')
23
- assert.match(source, /AbortSignal\.timeout\(NOTIFY_REQUEST_TIMEOUT_MS\)/, '长轮询请求缺少超时上限')
24
- assert.match(source, /\?cursor=' \+ notifyCursor/, '长轮询缺少续传游标')
97
+ test('Given usage双窗storage观察均为空 When 同时消费首快照 Then 事务单赢家且过期记录清理', async () => {
98
+ const idb = indexedDbFixture()
99
+ idb.rows.set('expired', { id: 'expired', expiresAt: 0 })
100
+ const a = clientFixture({ indexedDB: idb.factory }), b = clientFixture({ indexedDB: idb.factory })
101
+ const offA = a.generation().apply(), offB = b.generation().apply()
102
+ const payload = { version: 1, units: [{ id: 'race', text: '并发余额提醒', kind: 'balance', ts: Date.now() }] }
103
+ try {
104
+ a.set(payload); b.set(payload)
105
+ await Promise.all([a.poll(), b.poll()])
106
+ assert.equal(a.shown.length + b.shown.length, 1, '首快照保留既有呈现语义但跨窗只呈现一次')
107
+ assert.deepEqual([...idb.rows.keys()], ['race'])
108
+ } finally { offA(); offB() }
109
+ })
110
+
111
+ test('Given usage事务在途 When 最后owner退出和HMR Then 回滚无late显示且同id可重激活', async () => {
112
+ const idb = indexedDbFixture(), f = clientFixture({ indexedDB: idb.factory }), generation = f.generation()
113
+ const offA = generation.apply(), offB = generation.apply()
114
+ const payload = { version: 1, units: [{ id: 'late', text: '待接手', ts: Date.now() }] }
115
+ f.set(payload); idb.hold()
116
+ const pending = f.poll()
117
+ await flush(); await flush()
118
+ offA(); assert.equal(idb.connections[0].closed, false)
119
+ offB(); idb.release(); await pending
120
+ assert.equal(idb.rows.has('late'), false)
121
+ assert.deepEqual(f.shown, [])
122
+ assert.equal(idb.connections[0].closed, true)
123
+ const offAgain = generation.apply()
124
+ await f.poll()
125
+ assert.deepEqual(f.shown, ['待接手'])
126
+ const current = f.generation(), offCurrent = current.apply()
127
+ await flush()
128
+ assert.equal(idb.connections[1].closed, true, '新代接管关闭旧代连接')
129
+ offAgain(); offCurrent()
130
+ })
131
+
132
+ test('Given 宿主时钟落后 When 消费有效快照 Then 正常呈现;IDB不可用时本窗同事件仍只呈现一次', async () => {
133
+ const idb = indexedDbFixture(), f = clientFixture({ indexedDB: idb.factory })
134
+ const off = f.generation().apply()
135
+ try {
136
+ f.set({ version: 1, units: [{ id: 'clock-skew', text: '有效通知', ts: Date.now() - 10 * 60 * 1000 }] })
137
+ await f.poll()
138
+ assert.deepEqual(f.shown, ['有效通知'])
139
+ } finally { off() }
140
+ const degraded = clientFixture({ storage: { get length() { throw new Error('blocked') }, getItem() { throw new Error('blocked') } } })
141
+ const offDegraded = degraded.generation().apply()
142
+ try {
143
+ degraded.set({ version: 1, units: [{ id: 'fallback', text: '降级', ts: Date.now() }] })
144
+ await degraded.poll(); await degraded.poll()
145
+ assert.deepEqual(degraded.shown, ['降级'])
146
+ } finally { offDegraded() }
25
147
  })
26
148
 
27
- test('失败退避与游标防呆:异常响应不退化成紧密首拉循环', () => {
28
- assert.match(source, /backoffMs = NOTIFY_RETRY_MIN_MS/, '成功后未重置退避基数')
29
- assert.match(source, /backoffMs = Math\.min\(backoffMs \* 2, NOTIFY_RETRY_MAX_MS\)/, '缺少指数退避')
30
- assert.match(source, /typeof payload\.version !== 'number'\) return false/, '版本缺失未按失败退避')
149
+ test('Given usage认领事务等待 When 本地快照消费超时 Then 回滚不留done且另窗能接手', async () => {
150
+ const idb = indexedDbFixture(), timeout = new AbortController()
151
+ const payload = { version: 1, units: [{ id: 'timeout', text: '可接手', ts: Date.now() - 10 * 60 * 1000 }] }
152
+ const a = clientFixture({ indexedDB: idb.factory,
153
+ abortSignalImpl: { timeout: () => timeout.signal, any: signals => AbortSignal.any(signals) } })
154
+ const offA = a.generation().apply()
155
+ a.set(payload); idb.hold()
156
+ const pending = a.poll()
157
+ await flush(); await flush()
158
+ timeout.abort(); idb.release(); await pending
159
+ assert.deepEqual(a.shown, [])
160
+ assert.equal(idb.rows.has('timeout'), false)
161
+ const b = clientFixture({ indexedDB: idb.factory }), offB = b.generation().apply()
162
+ try { b.set(payload); await b.poll(); assert.deepEqual(b.shown, ['可接手']) }
163
+ finally { offA(); offB() }
164
+ })
165
+
166
+ test('消费形态为推送总线订阅:setInterval 短轮询与长轮询游标不得回归', () => {
167
+ assert.ok(!source.includes('setInterval'), '残留 interval 短轮询形态')
168
+ assert.ok(!source.includes('while (!notifyController'), '残留长轮询主循环形态')
169
+ assert.match(source, /tools\.setupCommonTools\(ctx\)/, '缺少插件上下文绑定')
170
+ assert.match(source, /bus\.subscribe\(refresh, \{ onReset: refresh \}\)/, '缺少消息与重连快照入口')
171
+ assert.match(source, /AbortSignal\.timeout\(NOTIFY_REQUEST_TIMEOUT_MS\)/, '快照请求缺少超时上限')
172
+ assert.ok(!source.includes('?cursor='), '残留长轮询续传游标形态')
173
+ assert.ok(!source.includes('NOTIFY_RETRY'), '残留退避重试形态')
31
174
  })
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { test, after } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
+ import { busStub, resetBusStub } from './_mocks.common-tools.mjs'
6
7
  import { EventEmitter } from 'node:events'
7
8
  import { mkdtemp, rm } from 'node:fs/promises'
8
9
  import { tmpdir } from 'node:os'
@@ -20,13 +21,16 @@ after(async () => {
20
21
 
21
22
  const NOTIFY_ON = { notify: { enabled: true, quotaThresholdPct: 50 } }
22
23
 
23
- function makeCtx({ settingsValue = {}, dshIm = undefined } = {}) {
24
+ function makeCtx({ settingsValue = {}, dshIm = undefined, beforeSettingsUpdate } = {}) {
24
25
  let value = settingsValue
25
26
  const routes = new Map()
27
+ resetBusStub()
28
+ const published = busStub.published
26
29
  const settingsService = {
27
30
  register() {},
28
31
  get: () => value,
29
32
  update: async (_ns, patch) => {
33
+ if (beforeSettingsUpdate) await beforeSettingsUpdate(patch)
30
34
  value = { ...value, ...patch }
31
35
  },
32
36
  }
@@ -49,7 +53,7 @@ function makeCtx({ settingsValue = {}, dshIm = undefined } = {}) {
49
53
  },
50
54
  },
51
55
  }
52
- return { ctx, routes }
56
+ return { ctx, routes, published }
53
57
  }
54
58
 
55
59
  function makeReq(method, body, extraHeaders = {}) {
@@ -208,6 +212,58 @@ test('notify-config: 非法补丁 400, 合法补丁合并生效', async () => {
208
212
  assert.equal(view.payload.notify.webhookUrl, 'https://hooks.example.com/keep')
209
213
  })
210
214
 
215
+ test('notify-config: 并发 POST 串行化读改写, 先写不被后写基于旧快照覆盖', async () => {
216
+ // Given 慢写 settings.update(可控闸门队列,落盘真实发生),初始通知关闭
217
+ const gates = []
218
+ const writes = []
219
+ const beforeSettingsUpdate = async (patch) => {
220
+ writes.push(patch)
221
+ await new Promise((resolve) => gates.push(resolve))
222
+ }
223
+ const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: false, balanceThreshold: 20 } }, beforeSettingsUpdate })
224
+ apply(ctx)
225
+ // When 启用通知(写1)与保存阈值(写2)并发提交
226
+ const waitForGate = () => new Promise((resolve) => {
227
+ const poll = () => { gates.length === 1 ? resolve() : setImmediate(poll) }
228
+ poll()
229
+ })
230
+ const enable = call(routes, '/api/usage-panel/notify-config', makeReq('POST', { enabled: true }))
231
+ await waitForGate()
232
+ const threshold = call(routes, '/api/usage-panel/notify-config', makeReq('POST', { balanceThreshold: 5 }))
233
+ // Then 两写按提交序串行落盘:写2 须在写1 完成后才进入读改写(闸门二次出现),
234
+ // 且基于含 enabled=true 的快照合并
235
+ gates.shift()()
236
+ await waitForGate()
237
+ gates.shift()()
238
+ // 两写均已放闸,完成序颠倒也不影响断言(读快照序已由串行链锁定)
239
+ const [enableRes, thresholdRes] = await Promise.all([enable, threshold])
240
+ assert.equal(enableRes.status, 200)
241
+ assert.equal(thresholdRes.status, 200)
242
+ assert.equal(writes.length, 2)
243
+ assert.equal(writes[0].notify.enabled, true)
244
+ assert.equal(writes[1].notify.enabled, true, '写2 读改写须含写1 的 enabled=true')
245
+ assert.equal(writes[1].notify.balanceThreshold, 5)
246
+ })
247
+
248
+ test('notify-config: 单次写失败不阻塞后续写, 失败响应 400 且错误可见', async () => {
249
+ // Given settings.update 首次失败
250
+ let calls = 0
251
+ const flakyBefore = async () => {
252
+ calls += 1
253
+ if (calls === 1) throw new Error('disk full')
254
+ }
255
+ const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: false } }, beforeSettingsUpdate: flakyBefore })
256
+ apply(ctx)
257
+ // When 第一次写失败后第二次写
258
+ const first = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { enabled: true }))
259
+ const second = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { enabled: true }))
260
+ // Then 首写 400 携带原因, 次写成功生效
261
+ assert.equal(first.status, 400)
262
+ assert.match(first.payload.error, /disk full/)
263
+ assert.equal(second.status, 200)
264
+ assert.equal(calls, 2)
265
+ })
266
+
211
267
  test('test-webhook: 返回真实投递结果而非谎报成功', async () => {
212
268
  // Given webhook 已配置, fetch 桩标记调用
213
269
  const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/hook' } } })
@@ -414,32 +470,41 @@ test('长轮询: 无 cursor 首拉立即返回空投影与当前版本', async (
414
470
  assert.equal(res.payload.version, 0)
415
471
  })
416
472
 
417
- test('长轮询: cursor 追平当前版本时挂起, 事件 push 后唤醒并返回新版本', async () => {
418
- // Given 通知启用
473
+ test('快照: 恒立即返回全量投影与当前版本, 无挂起形态', async () => {
474
+ // Given 通知启用且投影为空
419
475
  const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
420
476
  apply(ctx)
421
- // When 以追平版本 0 发起长轮询
422
- const { req, res } = notifyRequest('?cursor=0')
423
- const pending = routes.get('/api/usage-panel/notifications')(req, res)
424
- await new Promise((resolve) => setTimeout(resolve, 0))
425
- // Then 未唤醒前不响应
426
- assert.equal(res.payload, null, '未唤醒前不应响应')
477
+ // When 快照请求(带或带 cursor 均为全量形态)
478
+ const { req, res } = notifyRequest('')
479
+ await routes.get('/api/usage-panel/notifications')(req, res)
480
+ // Then 立即返回且携带版本号
481
+ assert.equal(res.status, 200)
482
+ assert.deepEqual(res.payload.units, [])
483
+ assert.equal(res.payload.version, 0)
484
+ })
485
+
486
+ test('变更发布: 事件 push 进入投影即发布总线变更', async () => {
487
+ // Given 通知启用
488
+ const { ctx, routes, published } = makeCtx({ settingsValue: NOTIFY_ON })
489
+ apply(ctx)
427
490
  // When 查询产生越阈事件入投影
428
491
  await produceNotifyEvent(routes)
429
- await pending
430
- // Then 唤醒返回新版本与事件
492
+ // Then 快照携带事件,总线收到发布
493
+ const { req, res } = notifyRequest('')
494
+ await routes.get('/api/usage-panel/notifications')(req, res)
431
495
  assert.equal(res.status, 200)
432
- assert.equal(res.payload.version, 2, 'push 两条事件应各递增一次版本并唤醒等待者')
433
496
  assert.equal(res.payload.units.length, 2)
497
+ assert.ok(res.payload.version > 0)
498
+ assert.deepEqual(published, [null, null], '每条事件入投影各发布一次')
434
499
  })
435
500
 
436
- test('长轮询: cursor 落后当前版本时立即返回不挂起', async () => {
501
+ test('快照: 投影已有事件时返回全量', async () => {
437
502
  // Given 投影已有事件(版本已前进)
438
503
  const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
439
504
  apply(ctx)
440
505
  await produceNotifyEvent(routes)
441
- // When 以落后 cursor 请求
442
- const { req, res } = notifyRequest('?cursor=0')
506
+ // When 快照请求
507
+ const { req, res } = notifyRequest('')
443
508
  await routes.get('/api/usage-panel/notifications')(req, res)
444
509
  // Then 立即返回全量
445
510
  assert.equal(res.status, 200)