@mzzsfy/dsh-usage-panel 0.4.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/src/client.js ADDED
@@ -0,0 +1,1424 @@
1
+ // 用量面板 Client 半区:settings.section 设置页,账号配置 + 手动查询读数。
2
+ // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory}),
3
+ // factory(require) 中 require('react') 由 DSH client runtime 的模块表解析;
4
+ // 浏览器半区通过 webServer 路由('/api/usage-panel/*')访问 Host,样式随组件内联渲染。
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: '@mzzsfy/dsh-usage-panel',
8
+ factory(require) {
9
+ const React = require('react')
10
+ const { useState, useEffect, useRef } = React
11
+
12
+ // 面板反馈出口:公共依赖 @mzzsfy/dsh-toast,可选消费——占位条目由
13
+ // session-manager 唯一代挂,权威方未安装时降级 console,不挂死不报错
14
+ let toast = null
15
+ try {
16
+ toast = require('@mzzsfy/dsh-toast/client').show
17
+ } catch {
18
+ // 模块表无 toast → 反馈降级 console.warn
19
+ }
20
+
21
+ const notify = (text, kind) => {
22
+ if (toast) toast(text, { kind: kind === 'ok' ? 'ok' : 'error' })
23
+ else console.warn('[dsh-usage-panel] ' + text)
24
+ }
25
+
26
+ /* LOGIC-BEGIN */
27
+ // 认领状态机:与 src/notify.mjs decideClaim 镜像,parity 测试锁定不漂移。
28
+ const CLAIM_LOCK_TTL_MS = 30 * 1000
29
+ const KEY_LOCK = 'usage-panel:notify-lock:'
30
+ const KEY_DONE = 'usage-panel:notify-done:'
31
+ const windowId = 'win-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1000000).toString(36)
32
+ // 存储可用性:首次写失败即标记 broken,后续认领放行本窗口直发(诚实降级不静默丢通知)
33
+ let storageBroken = false
34
+ const localGet = (key) => {
35
+ try { return window.localStorage.getItem(key) } catch { storageBroken = true; return null }
36
+ }
37
+ const localSet = (key, value) => {
38
+ try { window.localStorage.setItem(key, value) } catch { storageBroken = true }
39
+ }
40
+ const localDel = (key) => {
41
+ try { window.localStorage.removeItem(key) } catch { storageBroken = true }
42
+ }
43
+ // undefined 判定与 src/notify.mjs decideClaim 同形:镜像语义含 undefined 域,parity 锁定
44
+ function decideClaim(stored, done, now, wid) {
45
+ if (done !== null && done !== undefined) return 'done'
46
+ if (stored === null || stored === undefined) return 'claim'
47
+ let lock = null
48
+ try { lock = JSON.parse(stored) } catch { lock = null }
49
+ if (lock === null || typeof lock !== 'object' || typeof lock.at !== 'number' || typeof lock.wid !== 'string') return 'takeover'
50
+ if (now - lock.at >= CLAIM_LOCK_TTL_MS) return 'takeover'
51
+ return lock.wid === wid ? 'claim' : 'skip'
52
+ }
53
+ // 单元级认领(写后读回确认):claim/takeover 持有展示权,skip/done 让渡;
54
+ // 存储不可用时放行本窗口直发,多窗口去重让位于通知不丢
55
+ function claimEvent(id) {
56
+ if (storageBroken) return true
57
+ const now = Date.now()
58
+ const verdict = decideClaim(localGet(KEY_LOCK + id), localGet(KEY_DONE + id), now, windowId)
59
+ if (verdict !== 'claim' && verdict !== 'takeover') return false
60
+ localSet(KEY_LOCK + id, JSON.stringify({ wid: windowId, at: now }))
61
+ let confirmed = null
62
+ try { confirmed = JSON.parse(localGet(KEY_LOCK + id)) } catch { confirmed = null }
63
+ // 读回确认失败即存储中途不可用,同样放行直发
64
+ return (confirmed !== null && confirmed.wid === windowId) || storageBroken
65
+ }
66
+ const markDone = (id) => localSet(KEY_DONE + id, '1')
67
+
68
+ // IM 目标列表操作:与 src/notify.mjs 同形,parity 测试锁定不漂移。
69
+ // botId/targetId 字符集均不含 '/',拼接键无歧义;与 dsh-im delivery-service 共用 ID 规格。
70
+ const imTargetKey = (item) => item.botId + '/' + item.targetId
71
+ function toggleImTargetList(list, botId, targetId, checked) {
72
+ const wanted = { botId, targetId }
73
+ const rest = list.filter((item) => imTargetKey(item) !== imTargetKey(wanted))
74
+ return checked ? rest.concat([wanted]) : rest
75
+ }
76
+ function removeImTargetFromList(list, botId, targetId) {
77
+ return list.filter((item) => imTargetKey(item) !== botId + '/' + targetId)
78
+ }
79
+ function unregisterImBotList(list, botId) {
80
+ return list.filter((item) => item.botId !== botId)
81
+ }
82
+ function imBoundBotIds(list) {
83
+ const botIds = []
84
+ for (const item of list) {
85
+ if (!botIds.includes(item.botId)) botIds.push(item.botId)
86
+ }
87
+ return botIds
88
+ }
89
+ /* LOGIC-END */
90
+
91
+ // 页内通知长轮询:client 激活即挂起等待,不依赖面板打开;toast 库缺失(权威代挂方
92
+ // session-manager 未安装)整段跳过;批内逐条认领防多窗口重复弹;
93
+ // 代际令牌自愈:HMR/闭包重建首挂 abort 旧代长轮询再启新代,旧代不滞留不叠加
94
+ 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
+ const NOTIFY_TOAST_MS = 6 * 1000
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()
104
+ const notifyController = new AbortController()
105
+ window[KEY_POLL_TOKEN] = notifyController
106
+ // 已见投影版本:空即未首拉;长轮询续传游标,响应后随 payload 推进
107
+ 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
+ async function notifyPollOnce(signal) {
124
+ let payload
125
+ try {
126
+ // cursor 为空即首拉,服务端立即返回全量;之后携带版本挂起等待增量
127
+ const query = notifyCursor === null ? '' : '?cursor=' + notifyCursor
128
+ 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 })
131
+ if (!response.ok) {
132
+ void response.body?.cancel()
133
+ return false
134
+ }
135
+ payload = await response.json()
136
+ } catch {
137
+ return false
138
+ }
139
+ // 版本缺失即异常响应:按失败退避,防游标停滞退化成紧密首拉循环
140
+ if (typeof payload.version !== 'number') return false
141
+ notifyCursor = payload.version
142
+ const units = payload && Array.isArray(payload.units) ? payload.units : []
143
+ const liveIds = new Set(units.map((unit) => unit.id))
144
+ // 投影中已过期的本地残留清理,防旧锁与完成标记无限滞留;
145
+ // 存储不可用时跳过清理,认领侧已放行直发
146
+ if (!storageBroken) {
147
+ try {
148
+ for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
149
+ const key = window.localStorage.key(index)
150
+ if (key === null || (key.indexOf(KEY_LOCK) !== 0 && key.indexOf(KEY_DONE) !== 0)) continue
151
+ const id = key.indexOf(KEY_LOCK) === 0 ? key.slice(KEY_LOCK.length) : key.slice(KEY_DONE.length)
152
+ if (!liveIds.has(id)) localDel(key)
153
+ }
154
+ } catch { storageBroken = true }
155
+ }
156
+ for (const unit of units) {
157
+ if (!claimEvent(unit.id)) continue
158
+ markDone(unit.id)
159
+ toast(unit.text, { kind: unit.kind === 'reset' ? 'ok' : 'error', holdMs: NOTIFY_TOAST_MS })
160
+ }
161
+ return true
162
+ }
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
+ })()
182
+ }
183
+
184
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → plan);
185
+ // 该插件未就绪时入队,由其启动时排空
186
+ const NAV_ICON = { '账号余额': 'plan' }
187
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
188
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
189
+ else window.__navicIconQueue = [NAV_ICON]
190
+
191
+ 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);',
199
+ ' display:flex; flex-direction:column; gap:14px; color:inherit; font-size:13px; }',
200
+ // ---- 顶栏 ----
201
+ '.up-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
202
+ '.up-head__title { font-weight:600; font-size:15px; letter-spacing:0.2px; }',
203
+ '.up-head__hint { color:var(--up-muted); font-size:12px; }',
204
+ '.up-spacer { flex:1; }',
205
+ // ---- 按钮体系:实底主按钮 / ghost 次按钮 / 危险红调 ----
206
+ '.up-btn { cursor:pointer; border:1px solid var(--up-border); background:transparent;',
207
+ ' color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit;',
208
+ ' 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); }',
210
+ '.up-btn:active { transform:translateY(1px); }',
211
+ '.up-btn:disabled { opacity:0.45; cursor:default; transform:none; }',
212
+ '.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; }',
214
+ '.up-btn--primary:hover { background:var(--up-focus); opacity:0.88; }',
215
+ '.up-btn--danger { color:var(--up-err); border-color:color-mix(in srgb, var(--up-err) 45%, transparent); }',
216
+ '.up-btn--danger:hover { border-color:var(--up-err); background:color-mix(in srgb, var(--up-err) 12%, transparent); }',
217
+ // ---- 账号卡:透明底随宿主主题,边框定界,hover 极淡提层 ----
218
+ '.up-card { border:1px solid var(--up-border); border-radius:12px; padding:14px 16px;',
219
+ ' display:flex; flex-direction:column; gap:10px;',
220
+ ' transition:border-color 0.15s ease, background 0.15s ease; }',
221
+ '.up-card:hover { border-color:color-mix(in srgb, var(--up-focus) 40%, var(--up-border));',
222
+ ' background:rgba(128,148,180,0.06); }',
223
+ '.up-card__row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
224
+ '.up-card__name { font-weight:600; font-size:15px; }',
225
+ // 平台徽章:按平台固定色相着色的胶囊 pill;custom 走缺省灰底(低调平台共用)
226
+ '.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; }',
235
+ // ---- 读数行:标签 + 渐变进度条 + 等宽数字 ----
236
+ '.up-reading { display:flex; flex-direction:column; gap:6px; }',
237
+ '.up-row { display:flex; align-items:center; gap:10px; position:relative; }',
238
+ '.up-row__label { width:52px; flex:none; font-size:12px; color:var(--up-muted); white-space:nowrap; }',
239
+ '.up-bar { flex:1; min-width:120px; max-width:320px; height:8px; border-radius:999px; overflow:hidden; flex:none;',
240
+ ' background:var(--up-border); }',
241
+ '.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));',
243
+ ' 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); }',
246
+ '.up-pct { font-variant-numeric:tabular-nums; font-weight:600; font-size:13px; min-width:44px; text-align:right; }',
247
+ // ---- 数字与状态色 ----
248
+ '.up-meta { color:var(--up-muted); font-size:12px; line-height:1.6; }',
249
+ '.up-error { color:var(--up-err); font-size:12px; }',
250
+ '.up-ok { color:var(--up-ok); }',
251
+ '.up-warn { color:var(--up-warn); }',
252
+ '.up-num { font-variant-numeric:tabular-nums; }',
253
+ '.up-amount { font-variant-numeric:tabular-nums; font-weight:600; font-size:14px; }',
254
+ '.up-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; vertical-align:middle;',
255
+ ' background:var(--up-ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--up-ok) 18%, transparent); }',
256
+ '.up-dot--off { background:var(--up-err); box-shadow:0 0 0 3px color-mix(in srgb, var(--up-err) 18%, transparent); }',
257
+ // ---- 悬浮提示(读数明细) ----
258
+ '.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;',
260
+ ' 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); }',
262
+ '.up-row:hover .up-tip { visibility:visible; opacity:1; }',
263
+ // ---- 趋势弹层:毛玻璃 ----
264
+ '.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;',
266
+ ' backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);',
267
+ ' border:1px solid rgba(255,255,255,0.12);',
268
+ ' 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); }',
270
+ '.up-trend__title { font-weight:600; margin-bottom:4px; font-size:12px; }',
271
+ '.up-trend__chart { margin:2px 0 6px; }',
272
+ '.up-trend__chart text { fill:currentColor; font-size:9px; }',
273
+ '.up-trend__point:hover circle { r:4; }',
274
+ '.up-card:hover .up-trend { visibility:visible; opacity:1; }',
275
+ '.up-card:hover { cursor:default; }',
276
+ // ---- 对话框:毛玻璃遮罩 + 提升卡片 ----
277
+ '.up-dialog-mask { position:fixed; inset:0; background:rgba(0,0,0,0.5); z-index:60;',
278
+ ' backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);',
279
+ ' display:flex; align-items:center; justify-content:center; }',
280
+ '.up-dialog { background:var(--up-surface); color:inherit; border:1px solid var(--up-border); border-radius:14px;',
281
+ ' 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); }',
283
+ '.up-dialog table { border-collapse:collapse; width:100%; font-size:12px; }',
284
+ '.up-dialog th, .up-dialog td { text-align:left; padding:4px 10px;',
285
+ ' border-bottom:1px solid var(--up-border); }',
286
+ '.up-dialog th { color:var(--up-muted); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }',
287
+ // ---- 表单:透明底与宿主融合 ----
288
+ '.up-form { border:1px solid var(--up-border); border-radius:12px; padding:16px;',
289
+ ' display:flex; flex-direction:column; gap:10px; }',
290
+ '.up-form--nested { border-style:dashed; }',
291
+ '.up-field { display:flex; flex-direction:column; gap:4px; }',
292
+ '.up-field__label { font-size:12px; color:var(--up-muted); }',
293
+ '.up-field input, .up-field select, .up-field textarea {',
294
+ ' background:transparent; color:inherit; border:1px solid var(--up-border);',
295
+ ' border-radius:8px; padding:6px 10px; font-size:13px; font-family:inherit; box-sizing:border-box; width:100%;',
296
+ ' transition:border-color 0.15s ease, box-shadow 0.15s ease; }',
297
+ '.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
+ '.up-field input:focus-visible, .up-field select:focus-visible, .up-field textarea:focus-visible {',
299
+ ' 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; }',
301
+ '.up-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; }',
302
+ // ---- 通知与提示 ----
303
+ '.up-notice { font-size:12px; padding:7px 11px; border-radius:9px;',
304
+ ' border:1px solid var(--up-border); background:color-mix(in srgb, var(--up-err) 8%, transparent); }',
305
+ '.up-notice--error { color:var(--up-err); border-color:color-mix(in srgb, var(--up-err) 35%, transparent); }',
306
+ // ---- 折叠卡:details/summary 原生折叠,摘要行常显状态 ----
307
+ '.up-fold > summary { list-style:none; cursor:pointer; display:flex; align-items:center; gap:8px;',
308
+ ' border-radius:8px; transition:opacity 0.15s ease; }',
309
+ '.up-fold > summary::-webkit-details-marker { display:none; }',
310
+ '.up-fold > summary:hover { opacity:0.8; }',
311
+ '.up-fold > summary::after { content:""; width:6px; height:6px; flex:none; margin-left:auto; opacity:0.5;',
312
+ ' border-right:1.5px solid currentColor; border-bottom:1.5px solid currentColor;',
313
+ ' transform:rotate(-45deg); transition:transform 0.15s ease; }',
314
+ '.up-fold[open] > summary::after { transform:rotate(45deg); }',
315
+ '.up-fold:not([open]) { padding:10px 16px; }',
316
+ // ---- 分区标题(通知卡内分组) ----
317
+ '.up-section { display:flex; flex-direction:column; gap:8px; }',
318
+ '.up-section__title { font-size:11px; font-weight:600; letter-spacing:0.8px; text-transform:uppercase;',
319
+ ' color:var(--up-muted); padding-bottom:4px; border-bottom:1px solid var(--up-border); }',
320
+ // ---- IM 目标列表行 ----
321
+ '.up-list { display:flex; flex-direction:column; }',
322
+ '.up-list__item { display:flex; align-items:center; gap:10px; padding:6px 2px; border-bottom:1px solid var(--up-border); }',
323
+ '.up-list__item:last-child { border-bottom:none; }',
324
+ '.up-list__grow { flex:1; font-size:12px; }',
325
+ '.up-list__tag { font-size:11px; color:var(--up-muted); }',
326
+ // ---- chips:已绑 bot 管理 ----
327
+ '.up-chip { display:inline-flex; align-items:center; gap:2px; font-size:11px; padding:2px 4px 2px 9px; border-radius:999px;',
328
+ ' border:1px solid var(--up-border); background:color-mix(in srgb, var(--up-focus) 7%, transparent); }',
329
+ '.up-chip__name { cursor:pointer; border:none; background:transparent; color:inherit; padding:1px 2px; font-size:11px; font-family:inherit; }',
330
+ '.up-chip__name--active { color:var(--up-focus); font-weight:600; }',
331
+ '.up-chip__name:focus-visible { outline:2px solid var(--up-focus); outline-offset:1px; border-radius:4px; }',
332
+ '.up-chip__x { cursor:pointer; border:none; background:transparent; color:var(--up-muted); padding:0 5px; font-size:12px; border-radius:50%; line-height:1.4; }',
333
+ '.up-chip__x:hover { color:var(--up-err); }',
334
+ '.up-chip__x:focus-visible { outline:2px solid var(--up-focus); outline-offset:1px; }',
335
+ // ---- 布尔开关:规约形态(track 胶囊 + thumb 圆点,状态锚定 input) ----
336
+ '.up-switch { display:inline-flex; align-items:center; gap:6px; cursor:pointer; position:relative; }',
337
+ '.up-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
338
+ '.up-switch__track { width:30px; height:16px; border-radius:999px; flex:none; position:relative;',
339
+ ' background:var(--up-border); transition:background 0.15s ease; }',
340
+ '.up-switch__thumb { position:absolute; top:2px; left:2px; width:12px; height:12px; border-radius:50%;',
341
+ ' background:#fff; transition:left 0.15s ease; }',
342
+ '.up-switch input[type="checkbox"]:checked + .up-switch__track { background:var(--up-ok); }',
343
+ '.up-switch input[type="checkbox"]:checked + .up-switch__track .up-switch__thumb { left:16px; }',
344
+ '.up-switch input[type="checkbox"]:focus-visible + .up-switch__track { outline:2px solid var(--up-focus); outline-offset:2px; }',
345
+ '.up-switch input[type="checkbox"]:disabled + .up-switch__track { opacity:0.45; }',
346
+ '.up-switch:hover { opacity:0.85; }',
347
+ ].join('\n')
348
+
349
+ const TYPE_LABELS = {
350
+ deepseek: 'DeepSeek 官方',
351
+ openrouter: 'OpenRouter',
352
+ kimi: 'Kimi Code',
353
+ zhipu: '智谱 GLM',
354
+ minimax: 'MiniMax',
355
+ newapi: 'NewApi/OneApi',
356
+ custom: '自定义端点',
357
+ }
358
+
359
+ const CURRENCY_SYMBOLS = { CNY: '¥', USD: '$', EUR: '€' }
360
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
361
+ const WARN_PCT = 70
362
+ const CRIT_PCT = 90
363
+ const PERCENT_BASE = 100
364
+
365
+ const NEWAPI_EXAMPLE_HEADERS = { Authorization: 'Bearer sk-你的key' }
366
+ const NEWAPI_EXAMPLE_EXTRACT = {
367
+ remaining: { op: 'divide', path: 'data.total_available', by: 500000 },
368
+ maxBudget: { op: 'divide', path: 'data.total_granted', by: 500000 },
369
+ spend: { op: 'divide', path: 'data.total_used', by: 500000 },
370
+ unit: 'USD',
371
+ }
372
+
373
+ async function api(path, options) {
374
+ const response = await fetch(path, {
375
+ headers: { 'content-type': 'application/json' },
376
+ ...options,
377
+ })
378
+ const payload = await response.json()
379
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
380
+ return payload
381
+ }
382
+
383
+ function currencySymbol(code) {
384
+ return Object.prototype.hasOwnProperty.call(CURRENCY_SYMBOLS, code) ? CURRENCY_SYMBOLS[code] : code + ' '
385
+ }
386
+
387
+ function fmtMoney(value) {
388
+ const n = Number(value)
389
+ if (n !== n) return '—'
390
+ return n.toFixed(2)
391
+ }
392
+
393
+ function fmtInt(value) {
394
+ const n = Number(value)
395
+ return n === n ? String(Math.round(n)) : '—'
396
+ }
397
+
398
+ function fmtPct(value) {
399
+ const n = Number(value)
400
+ return n === n ? Math.round(n) + '%' : '—'
401
+ }
402
+
403
+ function fmtPctPrecise(value) {
404
+ const n = Number(value)
405
+ return n === n ? n.toFixed(1) + '%' : '—'
406
+ }
407
+
408
+ function pctClass(value) {
409
+ const n = Number(value)
410
+ if (n !== n) return ''
411
+ if (n >= CRIT_PCT) return 'up-error'
412
+ if (n >= WARN_PCT) return 'up-warn'
413
+ return 'up-ok'
414
+ }
415
+
416
+ function fmtTime(ms) {
417
+ const t = Number(ms)
418
+ if (t !== t) return null
419
+ const d = new Date(t)
420
+ const pad = (n) => (n < 10 ? '0' : '') + n
421
+ return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
422
+ }
423
+
424
+ function fmtTimeFull(t) {
425
+ const d = new Date(t)
426
+ const pad = (n) => (n < 10 ? '0' : '') + n
427
+ return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
428
+ }
429
+
430
+ function fmtRelative(t) {
431
+ const delta = t - Date.now()
432
+ if (delta <= 0) return '已可重置'
433
+ const minute = 60 * 1000
434
+ const hour = 60 * minute
435
+ const day = 24 * hour
436
+ if (delta < hour) return Math.max(1, Math.round(delta / minute)) + '分钟后'
437
+ if (delta < day) return Math.round(delta / hour) + '小时后'
438
+ return Math.round(delta / day) + '天后'
439
+ }
440
+
441
+ // 悬浮窗重置时间行:本地完整时间 + 倒计时,无时间数据时不输出。
442
+ function resetLine(resetsAt) {
443
+ if (!resetsAt) return null
444
+ const t = Date.parse(resetsAt)
445
+ if (t !== t) return null
446
+ return '重置 ' + fmtTimeFull(t) + '(' + fmtRelative(t) + ')'
447
+ }
448
+
449
+ function barWidth(value) {
450
+ const n = Number(value)
451
+ if (n !== n) return '0%'
452
+ return Math.max(0, Math.min(100, n)) + '%'
453
+ }
454
+
455
+ function fillClass(value) {
456
+ const n = Number(value)
457
+ if (n !== n) return ''
458
+ if (n >= CRIT_PCT) return ' up-bar__fill--crit'
459
+ if (n >= WARN_PCT) return ' up-bar__fill--warn'
460
+ return ''
461
+ }
462
+
463
+ function h(type, props) {
464
+ const children = Array.prototype.slice.call(arguments, 2)
465
+ return React.createElement.apply(React, [type, props || null].concat(children))
466
+ }
467
+
468
+ function newId() {
469
+ return 'acct-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1000000).toString(36)
470
+ }
471
+
472
+ function TipLines(props) {
473
+ return h('span', { className: 'up-tip' },
474
+ props.lines.map((line, index) => h('div', { key: String(index) }, line)),
475
+ )
476
+ }
477
+
478
+ function BalanceReading(props) {
479
+ const entries = props.reading.entries || []
480
+ const rows = entries.map((entry, index) => {
481
+ const sym = currencySymbol(entry.currency)
482
+ const tipLines = []
483
+ let main
484
+ if (entry.remaining !== null && entry.remaining !== undefined) {
485
+ main = '余 ' + sym + fmtMoney(entry.remaining) +
486
+ (entry.total !== null && entry.total !== undefined ? ' / 总 ' + sym + fmtMoney(entry.total) : '')
487
+ if (entry.used !== null && entry.used !== undefined) {
488
+ tipLines.push('已用 ' + sym + fmtMoney(entry.used))
489
+ if (entry.total > 0) tipLines.push('已用 ' + fmtPctPrecise((entry.used / entry.total) * PERCENT_BASE) + ' 的额度')
490
+ }
491
+ } else {
492
+ main = '余额 ' + sym + fmtMoney(entry.total)
493
+ }
494
+ const extra = []
495
+ if (entry.granted !== null && entry.granted !== undefined) extra.push('赠送 ' + sym + fmtMoney(entry.granted))
496
+ if (entry.toppedUp !== null && entry.toppedUp !== undefined) extra.push('充值 ' + sym + fmtMoney(entry.toppedUp))
497
+ if (extra.length > 0) tipLines.push(extra.join(' · '))
498
+ if (entry.isAvailable === false) tipLines.push('账户不可用')
499
+ const usedPct = entry.total !== null && entry.total !== undefined && entry.total > 0 &&
500
+ entry.used !== null && entry.used !== undefined
501
+ ? (entry.used / entry.total) * PERCENT_BASE
502
+ : null
503
+ return h('div', { className: 'up-row', key: String(index) },
504
+ h('span', { className: 'up-row__label' }, entry.currency),
505
+ usedPct !== null
506
+ ? h('span', { className: 'up-bar' },
507
+ h('span', { className: 'up-bar__fill' + fillClass(usedPct), style: { width: barWidth(usedPct) } }))
508
+ : h('span', { className: 'up-amount' }, main),
509
+ usedPct !== null ? h('span', { className: 'up-pct ' + pctClass(usedPct) }, fmtPct(usedPct)) : null,
510
+ h(TipLines, { lines: [main].concat(tipLines) }),
511
+ )
512
+ })
513
+ return h('div', { className: 'up-reading' }, rows)
514
+ }
515
+
516
+ function QuotaReading(props) {
517
+ const windows = props.reading.windows || []
518
+ const rows = windows.map((win, index) => {
519
+ const tipLines = [win.label + '窗口:已用 ' + fmtPctPrecise(win.utilization)]
520
+ const used = Number.isFinite(Number(win.limit)) && Number.isFinite(Number(win.remaining))
521
+ ? Number(win.limit) - Number(win.remaining)
522
+ : null
523
+ if (used !== null && Number(win.limit) > 0) {
524
+ tipLines.push('已用 ' + fmtInt(used) + ' · 余 ' + fmtInt(win.remaining) + ' · 总 ' + fmtInt(win.limit))
525
+ } else if (Number(win.limit) > 0) {
526
+ tipLines.push('余 ' + fmtInt(win.remaining) + ' · 总 ' + fmtInt(win.limit))
527
+ }
528
+ const reset = resetLine(win.resetsAt)
529
+ if (reset !== null) tipLines.push(reset)
530
+ if (Array.isArray(win.details) && win.details.length > 0) {
531
+ tipLines.push('模型明细: ' + win.details.map((d) => d.model + ' ×' + fmtInt(d.usage)).join(', '))
532
+ }
533
+ return h('div', { className: 'up-row', key: String(index) },
534
+ h('span', { className: 'up-row__label' }, win.label),
535
+ h('span', { className: 'up-bar' },
536
+ h('span', { className: 'up-bar__fill' + fillClass(win.utilization), style: { width: barWidth(win.utilization) } })),
537
+ h('span', { className: 'up-pct ' + pctClass(win.utilization) }, fmtPct(win.utilization)),
538
+ h(TipLines, { lines: tipLines }),
539
+ )
540
+ })
541
+ return h('div', { className: 'up-reading' }, rows)
542
+ }
543
+
544
+ function ReadingView(props) {
545
+ const last = props.last
546
+ if (!last) return h('span', { className: 'up-meta' }, '未查询,点击「刷新」获取读数')
547
+ if (!last.ok) return h('span', { className: 'up-error' }, '查询失败:' + (last.error || '未知错误'))
548
+ const reading = last.reading
549
+ if (!reading) return h('span', { className: 'up-error' }, '查询结果为空')
550
+ if (reading.kind === 'quota') return h(QuotaReading, { reading })
551
+ return h(BalanceReading, { reading })
552
+ }
553
+
554
+ // 档位徽章文案:已知档位首字母大写,未知值原样透传。
555
+ const LEVEL_LABELS = { pro: 'Pro', max: 'Max' }
556
+
557
+ function levelLabel(value) {
558
+ const key = String(value).toLowerCase()
559
+ return Object.prototype.hasOwnProperty.call(LEVEL_LABELS, key) ? LEVEL_LABELS[key] : String(value)
560
+ }
561
+
562
+ function AccountCard(props) {
563
+ const account = props.account
564
+ const busy = props.busy === true
565
+ const armed = props.deleteArmed === true
566
+ const reading = account.last && account.last.ok ? account.last.reading : null
567
+ const level = reading && (reading.level || reading.membership) ? levelLabel(reading.level || reading.membership) : null
568
+
569
+ return h('div', { className: 'up-card' },
570
+ h('div', { className: 'up-card__row' },
571
+ h('span', { className: 'up-card__name' }, account.name),
572
+ h('span', { className: 'up-badge', 'data-type': account.type }, TYPE_LABELS[account.type] || account.type),
573
+ level ? h('span', { className: 'up-badge', 'data-type': 'custom' }, level) : null,
574
+ h('span', { className: 'up-spacer' }),
575
+ h('button', { className: 'up-btn', disabled: busy, onClick: props.onRefresh }, busy ? '查询中…' : '刷新'),
576
+ h('button', { className: 'up-btn', disabled: busy, onClick: props.onEdit }, '编辑'),
577
+ h('button', {
578
+ className: 'up-btn' + (armed ? ' up-btn--danger' : ''),
579
+ disabled: busy, onClick: props.onDelete,
580
+ }, armed ? '确认删除' : '删除'),
581
+ ),
582
+ h('div', { className: 'up-card__row' }, h(ReadingView, { last: account.last })),
583
+ h(TrendPopover, { accountName: account.name, sequences: props.sequences || {} }),
584
+ )
585
+ }
586
+
587
+ // 表单草稿 <-> 账号对象互转,custom 的 headers/extract 以 JSON 文本编辑。
588
+ function draftFromAccount(account) {
589
+ const empty = {
590
+ id: null, isNew: true, name: '', type: 'deepseek',
591
+ baseUrl: '', apiKey: '', url: '', method: 'GET', headersText: '', bodyText: '', extractText: '',
592
+ notifyQuota: '', notifyBalance: '', notifyReset: '',
593
+ }
594
+ if (!account) return empty
595
+ const custom = account.custom || {}
596
+ const notify = account.notify && typeof account.notify === 'object' ? account.notify : {}
597
+ return {
598
+ id: account.id,
599
+ isNew: false,
600
+ hasKey: account.hasKey === true,
601
+ name: account.name,
602
+ type: account.type,
603
+ baseUrl: account.baseUrl || '',
604
+ apiKey: account.apiKey || '',
605
+ url: custom.url || '',
606
+ method: custom.method || 'GET',
607
+ headersText: custom.headers && Object.keys(custom.headers).length > 0 ? JSON.stringify(custom.headers, null, 2) : '',
608
+ bodyText: custom.body || '',
609
+ extractText: custom.extract && Object.keys(custom.extract).length > 0 ? JSON.stringify(custom.extract, null, 2) : '',
610
+ notifyQuota: notify.quotaThresholdPct === undefined ? '' : String(notify.quotaThresholdPct),
611
+ notifyBalance: notify.balanceThreshold === undefined || notify.balanceThreshold === null ? '' : String(notify.balanceThreshold),
612
+ notifyReset: notify.resetNotice === true ? 'on' : notify.resetNotice === false ? 'off' : '',
613
+ }
614
+ }
615
+
616
+ function parseJsonField(text, fieldName) {
617
+ if (typeof text !== 'string' || text.trim().length === 0) return {}
618
+ const parsed = JSON.parse(text)
619
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
620
+ throw new Error(fieldName + ' 必须是 JSON 对象')
621
+ }
622
+ return parsed
623
+ }
624
+
625
+ function buildAccountFromDraft(draft) {
626
+ if (!draft.name || draft.name.trim().length === 0) throw new Error('请填写账号名称')
627
+ const account = {
628
+ id: draft.isNew ? newId() : draft.id,
629
+ name: draft.name.trim(),
630
+ type: draft.type,
631
+ baseUrl: draft.baseUrl.trim(),
632
+ apiKey: draft.apiKey.trim(),
633
+ custom: { url: '', method: 'GET', headers: {}, body: '', extract: {} },
634
+ }
635
+ if (draft.type === 'custom') {
636
+ account.custom.url = draft.url.trim()
637
+ account.custom.method = draft.method
638
+ account.custom.headers = parseJsonField(draft.headersText, '请求头')
639
+ account.custom.body = draft.bodyText
640
+ account.custom.extract = parseJsonField(draft.extractText, '提取规则')
641
+ if (account.custom.url.length === 0) throw new Error('自定义端点必须填写完整 URL')
642
+ } else if (draft.type === 'newapi' && account.baseUrl.length === 0) {
643
+ throw new Error('NewApi/OneApi 需要填写 API 基础地址')
644
+ } else if (draft.apiKey.length === 0 && !draft.hasKey) {
645
+ throw new Error('该平台需要填写 API Key')
646
+ }
647
+ // 通知覆盖:仅非空字段写入,留空即继承全局(空串不产生覆盖键)
648
+ const notifyOverride = {}
649
+ if (draft.notifyQuota.trim().length > 0) {
650
+ const quota = Number(draft.notifyQuota)
651
+ if (!Number.isFinite(quota) || quota <= 0 || quota > PERCENT_BASE) throw new Error('用量阈值须为 (0,100] 内数值')
652
+ notifyOverride.quotaThresholdPct = quota
653
+ }
654
+ if (draft.notifyBalance.trim().length > 0) {
655
+ const balance = Number(draft.notifyBalance)
656
+ if (!Number.isFinite(balance) || balance < 0) throw new Error('余额阈值须为非负数值')
657
+ notifyOverride.balanceThreshold = balance
658
+ }
659
+ if (draft.notifyReset === 'on' || draft.notifyReset === 'off') notifyOverride.resetNotice = draft.notifyReset === 'on'
660
+ if (Object.keys(notifyOverride).length > 0) account.notify = notifyOverride
661
+ return account
662
+ }
663
+
664
+ function AccountForm(props) {
665
+ const [draft, setDraft] = useState(() => draftFromAccount(props.initial))
666
+ const [error, setError] = useState(null)
667
+
668
+ function patch(part) {
669
+ setDraft((prev) => Object.assign({}, prev, part))
670
+ }
671
+
672
+ function submit() {
673
+ try {
674
+ props.onSave(buildAccountFromDraft(draft))
675
+ } catch (submitError) {
676
+ setError(submitError && submitError.message ? submitError.message : String(submitError))
677
+ }
678
+ }
679
+
680
+ const isCustom = draft.type === 'custom'
681
+ const typeOptions = Object.keys(TYPE_LABELS).map((type) => h('option', { key: type, value: type }, TYPE_LABELS[type]))
682
+
683
+ return h('div', { className: 'up-form' },
684
+ h('div', { className: 'up-grid' },
685
+ h('div', { className: 'up-field' },
686
+ h('label', { className: 'up-field__label' }, '账号名称'),
687
+ h('input', { value: draft.name, onChange: (e) => patch({ name: e.target.value }), placeholder: '如 DeepSeek 主号' }),
688
+ ),
689
+ h('div', { className: 'up-field' },
690
+ h('label', { className: 'up-field__label' }, '平台类型'),
691
+ h('select', { value: draft.type, onChange: (e) => patch({ type: e.target.value }) }, typeOptions),
692
+ ),
693
+ ),
694
+ !isCustom ? h('div', { className: 'up-grid' },
695
+ h('div', { className: 'up-field' },
696
+ h('label', { className: 'up-field__label', title: '留空使用平台官方默认地址' }, 'API 基础地址'),
697
+ h('input', {
698
+ value: draft.baseUrl,
699
+ onChange: (e) => patch({ baseUrl: e.target.value }),
700
+ placeholder: draft.type === 'newapi' ? 'https://你的站点' : 'https://官方地址',
701
+ }),
702
+ ),
703
+ h('div', { className: 'up-field' },
704
+ h('label', { className: 'up-field__label', title: '仅存于本机配置,用于查询余额;已保存时留空保持不变' }, 'API Key'),
705
+ h('input', {
706
+ value: draft.apiKey,
707
+ onChange: (e) => patch({ apiKey: e.target.value }),
708
+ placeholder: draft.hasKey ? '已保存,留空保持不变' : 'sk-…',
709
+ }),
710
+ ),
711
+ ) : null,
712
+ isCustom ? h('div', { className: 'up-field' },
713
+ h('label', { className: 'up-field__label', title: '自定义余额端点的完整地址' }, '请求 URL'),
714
+ h('input', { value: draft.url, onChange: (e) => patch({ url: e.target.value }), placeholder: 'https://example.com/api/balance' }),
715
+ ) : null,
716
+ isCustom ? h('div', { className: 'up-grid' },
717
+ h('div', { className: 'up-field' },
718
+ h('label', { className: 'up-field__label' }, '请求方法'),
719
+ h('select', { value: draft.method, onChange: (e) => patch({ method: e.target.value }) },
720
+ HTTP_METHODS.map((method) => h('option', { key: method, value: method }, method))),
721
+ ),
722
+ h('div', { className: 'up-field' },
723
+ h('label', { className: 'up-field__label', title: '非 GET 请求的请求体,GET 留空' }, '请求体'),
724
+ h('input', { value: draft.bodyText, onChange: (e) => patch({ bodyText: e.target.value }) }),
725
+ ),
726
+ ) : null,
727
+ isCustom ? h('div', { className: 'up-field' },
728
+ h('label', { className: 'up-field__label', title: 'JSON 对象;鉴权头(如 Authorization)直接填真实 Key' }, '请求头'),
729
+ h('textarea', {
730
+ value: draft.headersText,
731
+ onChange: (e) => patch({ headersText: e.target.value }),
732
+ placeholder: '{"Authorization": "Bearer sk-xxx"}',
733
+ }),
734
+ ) : null,
735
+ isCustom ? h('div', { className: 'up-field' },
736
+ h('label', {
737
+ className: 'up-field__label',
738
+ title: 'JSON 对象;remaining 必填,取值支持点路径与 add/subtract/divide 运算',
739
+ }, '提取规则'),
740
+ h('textarea', {
741
+ value: draft.extractText,
742
+ onChange: (e) => patch({ extractText: e.target.value }),
743
+ placeholder: '{"remaining": {"op": "divide", "path": "data.total_available", "by": 500000}, "unit": "USD"}',
744
+ }),
745
+ h('button', {
746
+ className: 'up-btn',
747
+ title: '一键填入 NewApi 站点的典型余额接口配置,按实际站点修改',
748
+ onClick: () => patch({
749
+ url: draft.url.length > 0 ? draft.url : 'https://你的站点/api/usage/token',
750
+ method: 'GET',
751
+ headersText: JSON.stringify(NEWAPI_EXAMPLE_HEADERS, null, 2),
752
+ extractText: JSON.stringify(NEWAPI_EXAMPLE_EXTRACT, null, 2),
753
+ }),
754
+ }, '填入 NewApi 示例'),
755
+ ) : null,
756
+ h('details', { className: 'up-form up-form--nested' },
757
+ h('summary', { className: 'up-field__label', style: { cursor: 'pointer' }, title: '留空的字段继承全局通知配置' }, '通知规则覆盖'),
758
+ h('div', { className: 'up-grid' },
759
+ h('div', { className: 'up-field' },
760
+ h('label', { className: 'up-field__label', title: '该账号窗口用量达到此百分比时通知;留空继承全局' }, '用量阈值(%)'),
761
+ h('input', {
762
+ type: 'number', min: 1, max: 100,
763
+ value: draft.notifyQuota,
764
+ onChange: (e) => patch({ notifyQuota: e.target.value }),
765
+ placeholder: '留空继承全局',
766
+ })),
767
+ h('div', { className: 'up-field' },
768
+ h('label', { className: 'up-field__label', title: '该账号可用余额低于此值时通知;留空继承全局' }, '余额阈值'),
769
+ h('input', {
770
+ type: 'number', min: 0,
771
+ value: draft.notifyBalance,
772
+ onChange: (e) => patch({ notifyBalance: e.target.value }),
773
+ placeholder: '留空继承全局',
774
+ })),
775
+ ),
776
+ h('div', { className: 'up-field' },
777
+ h('label', { className: 'up-field__label', title: '覆盖全局的窗口重置通知开关' }, '窗口重置通知'),
778
+ h('select', { value: draft.notifyReset, onChange: (e) => patch({ notifyReset: e.target.value }) },
779
+ NOTIFY_TRISTATE.map((item) => h('option', { key: item.value, value: item.value }, item.label))),
780
+ ),
781
+ ),
782
+ error !== null ? h('div', { className: 'up-notice up-notice--error' }, error) : null,
783
+ h('div', { className: 'up-card__row' },
784
+ h('button', { className: 'up-btn up-btn--primary', onClick: submit }, draft.isNew ? '添加' : '保存'),
785
+ h('button', { className: 'up-btn', onClick: props.onCancel }, '取消'),
786
+ ),
787
+ )
788
+ }
789
+
790
+ // ---- 趋势视图(自绘 SVG,点位算法唯一实现在本文件;原 src/spark.mjs 参考副本已删除) ----
791
+
792
+ const SPARK_WIDTH = 220
793
+ const SPARK_HEIGHT = 60
794
+ const HOVER_WINDOW_POINTS = 24
795
+ const TREND_SEQUENCE_META = {
796
+ '5h': { label: '5 小时滚动', short: true },
797
+ '7d': { label: '7 天', short: false },
798
+ month: { label: '月', short: false },
799
+ balance: { label: '余额', short: false },
800
+ }
801
+
802
+ function computeDiffSeries(points) {
803
+ const series = []
804
+ for (let i = 1; i < points.length; i++) {
805
+ series.push({ t: points[i].t, v: points[i].v - points[i - 1].v })
806
+ }
807
+ return series
808
+ }
809
+
810
+ function extentOf(values) {
811
+ let min = Infinity
812
+ let max = -Infinity
813
+ for (const v of values) {
814
+ if (v < min) min = v
815
+ if (v > max) max = v
816
+ }
817
+ return { min, max }
818
+ }
819
+
820
+ function computeSparkPoints(points, mode, width, height) {
821
+ const series = mode === 'diff' ? computeDiffSeries(points) : points
822
+ if (series.length < 2) return []
823
+ const half = height / 2
824
+ const stepX = width / (series.length - 1)
825
+ if (mode === 'diff') {
826
+ const posMax = extentOf(series.map((bar) => Math.max(bar.v, 0))).max
827
+ const negMin = extentOf(series.map((bar) => Math.min(bar.v, 0))).min
828
+ const spanUp = posMax > 0 ? posMax : 1
829
+ const spanDown = negMin < 0 ? -negMin : 1
830
+ return series.map((bar, index) => ({
831
+ x: index * stepX,
832
+ y: bar.v >= 0 ? half - (bar.v / spanUp) * half : half + (-bar.v / spanDown) * half,
833
+ v: bar.v,
834
+ t: bar.t,
835
+ }))
836
+ }
837
+ const { min, max } = extentOf(series.map((point) => point.v))
838
+ return series.map((point, index) => ({
839
+ x: index * stepX,
840
+ y: max === min ? half : height - ((point.v - min) / (max - min)) * height,
841
+ v: point.v,
842
+ t: point.t,
843
+ }))
844
+ }
845
+
846
+ function TrendChart(props) {
847
+ const [mode, setMode] = useState('abs')
848
+ const plotted = computeSparkPoints(props.points, mode, SPARK_WIDTH, SPARK_HEIGHT)
849
+ const polyline = plotted.map((p) => Math.round(p.x) + ',' + Math.round(p.y)).join(' ')
850
+ return h('div', { className: 'up-trend__chart' },
851
+ h('div', null,
852
+ h('span', { className: 'up-trend__title' }, props.label + ' '),
853
+ h('button', { className: 'up-btn', onClick: () => setMode(mode === 'abs' ? 'diff' : 'abs') },
854
+ mode === 'abs' ? '差值' : '绝对值'),
855
+ props.onDetail ? h('button', { className: 'up-btn', onClick: props.onDetail }, '详情') : null,
856
+ ),
857
+ h('svg', { width: SPARK_WIDTH, height: SPARK_HEIGHT, viewBox: '0 0 ' + SPARK_WIDTH + ' ' + SPARK_HEIGHT },
858
+ mode === 'abs'
859
+ ? h('polyline', { points: polyline, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 })
860
+ : plotted.map((p, index) => h('line', {
861
+ key: String(index), className: 'up-trend__point',
862
+ x1: p.x, x2: p.x, y1: SPARK_HEIGHT / 2, y2: p.y,
863
+ stroke: p.v >= 0 ? '#1a9e55' : '#d43a3a', strokeWidth: 2,
864
+ }, h('title', null, fmtTimeFull(p.t) + ' ' + (p.v >= 0 ? '+' : '') + p.v))),
865
+ mode === 'abs' ? plotted.map((p, index) => h('circle', {
866
+ key: String(index), className: 'up-trend__point', cx: p.x, cy: p.y, r: 2, fill: 'currentColor',
867
+ }, h('title', null, fmtTimeFull(p.t) + ' ' + p.v))) : null,
868
+ mode === 'diff' ? h('line', {
869
+ x1: 0, x2: SPARK_WIDTH, y1: SPARK_HEIGHT / 2, y2: SPARK_HEIGHT / 2,
870
+ stroke: 'currentColor', strokeOpacity: 0.3, strokeWidth: 1,
871
+ }) : null,
872
+ ),
873
+ )
874
+ }
875
+
876
+ function summaryOf(points) {
877
+ const values = points.map((p) => p.v)
878
+ const min = Math.min.apply(null, values)
879
+ const max = Math.max.apply(null, values)
880
+ const sum = values.reduce((acc, v) => acc + v, 0)
881
+ let totalChange = 0
882
+ for (let i = 1; i < points.length; i++) totalChange += points[i].v - points[i - 1].v
883
+ return { latest: values[values.length - 1], avg: sum / values.length, min, max, totalChange }
884
+ }
885
+
886
+ function DetailDialog(props) {
887
+ const ranges = props.shortWindow ? ['all', '7d'] : ['7d', '30d', 'all']
888
+ const [range, setRange] = useState(ranges[0])
889
+ const dayMs = 24 * 60 * 60 * 1000
890
+ const cutoff = range === '7d' ? Date.now() - 7 * dayMs : range === '30d' ? Date.now() - 30 * dayMs : -Infinity
891
+ const points = props.points.filter((p) => p.t >= cutoff)
892
+ const summary = points.length >= 2 ? summaryOf(points) : null
893
+ return h('div', { className: 'up-dialog-mask', onClick: props.onClose },
894
+ h('div', { className: 'up-dialog', onClick: (e) => e.stopPropagation() },
895
+ h('div', { className: 'up-card__row' },
896
+ h('span', { className: 'up-card__name' }, props.accountName + ' · ' + props.label),
897
+ h('span', { className: 'up-spacer' }),
898
+ ranges.map((r) => h('button', {
899
+ key: r, className: 'up-btn', disabled: r === range,
900
+ onClick: () => setRange(r),
901
+ }, r === 'all' ? '全部' : '近 ' + r)),
902
+ h('button', { className: 'up-btn', onClick: props.onClose }, '关闭'),
903
+ ),
904
+ h(TrendChart, { label: props.label, points, onDetail: null }),
905
+ summary !== null
906
+ ? h('div', { className: 'up-meta' },
907
+ '最新 ' + summary.latest + ' · 均值 ' + summary.avg.toFixed(2) +
908
+ ' · 最低 ' + summary.min + ' · 最高 ' + summary.max +
909
+ ' · 总变化 ' + (summary.totalChange >= 0 ? '+' : '') + summary.totalChange.toFixed(2))
910
+ : h('div', { className: 'up-meta' }, '所选范围快照不足'),
911
+ h('table', null,
912
+ h('thead', null, h('tr', null, h('th', null, '采样时间'), h('th', null, '数值'))),
913
+ h('tbody', null, points.slice().reverse().map((p) =>
914
+ h('tr', { key: String(p.t) }, h('td', null, fmtTimeFull(p.t)), h('td', { className: 'up-num' }, String(p.v))))),
915
+ ),
916
+ ),
917
+ )
918
+ }
919
+
920
+ // 账号悬浮趋势弹层:短窗口与长窗口各自独立成图,余额账号单图;档点不足两条不伪造数据。
921
+ function TrendPopover(props) {
922
+ const { sequences } = props
923
+ const [detail, setDetail] = useState(null)
924
+ const charts = Object.keys(TREND_SEQUENCE_META)
925
+ .map((suffix) => ({ suffix, meta: TREND_SEQUENCE_META[suffix] }))
926
+ .filter((item) => sequences[item.suffix] && sequences[item.suffix].points)
927
+ .map((item) => {
928
+ const all = sequences[item.suffix].points
929
+ const points = all.slice(Math.max(0, all.length - HOVER_WINDOW_POINTS))
930
+ if (points.length < 2) return null
931
+ return h(TrendChart, {
932
+ key: item.suffix, label: item.meta.label, points,
933
+ onDetail: () => setDetail({ suffix: item.suffix }),
934
+ })
935
+ })
936
+ .filter(Boolean)
937
+ const detailSeq = detail ? sequences[detail.suffix] : null
938
+ return h('div', { className: 'up-trend' },
939
+ charts.length === 0 ? h('div', { className: 'up-meta' }, '暂无趋势数据') : charts,
940
+ detailSeq !== null
941
+ ? h(DetailDialog, {
942
+ accountName: props.accountName,
943
+ label: TREND_SEQUENCE_META[detail.suffix].label,
944
+ shortWindow: TREND_SEQUENCE_META[detail.suffix].short === true,
945
+ points: detailSeq.points,
946
+ onClose: () => setDetail(null),
947
+ })
948
+ : null,
949
+ )
950
+ }
951
+
952
+ // 布尔开关:原生 checkbox 保可访问性,视觉为 track 胶囊 + thumb 圆点(规约形态);
953
+ // className/children 供列表行形态(如 IM 目录勾选行)扩展,title 为悬浮详解,checkbox 本体不外泄
954
+ function Switch(props) {
955
+ return h('label', {
956
+ className: 'up-switch' + (props.className ? ' ' + props.className : ''),
957
+ title: props.title,
958
+ },
959
+ h('input', {
960
+ type: 'checkbox',
961
+ checked: props.checked === true,
962
+ disabled: props.disabled === true,
963
+ onChange: (e) => props.onChange(e.target.checked),
964
+ }),
965
+ h('span', { className: 'up-switch__track' }, h('span', { className: 'up-switch__thumb' })),
966
+ props.label ? h('span', { className: 'up-field__label' }, props.label) : null,
967
+ props.children || null,
968
+ )
969
+ }
970
+
971
+ const NOTIFY_TRISTATE = [
972
+ { value: '', label: '继承全局' },
973
+ { value: 'on', label: '开启' },
974
+ { value: 'off', label: '关闭' },
975
+ ]
976
+
977
+ // 通知配置卡:全局规则 + 三通道(webhook / dsh-im / 页内 toast)配置与测试。
978
+ function NotifyConfigCard() {
979
+ const [config, setConfig] = useState(null)
980
+ const [imAvailable, setImAvailable] = useState(false)
981
+ const [webhookUrl, setWebhookUrl] = useState('')
982
+ const [quotaPct, setQuotaPct] = useState('')
983
+ const [balanceThreshold, setBalanceThreshold] = useState('')
984
+ const [imBotIdDraft, setImBotIdDraft] = useState('')
985
+ const [imCatalog, setImCatalog] = useState(null)
986
+ const [imBusy, setImBusy] = useState(false)
987
+ const [error, setError] = useState(null)
988
+
989
+ useEffect(() => {
990
+ let alive = true
991
+ api('/api/usage-panel/notify-config')
992
+ .then((res) => {
993
+ if (!alive) return
994
+ setConfig(res && res.notify ? res.notify : {})
995
+ setImAvailable(res ? res.imAvailable === true : false)
996
+ })
997
+ .catch((err) => { if (alive) setError('读取通知配置失败:' + (err && err.message ? err.message : String(err))) })
998
+ return () => { alive = false }
999
+ }, [])
1000
+
1001
+ function apply(res) {
1002
+ if (res && res.notify) setConfig(res.notify)
1003
+ notify('通知配置已保存', 'ok')
1004
+ }
1005
+ function fail(err) {
1006
+ notify('保存失败:' + (err && err.message ? err.message : String(err)), 'error')
1007
+ }
1008
+ function patch(part) {
1009
+ api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify(part) })
1010
+ .then(apply)
1011
+ .catch(fail)
1012
+ }
1013
+ function saveThresholds() {
1014
+ const part = {}
1015
+ if (quotaPct.trim().length > 0) part.quotaThresholdPct = Number(quotaPct)
1016
+ if (balanceThreshold.trim().length > 0) part.balanceThreshold = Number(balanceThreshold)
1017
+ if (Object.keys(part).length === 0) {
1018
+ notify('无改动', 'ok')
1019
+ return
1020
+ }
1021
+ api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify(part) })
1022
+ .then((res) => { apply(res); setQuotaPct(''); setBalanceThreshold('') })
1023
+ .catch(fail)
1024
+ }
1025
+ function testWebhook() {
1026
+ api('/api/usage-panel/test-webhook', { method: 'POST', body: '{}' })
1027
+ .then((res) => notify(res && res.ok ? 'webhook 投递成功(' + res.detail + ')' : 'webhook 投递失败:' + (res ? res.detail : '无响应'), res && res.ok ? 'ok' : 'error'))
1028
+ .catch((err) => notify('测试失败:' + (err && err.message ? err.message : String(err)), 'error'))
1029
+ }
1030
+ function testIm() {
1031
+ api('/api/usage-panel/test-im', { method: 'POST', body: '{}' })
1032
+ .then((res) => {
1033
+ if (!res || !Array.isArray(res.results)) {
1034
+ notify('IM 测试失败:' + (res && res.detail ? res.detail : '无响应'), 'error')
1035
+ return
1036
+ }
1037
+ const failed = res.results.filter((item) => !item.ok)
1038
+ notify(failed.length === 0
1039
+ ? 'IM 通知已全部送达(' + res.results.length + ' 个目标)'
1040
+ : '部分失败:' + failed.map((item) => item.botId + '/' + item.targetId + ' ' + item.detail).join('; '),
1041
+ failed.length === 0 ? 'ok' : 'error')
1042
+ })
1043
+ .catch((err) => notify('测试失败:' + (err && err.message ? err.message : String(err)), 'error'))
1044
+ }
1045
+
1046
+ // IM 目录加载:目录来自 dsh-im 已保存目标;失败(离线/ID 复制错误)如实展示错误码
1047
+ async function loadImTargets(botIdOverride) {
1048
+ const botId = (typeof botIdOverride === 'string' ? botIdOverride : imBotIdDraft).trim()
1049
+ if (botId.length === 0) {
1050
+ notify('请先粘贴 Bot ID(dsh-im 设置页 IM机器人 卡片)', 'error')
1051
+ return
1052
+ }
1053
+ setImBusy(true)
1054
+ try {
1055
+ const res = await api('/api/usage-panel/im-targets?botId=' + encodeURIComponent(botId))
1056
+ const loaded = Array.isArray(res && res.targets) ? res.targets : []
1057
+ setImCatalog({ botId, targets: loaded })
1058
+ notify('已加载 ' + loaded.length + ' 个目标,勾选即保存', 'ok')
1059
+ } catch (err) {
1060
+ notify('加载失败:' + (err && err.message ? err.message : String(err)), 'error')
1061
+ } finally { setImBusy(false) }
1062
+ }
1063
+
1064
+ // 勾选即存:整体替换 imTargets,序列号防连续操作竞态,只 POST {imTargets} 不触碰其他配置;
1065
+ // 序列号驻 useRef 跨渲染保持,失败回填权威配置收敛 UI 与服务端(过期回填按序列号丢弃)
1066
+ const imPersistSeq = useRef(0)
1067
+ function persistImTargets(next, okMessage) {
1068
+ const seq = ++imPersistSeq.current
1069
+ setConfig((prev) => ({ ...prev, imTargets: next }))
1070
+ api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify({ imTargets: next }) })
1071
+ .then((res) => {
1072
+ if (seq !== imPersistSeq.current) return
1073
+ if (res && res.notify) setConfig(res.notify)
1074
+ if (okMessage !== undefined) notify(okMessage, 'ok')
1075
+ })
1076
+ .catch((err) => {
1077
+ notify('IM 目标保存失败:' + (err && err.message ? err.message : String(err)), 'error')
1078
+ api('/api/usage-panel/notify-config')
1079
+ .then((res) => { if (seq === imPersistSeq.current && res && res.notify) setConfig(res.notify) })
1080
+ .catch(() => {})
1081
+ })
1082
+ }
1083
+ function toggleImTarget(botId, target, checked) {
1084
+ persistImTargets(toggleImTargetList(config.imTargets || [], botId, target.targetId, checked))
1085
+ }
1086
+ function removeImTarget(item) {
1087
+ persistImTargets(removeImTargetFromList(config.imTargets || [], item.botId, item.targetId))
1088
+ }
1089
+ // 取消注册:移除该 bot 全部目标;bot 在 dsh-im 已删除时借此清理残留绑定
1090
+ function unregisterImBot(botId) {
1091
+ persistImTargets(unregisterImBotList(config.imTargets || [], botId), '已取消注册 ' + botId)
1092
+ }
1093
+
1094
+ if (config === null) {
1095
+ return h('div', { className: 'up-card' },
1096
+ h('div', { className: 'up-card__row' }, h('span', { className: 'up-head__title' }, '通知规则'), h('span', { className: 'up-meta' }, '加载中…')))
1097
+ }
1098
+ const targets = Array.isArray(config.imTargets) ? config.imTargets : []
1099
+ const boundBots = imBoundBotIds(targets)
1100
+ // 通知配置一次即久,默认折叠:summary 摘要行常显状态,展开才是完整配置
1101
+ return h('details', { className: 'up-card up-fold' },
1102
+ h('summary', { className: 'up-fold__summary' },
1103
+ h('span', { className: 'up-head__title' }, '通知规则'),
1104
+ h('span', { className: 'up-dot' + (config.enabled === true ? '' : ' up-dot--off') }),
1105
+ h('span', { className: 'up-meta' }, config.enabled === true ? '已启用' : '已关闭'),
1106
+ targets.length > 0 ? h('span', { className: 'up-meta' }, targets.length + ' 个 IM 目标') : null,
1107
+ config.webhookConfigured === true ? h('span', { className: 'up-meta' }, 'webhook 已配置') : null,
1108
+ ),
1109
+ h('div', { className: 'up-card__row' },
1110
+ h(Switch, {
1111
+ checked: config.enabled === true,
1112
+ label: '启用通知',
1113
+ title: '越过用量/余额阈值或窗口重置时推送;关闭后刷新仅更新读数,不产生任何推送',
1114
+ onChange: (checked) => patch({ enabled: checked }),
1115
+ }),
1116
+ ),
1117
+ // 阈值分区
1118
+ h('div', { className: 'up-section' },
1119
+ h('span', { className: 'up-section__title' }, '阈值'),
1120
+ h('div', { className: 'up-card__row' },
1121
+ h('span', { className: 'up-field__label', title: '任一窗口用量达到该百分比时通知' }, '用量阈值(%)'),
1122
+ h('span', { className: 'up-field' },
1123
+ h('input', {
1124
+ type: 'number', min: 1, max: 100, style: { width: '70px' },
1125
+ value: quotaPct !== '' ? quotaPct : '',
1126
+ onChange: (e) => setQuotaPct(e.target.value),
1127
+ placeholder: config.quotaThresholdPct === undefined ? '' : String(config.quotaThresholdPct),
1128
+ })),
1129
+ h('span', { className: 'up-field__label', title: '可用余额低于该值时通知;留空不启用' }, '余额阈值'),
1130
+ h('span', { className: 'up-field' },
1131
+ h('input', {
1132
+ type: 'number', min: 0, style: { width: '90px' },
1133
+ value: balanceThreshold !== '' ? balanceThreshold : '',
1134
+ onChange: (e) => setBalanceThreshold(e.target.value),
1135
+ placeholder: '如 20',
1136
+ })),
1137
+ h('button', { className: 'up-btn up-btn--primary', onClick: saveThresholds }, '保存阈值'),
1138
+ config.balanceThreshold === null || config.balanceThreshold === undefined
1139
+ ? null
1140
+ : h('button', { className: 'up-btn up-btn--danger', onClick: () => patch({ balanceThreshold: null }) }, '清除余额阈值'),
1141
+ ),
1142
+ h(Switch, {
1143
+ checked: config.resetNotice !== false,
1144
+ label: '窗口重置通知',
1145
+ title: '用量窗口轮转时,通知上一窗口的峰值用量',
1146
+ onChange: (checked) => patch({ resetNotice: checked }),
1147
+ }),
1148
+ ),
1149
+ // 通道分区
1150
+ h('div', { className: 'up-section' },
1151
+ h('span', { className: 'up-section__title' }, '推送通道'),
1152
+ h(Switch, {
1153
+ checked: config.toast !== false,
1154
+ label: '页内 toast',
1155
+ title: '浏览器页内弹窗提醒;toast 库未装载时此通道不可用',
1156
+ onChange: (checked) => patch({ toast: checked }),
1157
+ }),
1158
+ h('div', { className: 'up-card__row' },
1159
+ h('span', { className: 'up-field__label', title: 'host 直发的 Slack 兼容 JSON 通知;填新地址保存后自动发送测试' }, 'Webhook'),
1160
+ config.webhookConfigured === true ? h('span', { className: 'up-dot', title: '已配置' }) : null,
1161
+ h('span', { className: 'up-field' },
1162
+ h('input', {
1163
+ type: 'password', style: { width: '260px' },
1164
+ value: webhookUrl,
1165
+ onChange: (e) => setWebhookUrl(e.target.value),
1166
+ placeholder: config.webhookConfigured === true ? '已配置,留空保持不变' : 'https://hooks.example.com/…',
1167
+ })),
1168
+ h('button', {
1169
+ className: 'up-btn',
1170
+ onClick: () => {
1171
+ if (webhookUrl.trim().length === 0) {
1172
+ if (config.webhookConfigured !== true) { notify('请先填写 webhook URL', 'error'); return }
1173
+ testWebhook()
1174
+ return
1175
+ }
1176
+ const part = { webhookUrl: webhookUrl.trim() }
1177
+ api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify(part) })
1178
+ .then((res) => { apply(res); setWebhookUrl(''); testWebhook() })
1179
+ .catch(fail)
1180
+ },
1181
+ }, webhookUrl.trim().length > 0 ? '保存并测试' : '测试'),
1182
+ ),
1183
+ // IM 通道:目标来自 dsh-im 已保存目录,勾选即自动保存;新建与平台测试在 dsh-im 设置页完成
1184
+ imAvailable ? null : h('span', { className: 'up-meta' }, 'dsh-im 未安装,IM 通道不可用'),
1185
+ imAvailable ? h('div', { className: 'up-section' },
1186
+ h('div', { className: 'up-card__row' },
1187
+ h('span', { className: 'up-field__label', title: '经 dsh-im 推送到微信等渠道;目标在其设置页创建,此处勾选绑定' }, 'IM 投递'),
1188
+ h('span', { className: 'up-spacer' }),
1189
+ h('button', { className: 'up-btn', disabled: imBusy || targets.length === 0, onClick: testIm }, '测试 IM'),
1190
+ ),
1191
+ h('div', { className: 'up-card__row' },
1192
+ h('span', { className: 'up-field' },
1193
+ h('input', {
1194
+ value: imBotIdDraft,
1195
+ onChange: (e) => setImBotIdDraft(e.target.value),
1196
+ placeholder: '粘贴 Bot ID',
1197
+ title: '从 dsh-im 设置页「IM机器人」卡片复制 Bot ID,加载其已保存的投递目标目录',
1198
+ style: { width: '240px' },
1199
+ })),
1200
+ h('button', { className: 'up-btn', disabled: imBusy, title: '拉取该 bot 在 dsh-im 已保存的投递目标,勾选即保存', onClick: () => void loadImTargets() }, '加载目标'),
1201
+ ),
1202
+ boundBots.length > 0 ? h('div', { className: 'up-card__row' },
1203
+ h('span', { className: 'up-field__label' }, '已绑 bot'),
1204
+ boundBots.map((botId) => h('span', { className: 'up-chip', key: botId },
1205
+ h('button', {
1206
+ className: 'up-chip__name'
1207
+ + (imCatalog !== null && imCatalog.botId === botId ? ' up-chip__name--active' : ''),
1208
+ disabled: imBusy,
1209
+ onClick: () => { setImBotIdDraft(botId); void loadImTargets(botId) },
1210
+ }, botId),
1211
+ h('button', {
1212
+ className: 'up-chip__x', disabled: imBusy, title: '取消注册(移除该 bot 全部目标)',
1213
+ onClick: () => unregisterImBot(botId),
1214
+ }, '×'),
1215
+ )),
1216
+ ) : null,
1217
+ imCatalog !== null
1218
+ ? imCatalog.targets.length === 0
1219
+ ? h('span', { className: 'up-meta' }, '该 bot 尚无已保存投递目标,先在 dsh-im 设置页新建并测试')
1220
+ : h('div', { className: 'up-list' },
1221
+ imCatalog.targets.map((target) => {
1222
+ const checked = targets.some((item) => item.botId === imCatalog.botId && item.targetId === target.targetId)
1223
+ return h(Switch, {
1224
+ key: target.targetId,
1225
+ className: 'up-list__item',
1226
+ checked,
1227
+ onChange: (next) => toggleImTarget(imCatalog.botId, target, next),
1228
+ },
1229
+ h('span', { className: 'up-list__grow' },
1230
+ target.targetId + (target.name ? ' (' + target.name + ')' : '')),
1231
+ h('span', { className: 'up-list__tag' }, target.kind || ''),
1232
+ )
1233
+ }),
1234
+ )
1235
+ : null,
1236
+ targets.length === 0
1237
+ ? h('span', { className: 'up-meta' }, '尚未绑定投递目标,通知不会推送 IM')
1238
+ : h('div', { className: 'up-list' },
1239
+ targets.map((item) => h('div', { className: 'up-list__item', key: imTargetKey(item) },
1240
+ h('span', { className: 'up-list__grow' }, item.targetId),
1241
+ h('span', { className: 'up-list__tag' }, item.botId),
1242
+ h('button', {
1243
+ className: 'up-btn', disabled: imBusy, onClick: () => removeImTarget(item),
1244
+ }, '移除'),
1245
+ )),
1246
+ ),
1247
+ ) : null,
1248
+ ),
1249
+ error !== null ? h('div', { className: 'up-notice up-notice--error' }, error) : null,
1250
+ )
1251
+ }
1252
+
1253
+ function UsagePanelApp() {
1254
+ const [accounts, setAccounts] = useState(null)
1255
+ const [editing, setEditing] = useState(null)
1256
+ const [busy, setBusy] = useState({})
1257
+ // 加载失败占位:与「读取失败/加载中」占位文案联动;操作反馈不经此状态
1258
+ const [notice, setNotice] = useState(null)
1259
+ const [armed, setArmed] = useState(null)
1260
+ const [sequences, setSequences] = useState({})
1261
+ const [pollArmed, setPollArmed] = useState(null)
1262
+
1263
+ useEffect(() => {
1264
+ let alive = true
1265
+ api('/api/usage-panel/accounts')
1266
+ .then((res) => {
1267
+ if (!alive) return null
1268
+ const list = res && res.accounts ? res.accounts : []
1269
+ setAccounts(list)
1270
+ // 即时性兜底:无缓存读数的账号触发一次自动查询(受 host 退避约束)
1271
+ list.filter((item) => !item.last).forEach((item) => {
1272
+ api('/api/usage-panel/query', { method: 'POST', body: JSON.stringify({ id: item.id, auto: true }) })
1273
+ .then((r) => { if (r && r.account) replaceAccount(r.account) })
1274
+ .catch(() => {})
1275
+ })
1276
+ return null
1277
+ })
1278
+ .catch((error) => {
1279
+ if (alive) setNotice('读取配置失败:' + (error && error.message ? error.message : String(error)))
1280
+ })
1281
+ api('/api/usage-panel/history')
1282
+ .then((res) => { if (alive) setSequences(res && res.sequences ? res.sequences : {}) })
1283
+ .catch(() => {})
1284
+ api('/api/usage-panel/settings')
1285
+ .then((res) => {
1286
+ if (!alive) return
1287
+ setPollArmed(res ? Boolean(res.pollArmed) : null)
1288
+ })
1289
+ .catch(() => {})
1290
+ return () => { alive = false }
1291
+ }, [])
1292
+
1293
+ function refreshSequences() {
1294
+ return api('/api/usage-panel/history')
1295
+ .then((res) => { setSequences(res && res.sequences ? res.sequences : {}) })
1296
+ }
1297
+
1298
+ function markBusy(id, value) {
1299
+ setBusy((prev) => Object.assign({}, prev, { [id]: value }))
1300
+ }
1301
+
1302
+ function replaceAccount(nextAccount) {
1303
+ setAccounts((prev) => prev.map((item) => (item.id === nextAccount.id ? nextAccount : item)))
1304
+ }
1305
+
1306
+ function refreshOne(id) {
1307
+ markBusy(id, true)
1308
+ return api('/api/usage-panel/query', { method: 'POST', body: JSON.stringify({ id }) })
1309
+ .then((res) => {
1310
+ if (res && res.account) replaceAccount(res.account)
1311
+ return api('/api/usage-panel/history')
1312
+ })
1313
+ .then((res) => { setSequences(res && res.sequences ? res.sequences : {}) })
1314
+ .catch((error) => {
1315
+ notify('查询失败:' + (error && error.message ? error.message : String(error)), 'error')
1316
+ })
1317
+ .then(() => markBusy(id, false))
1318
+ }
1319
+
1320
+ function refreshAll() {
1321
+ if (!accounts) return
1322
+ const ids = accounts.map((item) => item.id)
1323
+ ids.forEach((id) => markBusy(id, true))
1324
+ // 批量查询后单次拉取 history,不做每账号一次 history GET
1325
+ Promise.all(ids.map((id) =>
1326
+ api('/api/usage-panel/query', { method: 'POST', body: JSON.stringify({ id }) })
1327
+ .then((res) => { if (res && res.account) replaceAccount(res.account) })
1328
+ .catch((error) => {
1329
+ notify('查询失败:' + (error && error.message ? error.message : String(error)), 'error')
1330
+ })
1331
+ ))
1332
+ .then(refreshSequences)
1333
+ .catch(() => {})
1334
+ .then(() => ids.forEach((id) => markBusy(id, false)))
1335
+ }
1336
+
1337
+ function saveAccounts(nextAccounts) {
1338
+ return api('/api/usage-panel/accounts', { method: 'POST', body: JSON.stringify({ accounts: nextAccounts }) })
1339
+ .then((res) => {
1340
+ setAccounts(res && res.accounts ? res.accounts : nextAccounts)
1341
+ setEditing(null)
1342
+ notify('配置已保存', 'ok')
1343
+ })
1344
+ .catch((error) => {
1345
+ notify('保存失败:' + (error && error.message ? error.message : String(error)), 'error')
1346
+ })
1347
+ }
1348
+
1349
+ function onDelete(account) {
1350
+ if (armed !== account.id) {
1351
+ setArmed(account.id)
1352
+ return
1353
+ }
1354
+ setArmed(null)
1355
+ saveAccounts(accounts.filter((item) => item.id !== account.id))
1356
+ }
1357
+
1358
+ if (accounts === null) {
1359
+ return h('div', { className: 'up-panel' },
1360
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
1361
+ h('span', { className: 'up-meta' }, notice !== null ? '读取失败' : '加载中…'),
1362
+ notice !== null ? h('div', { className: 'up-notice up-notice--error' }, notice) : null)
1363
+ }
1364
+
1365
+ const cards = accounts.map((account) => {
1366
+ const prefix = account.id + ':'
1367
+ const own = {}
1368
+ for (const key of Object.keys(sequences)) {
1369
+ if (key.indexOf(prefix) === 0) own[key.slice(prefix.length)] = sequences[key]
1370
+ }
1371
+ return h(AccountCard, {
1372
+ key: account.id,
1373
+ account,
1374
+ sequences: own,
1375
+ busy: busy[account.id] === true,
1376
+ deleteArmed: armed === account.id,
1377
+ onRefresh: () => refreshOne(account.id),
1378
+ onEdit: () => setEditing(account),
1379
+ onDelete: () => onDelete(account),
1380
+ })
1381
+ })
1382
+
1383
+ const anyBusy = Object.keys(busy).some((id) => busy[id] === true)
1384
+
1385
+ return h('div', { className: 'up-panel' },
1386
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
1387
+ h('div', { className: 'up-head' },
1388
+ h('span', { className: 'up-head__title' }, '账号详情'),
1389
+ h('span', { className: 'up-spacer' }),
1390
+ h('button', { className: 'up-btn', title: '手动查询全部账号并更新读数', disabled: anyBusy || accounts.length === 0, onClick: refreshAll }, '全部刷新'),
1391
+ h('button', { className: 'up-btn up-btn--primary', onClick: () => setEditing({ isNew: true, id: null }) }, '添加账号'),
1392
+ ),
1393
+ pollArmed === false ? h('div', { className: 'up-notice up-notice--error' },
1394
+ '自动轮询未运行(宿主定时服务不可用);手动查询不受影响。') : null,
1395
+ h(NotifyConfigCard),
1396
+ editing !== null
1397
+ ? h(AccountForm, {
1398
+ key: editing.id || 'new',
1399
+ initial: editing.isNew ? null : editing,
1400
+ onCancel: () => setEditing(null),
1401
+ onSave: (built) => {
1402
+ saveAccounts(accounts.filter((item) => item.id !== built.id).concat([built]))
1403
+ },
1404
+ })
1405
+ : null,
1406
+ cards.length === 0 && editing === null
1407
+ ? h('div', { className: 'up-meta' }, '还没有账号,点击「添加账号」手动填入 API 地址与 Key。')
1408
+ : null,
1409
+ cards,
1410
+ )
1411
+ }
1412
+
1413
+ return {
1414
+ inject: ['slots'],
1415
+ apply(ctx) {
1416
+ ctx.slots.inject('settings.section', () =>
1417
+ ctx.slots.register(
1418
+ { name: 'settings.section', id: 'usage-panel', order: 40, label: '账号余额' },
1419
+ () => React.createElement(UsagePanelApp),
1420
+ ))
1421
+ },
1422
+ }
1423
+ },
1424
+ })