dsh-consumption-badge 1.1.0 → 1.2.2
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 +110 -11
- package/lib/client.js +2079 -40
- package/lib/index.js +48 -15
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -107,12 +107,263 @@ window.__ModuleLoader__.load({
|
|
|
107
107
|
|
|
108
108
|
// 同源取数:Host 半边的只读路由,失败一律转成 store 里的错误文案。
|
|
109
109
|
async function fetchJson(path) {
|
|
110
|
-
if (typeof fetch !== 'function') throw new Error('fetch 不可用')
|
|
110
|
+
if (typeof fetch !== 'function') throw new Error(tt('fetch 不可用'))
|
|
111
111
|
const res = await fetch(path, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
112
112
|
if (!res.ok) throw new Error('HTTP ' + String(res.status))
|
|
113
113
|
return await res.json()
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/* ============ i18n: follow the shell locale (zh / en) ============ */
|
|
117
|
+
// Design: the Chinese copy IS the dictionary key. Under `zh` no dictionary is
|
|
118
|
+
// needed (the key is the text); under `en` DICT_EN supplies the translation.
|
|
119
|
+
// Anything missing from DICT_EN falls back to the key, so a gap can only show
|
|
120
|
+
// Chinese — never an empty string.
|
|
121
|
+
//
|
|
122
|
+
// No manual re-render plumbing for slot-mounted UI: dsh-client-web-react's
|
|
123
|
+
// SlotOutlet subscribes to the locale face revision (useLocaleRevision), so
|
|
124
|
+
// every outlet re-renders on a language switch and these module-level calls
|
|
125
|
+
// pick up the new locale. Only the personalization page's own React root
|
|
126
|
+
// (rendered outside the slot system) subscribes explicitly.
|
|
127
|
+
const LOCALE_NS = 'consumption-badge'
|
|
128
|
+
const DICT_EN = {
|
|
129
|
+
/* badge + panel */
|
|
130
|
+
'本会话消耗(点击查看逐轮明细)': 'Session usage (click for per-turn details)',
|
|
131
|
+
'本会话消耗': 'Session usage',
|
|
132
|
+
'⛽ 本会话消耗': '⛽ Session usage',
|
|
133
|
+
'刷新余额': 'Refresh balance',
|
|
134
|
+
'Esc 关闭': 'Esc to close',
|
|
135
|
+
'累计 Token': 'Total tokens',
|
|
136
|
+
'估算金额': 'Estimated cost',
|
|
137
|
+
'当前时段': 'Current window',
|
|
138
|
+
'账户余额': 'Account balance',
|
|
139
|
+
'逐轮消耗趋势': 'Per-turn usage trend',
|
|
140
|
+
'命中 {hit} · 未命中 {miss} · 输出 {out}': 'hit {hit} · miss {miss} · output {out}',
|
|
141
|
+
'{id}(按 flash 估算)': '{id} (priced as flash)',
|
|
142
|
+
' · 峰时 ×2': ' · peak ×2',
|
|
143
|
+
' · 谷时': ' · off-peak ×1',
|
|
144
|
+
'高峰': 'Peak',
|
|
145
|
+
'空闲': 'Off-peak',
|
|
146
|
+
'峰': 'Peak',
|
|
147
|
+
'谷': 'Off-peak',
|
|
148
|
+
'峰时': 'Peak',
|
|
149
|
+
'谷时': 'Off-peak',
|
|
150
|
+
'北京时间 工作日 09:00-12:00 / 14:00-18:00': 'Beijing time, weekdays 09:00-12:00 / 14:00-18:00',
|
|
151
|
+
'金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。': 'Costs are estimated from DeepSeek list prices (off-peak/peak ×1/×2) and are for reference only; the source is this session log, restored automatically after a refresh.',
|
|
152
|
+
/* chart tooltips */
|
|
153
|
+
'本会话还没有可统计的轮次': 'No turns to chart in this session yet',
|
|
154
|
+
'输入命中': 'Cache hit',
|
|
155
|
+
'输入未命中': 'Cache miss',
|
|
156
|
+
'输出': 'Output',
|
|
157
|
+
'本轮合计': 'Turn total',
|
|
158
|
+
'每轮 Token': 'Tokens per turn',
|
|
159
|
+
'每轮估算金额': 'Cost per turn',
|
|
160
|
+
'{n} 轮': '{n} turns',
|
|
161
|
+
'当日合计': 'Total',
|
|
162
|
+
'模型': 'Model',
|
|
163
|
+
/* balance + error copy */
|
|
164
|
+
'打开面板时请求一次': 'Fetched once per panel open',
|
|
165
|
+
'打开设置页时请求一次': 'Fetched once per settings open',
|
|
166
|
+
'读取中…': 'Loading…',
|
|
167
|
+
'获取失败': 'Failed',
|
|
168
|
+
'余额接口不可用': 'Balance API unavailable',
|
|
169
|
+
'余额不足': 'Insufficient balance',
|
|
170
|
+
'请前往 DeepSeek 开放平台充值': 'Top up at the DeepSeek open platform',
|
|
171
|
+
'赠送 ¥{v}': 'granted ¥{v}',
|
|
172
|
+
'充值 ¥{v}': 'topped up ¥{v}',
|
|
173
|
+
'DeepSeek 账户余额': 'DeepSeek account balance',
|
|
174
|
+
'空响应': 'Empty response',
|
|
175
|
+
'余额获取失败': 'Balance request failed',
|
|
176
|
+
'余额获取失败:{msg}': 'Balance request failed: {msg}',
|
|
177
|
+
'会话消耗读取失败:{msg}': 'Failed to read session usage: {msg}',
|
|
178
|
+
'fetch 不可用': 'fetch is not available',
|
|
179
|
+
/* host error codes (lib/index.js) → zh copy; DICT_EN supplies the English */
|
|
180
|
+
'credentials 服务不可用': 'The credentials service is unavailable.',
|
|
181
|
+
'读取 API Key 失败:{detail}': 'Failed to read the API key: {detail}',
|
|
182
|
+
'未配置 {detail}': 'No API key configured ({detail}).',
|
|
183
|
+
'subprocess 服务不可用': 'The subprocess service is unavailable.',
|
|
184
|
+
'curl 没有输出:{detail}': 'curl produced no output: {detail}',
|
|
185
|
+
'curl 启动失败:{detail}': 'curl could not be started: {detail}',
|
|
186
|
+
'响应解析失败:{detail}': 'Could not parse the response: {detail}',
|
|
187
|
+
'余额接口请求失败': 'The balance request failed.',
|
|
188
|
+
'缺少 sessionId': 'Missing sessionId.',
|
|
189
|
+
'会话日志不可用': 'The session log is unavailable.',
|
|
190
|
+
/* settings page */
|
|
191
|
+
'消耗统计': 'Usage statistics',
|
|
192
|
+
'本地估算,非官方账单': 'Local estimate, not an official bill',
|
|
193
|
+
'今天': 'Today',
|
|
194
|
+
'近一周': 'Last 7 days',
|
|
195
|
+
'近一月': 'Last 30 days',
|
|
196
|
+
'近一年': 'Last 12 months',
|
|
197
|
+
'全部历史 · 含所有工作区': 'All history · every workspace',
|
|
198
|
+
' · 全部历史逐轮计价': ' · per-turn pricing over all history',
|
|
199
|
+
'时间范围': 'Time range',
|
|
200
|
+
'自定义区间': 'Custom range',
|
|
201
|
+
'工作区': 'Workspace',
|
|
202
|
+
'全部工作区': 'All workspaces',
|
|
203
|
+
'未归属工作区': 'Unassigned',
|
|
204
|
+
'日历选择(覆盖时间范围)': 'Calendar (overrides the range)',
|
|
205
|
+
'清除': 'Clear',
|
|
206
|
+
'数据': 'Data',
|
|
207
|
+
'刷新': 'Refresh',
|
|
208
|
+
'估算金额(右轴)': 'Estimated cost (right axis)',
|
|
209
|
+
'Token(左轴,默认隐藏)': 'Tokens (left axis, hidden by default)',
|
|
210
|
+
'该范围内暂无消耗记录': 'No usage recorded in this range',
|
|
211
|
+
'总消耗 ': 'Total cost ',
|
|
212
|
+
'总 Token ': 'Total tokens ',
|
|
213
|
+
'记录 ': 'Records ',
|
|
214
|
+
' 条': '',
|
|
215
|
+
'峰时 ': 'Peak ',
|
|
216
|
+
'谷时 ': 'Off-peak ',
|
|
217
|
+
'粒度 ': 'Bucket ',
|
|
218
|
+
'按月': 'monthly',
|
|
219
|
+
'按日': 'daily',
|
|
220
|
+
'按小时': 'hourly',
|
|
221
|
+
'数据源:本机逐轮记录(localStorage {prefix}<sessionId>:<turn>);按北京时间聚合(今天与单日区间逐小时,多日逐日,近一年逐月){worker}。金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考。': 'Source: local per-turn records (localStorage {prefix}<sessionId>:<turn>), aggregated in Beijing time (hourly for today and single-day ranges, daily for multi-day ranges, monthly for the last 12 months){worker}. Costs are estimated from DeepSeek list prices (off-peak/peak ×1/×2) and are for reference only.',
|
|
222
|
+
';聚合已在 Web Worker 中完成': '; aggregation ran in a Web Worker',
|
|
223
|
+
/* personalization page block */
|
|
224
|
+
'消耗弹窗': 'Usage popup',
|
|
225
|
+
'背景': 'Background',
|
|
226
|
+
'点击对话里的 ⛽ 胶囊弹出的消耗面板': 'The usage panel opened by the ⛽ pill in the chat',
|
|
227
|
+
'消耗弹窗背景': 'Usage popup background',
|
|
228
|
+
'默认': 'Default',
|
|
229
|
+
'毛玻璃': 'Glass',
|
|
230
|
+
'自定义背景': 'Custom image',
|
|
231
|
+
'纯色': 'Solid color',
|
|
232
|
+
'弹窗底色;文字颜色会按明暗自动切换': 'Popup base color; text color adapts automatically to lightness',
|
|
233
|
+
'正在保存…': 'Saving…',
|
|
234
|
+
'已保存到本机': 'Saved on this device',
|
|
235
|
+
'取回失败,请重新选择': 'Could not read it back — please pick again',
|
|
236
|
+
'当前:本地图片({status})': 'Using: local image ({status})',
|
|
237
|
+
'当前:本地图片(已保存到本机)': 'Using: local image (saved on this device)',
|
|
238
|
+
'当前:图片 URL': 'Using: image URL',
|
|
239
|
+
'未设置图片(此时按默认背景显示)': 'No image set (falls back to the default background)',
|
|
240
|
+
'图片': 'Image',
|
|
241
|
+
';可选 URL 或本地图片,不限大小': '; a URL or a local image, any size',
|
|
242
|
+
'更换图片…': 'Change image…',
|
|
243
|
+
'选择图片…': 'Choose image…',
|
|
244
|
+
'已使用本地图片,输入 URL 可替换': 'Local image in use — enter a URL to replace it',
|
|
245
|
+
'不透明度': 'Opacity',
|
|
246
|
+
'背景图片自身的透明度(0% 完全不可见,100% 完整显示)': 'Opacity of the background image itself (0% invisible, 100% fully visible)',
|
|
247
|
+
'把背景图片磨砂化(对图片做真模糊)': 'Frost the background image (a real blur on the image)',
|
|
248
|
+
'底色半透明并模糊背后的页面': 'Translucent base that blurs the page behind it',
|
|
249
|
+
'毛玻璃强度': 'Glass strength',
|
|
250
|
+
'模糊半径(0–48px)': 'Blur radius (0–48px)',
|
|
251
|
+
'消耗徽章弹层的背景样式;不影响对话区与侧边栏背景。{tag}': 'Background style of the badge popup; it does not affect the chat or sidebar background. {tag}',
|
|
252
|
+
'重置本栏': 'Reset this section',
|
|
253
|
+
'消耗弹窗设置需要 react-dom,当前环境不可用。': 'Popup settings need react-dom, which is not available here.',
|
|
254
|
+
'error: IndexedDB 与 FileReader 都不可用': 'error: IndexedDB and FileReader are both unavailable',
|
|
255
|
+
'IndexedDB 与 FileReader 都不可用': 'IndexedDB and FileReader are both unavailable',
|
|
256
|
+
/* update notice */
|
|
257
|
+
'消耗徽章有新版本': 'A new version of the usage badge is available',
|
|
258
|
+
'当前 v{current} → 最新 v{latest}': 'current v{current} → latest v{latest}',
|
|
259
|
+
'复制更新命令': 'Copy update command',
|
|
260
|
+
'已复制': 'Copied',
|
|
261
|
+
'忽略此版本': 'Ignore this version',
|
|
262
|
+
'稍后提醒': 'Remind me later',
|
|
263
|
+
'更新后需重启 dsh 进程': 'Restart the dsh process after updating',
|
|
264
|
+
'版本来自 npm registry(24h 缓存);「忽略此版本」会记住,直到出现更高版本。': 'Versions come from the npm registry (24h cache); “Ignore this version” is remembered until a higher version appears.',
|
|
265
|
+
/* slot labels */
|
|
266
|
+
'消耗': 'Usage',
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let boundTranslate = null
|
|
270
|
+
let localeService = null
|
|
271
|
+
let appCtx = null
|
|
272
|
+
|
|
273
|
+
/** Fill `{name}` placeholders (same template syntax as the locale service). */
|
|
274
|
+
function applyParams(template, params) {
|
|
275
|
+
if (!params) return String(template)
|
|
276
|
+
return String(template).replace(/\{(\w+)\}/g, function (all, name) {
|
|
277
|
+
return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : all
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Bind the shell's locale service when the composition provides one.
|
|
283
|
+
* Optional dependency on purpose: without the service every string falls
|
|
284
|
+
* back to its Chinese key, so the plugin still works standalone.
|
|
285
|
+
* @param ctx client cordis context.
|
|
286
|
+
*/
|
|
287
|
+
function attachLocale(ctx) {
|
|
288
|
+
if (localeService !== null) return
|
|
289
|
+
let service = null
|
|
290
|
+
try {
|
|
291
|
+
// ctx.get(name, strict = true) throws when the service is missing: this is an
|
|
292
|
+
// optional dependency, so read it non-strictly. The try/catch also covers
|
|
293
|
+
// contexts without a get() (older shells, test doubles).
|
|
294
|
+
if (ctx && typeof ctx.get === 'function') service = ctx.get('locale', false) || null
|
|
295
|
+
} catch (e) {
|
|
296
|
+
service = null
|
|
297
|
+
}
|
|
298
|
+
if (service === null || typeof service.register !== 'function' || typeof service.bind !== 'function') return
|
|
299
|
+
try {
|
|
300
|
+
// Only the `en` side is registered: the key already is the `zh` copy, and
|
|
301
|
+
// the locale service resolves a miss to the key itself.
|
|
302
|
+
service.register(LOCALE_NS, 'en', DICT_EN)
|
|
303
|
+
} catch (e) {
|
|
304
|
+
// Already registered (hot re-mount of the same fiber) — binding is enough.
|
|
305
|
+
}
|
|
306
|
+
boundTranslate = service.bind(LOCALE_NS)
|
|
307
|
+
localeService = service
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Late-binding guard: the locale service may be provided after this plugin's
|
|
312
|
+
* apply() ran (boot order differs between compositions), so the first render
|
|
313
|
+
* retries the bind. Costs one ctx.get per tt() call while unbound.
|
|
314
|
+
*/
|
|
315
|
+
function ensureLocaleBound() {
|
|
316
|
+
if (localeService !== null || appCtx === null) return
|
|
317
|
+
attachLocale(appCtx)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Translate a key with the active locale; falls back to the key itself. */
|
|
321
|
+
function tt(key, params) {
|
|
322
|
+
if (localeService === null) ensureLocaleBound()
|
|
323
|
+
if (boundTranslate !== null) {
|
|
324
|
+
try {
|
|
325
|
+
return boundTranslate(key, params)
|
|
326
|
+
} catch (e) { /* fall through to the key */ }
|
|
327
|
+
}
|
|
328
|
+
return applyParams(key, params)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Subscribe to locale switches (only the self-built React root needs this). */
|
|
332
|
+
function subscribeLocale(fn) {
|
|
333
|
+
const service = localeService
|
|
334
|
+
if (service === null || typeof service.subscribe !== 'function') return function () {}
|
|
335
|
+
try {
|
|
336
|
+
const off = service.subscribe(fn)
|
|
337
|
+
return typeof off === 'function' ? off : function () {}
|
|
338
|
+
} catch (e) {
|
|
339
|
+
return function () {}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* Host error codes (lib/index.js) → the Chinese key tt() translates. */
|
|
344
|
+
const HOST_ERROR_KEYS = {
|
|
345
|
+
'no-credentials': 'credentials 服务不可用',
|
|
346
|
+
'key-read-failed': '读取 API Key 失败:{detail}',
|
|
347
|
+
'no-key': '未配置 {detail}',
|
|
348
|
+
'no-subprocess': 'subprocess 服务不可用',
|
|
349
|
+
'curl-empty': 'curl 没有输出:{detail}',
|
|
350
|
+
'spawn-failed': 'curl 启动失败:{detail}',
|
|
351
|
+
'bad-response': '响应解析失败:{detail}',
|
|
352
|
+
'balance-request-failed': '余额接口请求失败',
|
|
353
|
+
'missing-session-id': '缺少 sessionId',
|
|
354
|
+
'log-unavailable': '会话日志不可用',
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Render a host error payload in the active locale. Unknown codes fall back
|
|
359
|
+
* to the host's own (English) message so nothing is ever swallowed.
|
|
360
|
+
*/
|
|
361
|
+
function hostErrorText(code, detail, fallback) {
|
|
362
|
+
const key = typeof code === 'string' ? HOST_ERROR_KEYS[code] : undefined
|
|
363
|
+
if (key === undefined) return typeof fallback === 'string' ? fallback : ''
|
|
364
|
+
return tt(key, { detail: typeof detail === 'string' ? detail : '' })
|
|
365
|
+
}
|
|
366
|
+
|
|
116
367
|
/* local persistence: one record per <sessionId>:<turn> */
|
|
117
368
|
function lsGet(key) {
|
|
118
369
|
try {
|
|
@@ -201,6 +452,376 @@ window.__ModuleLoader__.load({
|
|
|
201
452
|
return merged
|
|
202
453
|
}
|
|
203
454
|
|
|
455
|
+
/* ================= 消耗弹窗背景样式(与个性化插件联动) ================= */
|
|
456
|
+
const MODAL_STYLE_KEY = 'dsh-consumption:modal-style'
|
|
457
|
+
const BUILD_TAG = 'dcb-1.2.2'
|
|
458
|
+
// 本地版本:本包是零构建的手写客户端包,无法 import package.json,因此把版本号作为常量维护,
|
|
459
|
+
// 并由 check.mjs 守卫与 package.json 严格一致(发布前不一致直接失败)。
|
|
460
|
+
const PLUGIN_VERSION = '1.2.2'
|
|
461
|
+
const PKG_NAME = 'dsh-consumption-badge'
|
|
462
|
+
const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/' + PKG_NAME + '/latest'
|
|
463
|
+
const UPDATE_COMMAND = 'dsh plugin --profile web update ' + PKG_NAME
|
|
464
|
+
// 与 dsh-plugin-personalization 同口径:registry 结果缓存 24h;
|
|
465
|
+
// 「忽略此版本」持久化;「稍后提醒」只关闭本次会话(不写忽略键,下次启动仍会检测)。
|
|
466
|
+
const LS_UPDATE_CHECK = 'dsh-consumption:update-check'
|
|
467
|
+
const LS_IGNORED_UPDATE = 'dsh-consumption:ignored-update-version'
|
|
468
|
+
const UPDATE_CHECK_TTL = 24 * 60 * 60 * 1000
|
|
469
|
+
const UPDATE_FETCH_TIMEOUT = 5000
|
|
470
|
+
// opacity:背景图片自身的不透明度(0–100,与个性化插件其它背景栏同口径);
|
|
471
|
+
// 旧版的 scrim(压暗遮罩)在读取时折算成 100 - scrim,老配置不会丢。
|
|
472
|
+
const DEFAULT_MODAL_STYLE = { mode: 'default', color: '#17181c', image: '', imageKey: '', opacity: 85, glass: false, blur: 22 }
|
|
473
|
+
// label 是词典 key(中文原文),渲染时统一走 tt(mode.label),所以这里不能直接显示。
|
|
474
|
+
const MODAL_MODES = [
|
|
475
|
+
{ value: 'default', label: '默认' },
|
|
476
|
+
{ value: 'glass', label: '毛玻璃' },
|
|
477
|
+
{ value: 'image', label: '自定义背景' },
|
|
478
|
+
{ value: 'solid', label: '纯色' },
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
/* ---- 弹窗背景图片的本地存储:IndexedDB 存 Blob,localStorage 只留一个 key ---- */
|
|
482
|
+
const IMG_DB_NAME = 'dsh-consumption-assets'
|
|
483
|
+
const IMG_STORE = 'images'
|
|
484
|
+
let imgDbPromise = null
|
|
485
|
+
|
|
486
|
+
/** 打开(或首次创建)图片库;环境不支持 IndexedDB 时返回 null。 */
|
|
487
|
+
function imageDb() {
|
|
488
|
+
if (typeof indexedDB === 'undefined' || indexedDB === null) return Promise.resolve(null)
|
|
489
|
+
if (imgDbPromise !== null) return imgDbPromise
|
|
490
|
+
imgDbPromise = new Promise(function (resolve) {
|
|
491
|
+
let req = null
|
|
492
|
+
try {
|
|
493
|
+
req = indexedDB.open(IMG_DB_NAME, 1)
|
|
494
|
+
} catch (e) {
|
|
495
|
+
resolve(null)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
req.onupgradeneeded = function () {
|
|
499
|
+
try {
|
|
500
|
+
req.result.createObjectStore(IMG_STORE)
|
|
501
|
+
} catch (e) { /* 已存在 */ }
|
|
502
|
+
}
|
|
503
|
+
req.onsuccess = function () { resolve(req.result) }
|
|
504
|
+
req.onerror = function () { resolve(null) }
|
|
505
|
+
req.onblocked = function () { resolve(null) }
|
|
506
|
+
})
|
|
507
|
+
return imgDbPromise
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** 写入一张图片(File/Blob 直接入库存,不做 base64,因此没有大小上限)。 */
|
|
511
|
+
function idbPutImage(key, blob) {
|
|
512
|
+
return imageDb().then(function (db) {
|
|
513
|
+
if (db === null) return false
|
|
514
|
+
return new Promise(function (resolve) {
|
|
515
|
+
try {
|
|
516
|
+
const tx = db.transaction(IMG_STORE, 'readwrite')
|
|
517
|
+
tx.objectStore(IMG_STORE).put(blob, key)
|
|
518
|
+
tx.oncomplete = function () { resolve(true) }
|
|
519
|
+
tx.onerror = function () { resolve(false) }
|
|
520
|
+
tx.onabort = function () { resolve(false) }
|
|
521
|
+
} catch (e) {
|
|
522
|
+
resolve(false)
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 读回一张图片。 */
|
|
529
|
+
function idbGetImage(key) {
|
|
530
|
+
return imageDb().then(function (db) {
|
|
531
|
+
if (db === null || !key) return null
|
|
532
|
+
return new Promise(function (resolve) {
|
|
533
|
+
try {
|
|
534
|
+
const tx = db.transaction(IMG_STORE, 'readonly')
|
|
535
|
+
const req = tx.objectStore(IMG_STORE).get(key)
|
|
536
|
+
req.onsuccess = function () { resolve(req.result === undefined ? null : req.result) }
|
|
537
|
+
req.onerror = function () { resolve(null) }
|
|
538
|
+
} catch (e) {
|
|
539
|
+
resolve(null)
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** 删除一张图片。 */
|
|
546
|
+
function idbDeleteImage(key) {
|
|
547
|
+
if (!key) return Promise.resolve()
|
|
548
|
+
return imageDb().then(function (db) {
|
|
549
|
+
if (db === null) return
|
|
550
|
+
try {
|
|
551
|
+
db.transaction(IMG_STORE, 'readwrite').objectStore(IMG_STORE).delete(key)
|
|
552
|
+
} catch (e) { /* ignore */ }
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 读弹窗背景配置(本地偏好,非会话数据),损坏则回默认。 */
|
|
557
|
+
function readModalStyle() {
|
|
558
|
+
const out = {
|
|
559
|
+
mode: DEFAULT_MODAL_STYLE.mode,
|
|
560
|
+
color: DEFAULT_MODAL_STYLE.color,
|
|
561
|
+
image: DEFAULT_MODAL_STYLE.image,
|
|
562
|
+
imageKey: DEFAULT_MODAL_STYLE.imageKey,
|
|
563
|
+
opacity: DEFAULT_MODAL_STYLE.opacity,
|
|
564
|
+
blur: DEFAULT_MODAL_STYLE.blur,
|
|
565
|
+
glass: DEFAULT_MODAL_STYLE.glass,
|
|
566
|
+
}
|
|
567
|
+
const raw = lsGet(MODAL_STYLE_KEY)
|
|
568
|
+
if (!raw) return out
|
|
569
|
+
try {
|
|
570
|
+
const rec = JSON.parse(raw)
|
|
571
|
+
if (!rec || typeof rec !== 'object') return out
|
|
572
|
+
if (rec.mode === 'default' || rec.mode === 'glass' || rec.mode === 'image' || rec.mode === 'solid') out.mode = rec.mode
|
|
573
|
+
if (typeof rec.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(rec.color)) out.color = rec.color
|
|
574
|
+
if (typeof rec.image === 'string') out.image = rec.image
|
|
575
|
+
if (typeof rec.imageKey === 'string') out.imageKey = rec.imageKey
|
|
576
|
+
if (typeof rec.opacity === 'number' && rec.opacity >= 0 && rec.opacity <= 100) {
|
|
577
|
+
out.opacity = rec.opacity
|
|
578
|
+
} else if (typeof rec.scrim === 'number' && rec.scrim >= 0 && rec.scrim <= 100) {
|
|
579
|
+
// 旧配置:压暗 62% ⇒ 图片不透明度 38%
|
|
580
|
+
out.opacity = Math.max(0, Math.min(100, 100 - rec.scrim))
|
|
581
|
+
}
|
|
582
|
+
if (typeof rec.blur === 'number' && rec.blur >= 0 && rec.blur <= 48) out.blur = rec.blur
|
|
583
|
+
if (rec.glass === true) out.glass = true
|
|
584
|
+
} catch (e) { /* 忽略损坏配置 */ }
|
|
585
|
+
return out
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 浅/深底上的墨色调色板:只作用于弹窗子树(覆盖主题 token 的局部值)。
|
|
590
|
+
* 连 `--dsw-alias-bg-overlay` 一起覆盖,否则 tooltip 会用主题的浅色底配我们强制出来的浅色字 = 看不见。
|
|
591
|
+
*/
|
|
592
|
+
function inkPalette(dark) {
|
|
593
|
+
if (dark) {
|
|
594
|
+
return {
|
|
595
|
+
'--dsw-alias-bg-base': 'rgba(12,14,18,.58)',
|
|
596
|
+
'--dsw-alias-bg-layer-1': 'rgba(12,14,18,.55)',
|
|
597
|
+
'--dsw-alias-bg-layer-2': 'rgba(255,255,255,.12)',
|
|
598
|
+
'--dsw-alias-bg-overlay': 'rgba(16,18,24,.95)',
|
|
599
|
+
'--dsw-alias-label-primary': 'rgba(255,255,255,.96)',
|
|
600
|
+
'--dsw-alias-label-secondary': 'rgba(255,255,255,.74)',
|
|
601
|
+
'--dsw-alias-border-l1': 'rgba(255,255,255,.16)',
|
|
602
|
+
'--dsw-alias-border-l2': 'rgba(255,255,255,.28)',
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
'--dsw-alias-bg-base': 'rgba(255,255,255,.6)',
|
|
607
|
+
'--dsw-alias-bg-layer-1': 'rgba(255,255,255,.66)',
|
|
608
|
+
'--dsw-alias-bg-layer-2': 'rgba(0,0,0,.06)',
|
|
609
|
+
'--dsw-alias-bg-overlay': 'rgba(255,255,255,.97)',
|
|
610
|
+
'--dsw-alias-label-primary': 'rgba(0,0,0,.88)',
|
|
611
|
+
'--dsw-alias-label-secondary': 'rgba(0,0,0,.62)',
|
|
612
|
+
'--dsw-alias-border-l1': 'rgba(0,0,0,.12)',
|
|
613
|
+
'--dsw-alias-border-l2': 'rgba(0,0,0,.22)',
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 十六进制颜色的相对亮度(判断该配深色字还是浅色字)。 */
|
|
618
|
+
function hexLuma(hex) {
|
|
619
|
+
const v = parseInt(String(hex).slice(1), 16)
|
|
620
|
+
const r = (v >> 16) & 255
|
|
621
|
+
const g = (v >> 8) & 255
|
|
622
|
+
const b = v & 255
|
|
623
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 由配置算出弹窗的 class + 自定义属性。真正的观感全部交给 CSS 变量 + backdrop-filter:
|
|
628
|
+
* - 毛玻璃:不加压暗底色,靠 `backdrop-filter` 模糊背后的页面(与个性化插件的设置面板同款做法);
|
|
629
|
+
* - 自定义背景:图片走伪元素 `--dcb-bg-layer`,因此可以单独对图片做模糊;
|
|
630
|
+
* - 纯色:只给 `--dcb-solid`,由 CSS 决定透明度(开毛玻璃时半透明 + 模糊)。
|
|
631
|
+
* @param style 偏好配置。
|
|
632
|
+
* @param imageUrl 已解析好的图片地址(URL 或 IndexedDB 里取出来的 blob: 地址)。
|
|
633
|
+
*/
|
|
634
|
+
function modalSurface(style, imageUrl) {
|
|
635
|
+
const cfg = style || DEFAULT_MODAL_STYLE
|
|
636
|
+
const src = typeof imageUrl === 'string' && imageUrl !== '' ? imageUrl : (typeof cfg.image === 'string' ? cfg.image : '')
|
|
637
|
+
const hasImage = cfg.mode === 'image' && !!(cfg.imageKey || src)
|
|
638
|
+
const mode = cfg.mode === 'glass' ? 'glass' : (cfg.mode === 'solid' ? 'solid' : (hasImage ? 'image' : 'default'))
|
|
639
|
+
const glassed = cfg.mode === 'glass' || cfg.glass === true
|
|
640
|
+
const classes = ['dcb-modal']
|
|
641
|
+
if (mode === 'glass') classes.push('is-glass')
|
|
642
|
+
if (mode === 'solid') classes.push('has-solid')
|
|
643
|
+
if (mode === 'image') classes.push('has-image')
|
|
644
|
+
// 毛玻璃只对「有底」的三种形态有意义;图片被清除后回落默认外观,不再挂玻璃。
|
|
645
|
+
if (glassed && mode !== 'glass' && mode !== 'default') classes.push('is-glass')
|
|
646
|
+
const inline = {}
|
|
647
|
+
if (glassed) {
|
|
648
|
+
inline['--dcb-glass-blur'] = (typeof cfg.blur === 'number' ? cfg.blur : DEFAULT_MODAL_STYLE.blur) + 'px'
|
|
649
|
+
}
|
|
650
|
+
if (mode === 'solid') {
|
|
651
|
+
const dark = hexLuma(cfg.color) < 0.5
|
|
652
|
+
Object.assign(inline, inkPalette(dark))
|
|
653
|
+
inline['--dcb-solid'] = cfg.color
|
|
654
|
+
} else if (mode === 'image') {
|
|
655
|
+
// 与个性化插件其它背景栏同口径:图片自身带 opacity(真半透明,不与底色混成发白),
|
|
656
|
+
// 底下垫的是主题面板色,所以透明度越低越"淡",而不是越"暗"。
|
|
657
|
+
inline['--dcb-bg-image'] = 'url("' + String(src).replace(/"/g, '%22') + '")'
|
|
658
|
+
const alpha = Math.min(1, Math.max(0, (typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity) / 100))
|
|
659
|
+
inline['--dcb-bg-opacity'] = String(alpha)
|
|
660
|
+
}
|
|
661
|
+
// 遮罩层永远不参与毛玻璃:模糊只发生在弹窗本体(`.dcb-modal.is-glass` 的 backdrop-filter)。
|
|
662
|
+
return {
|
|
663
|
+
className: classes.join(' '),
|
|
664
|
+
style: inline,
|
|
665
|
+
glass: glassed || mode === 'glass',
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* ===================== 更新检测(对齐个性化插件的口径) ===================== */
|
|
670
|
+
|
|
671
|
+
/** 解析版本号:`1.2.3-rc.1` → { numbers:[1,2,3], pre:['rc','1'] }。 */
|
|
672
|
+
function parseVersionText(value) {
|
|
673
|
+
const text = typeof value === 'string' ? value.trim().replace(/^v/i, '') : ''
|
|
674
|
+
const dash = text.indexOf('-')
|
|
675
|
+
const core = dash >= 0 ? text.slice(0, dash) : text
|
|
676
|
+
const pre = dash >= 0 ? text.slice(dash + 1) : ''
|
|
677
|
+
const numbers = [0, 0, 0]
|
|
678
|
+
const parts = core.split('.')
|
|
679
|
+
for (let i = 0; i < 3; i += 1) {
|
|
680
|
+
const n = parseInt(parts[i] === undefined ? '0' : parts[i], 10)
|
|
681
|
+
numbers[i] = Number.isFinite(n) ? n : 0
|
|
682
|
+
}
|
|
683
|
+
return { numbers: numbers, pre: pre === '' ? [] : pre.split('.') }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 预发布段比较:数字段按数值、字母段按字典序,缺段者更高(1.0.0 > 1.0.0-rc.1)。 */
|
|
687
|
+
function comparePrerelease(a, b) {
|
|
688
|
+
if (a.length === 0 && b.length === 0) return 0
|
|
689
|
+
if (a.length === 0) return 1
|
|
690
|
+
if (b.length === 0) return -1
|
|
691
|
+
const len = Math.max(a.length, b.length)
|
|
692
|
+
for (let i = 0; i < len; i += 1) {
|
|
693
|
+
if (a[i] === undefined) return -1
|
|
694
|
+
if (b[i] === undefined) return 1
|
|
695
|
+
const an = Number(a[i])
|
|
696
|
+
const bn = Number(b[i])
|
|
697
|
+
const aNum = Number.isFinite(an) && String(an) === String(a[i])
|
|
698
|
+
const bNum = Number.isFinite(bn) && String(bn) === String(b[i])
|
|
699
|
+
if (aNum && bNum) {
|
|
700
|
+
if (an !== bn) return an < bn ? -1 : 1
|
|
701
|
+
} else if (a[i] !== b[i]) {
|
|
702
|
+
return a[i] < b[i] ? -1 : 1
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return 0
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** 语义化版本比较:a<b 返回 -1,相等 0,a>b 返回 1。 */
|
|
709
|
+
function compareVersions(local, latest) {
|
|
710
|
+
const a = parseVersionText(local)
|
|
711
|
+
const b = parseVersionText(latest)
|
|
712
|
+
for (let i = 0; i < 3; i += 1) {
|
|
713
|
+
if (a.numbers[i] !== b.numbers[i]) return a.numbers[i] < b.numbers[i] ? -1 : 1
|
|
714
|
+
}
|
|
715
|
+
return comparePrerelease(a.pre, b.pre)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** 读 registry 结果缓存 `{latestVersion, checkedAt}`;结构非法一律视为无缓存。 */
|
|
719
|
+
function readUpdateCache() {
|
|
720
|
+
const raw = lsGet(LS_UPDATE_CHECK)
|
|
721
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
722
|
+
try {
|
|
723
|
+
const parsed = JSON.parse(raw)
|
|
724
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
725
|
+
const latest = typeof parsed.latestVersion === 'string' ? parsed.latestVersion : ''
|
|
726
|
+
const checkedAt = Number(parsed.checkedAt)
|
|
727
|
+
if (latest === '' || !Number.isFinite(checkedAt)) return null
|
|
728
|
+
return { latestVersion: latest, checkedAt: checkedAt }
|
|
729
|
+
} catch (e) {
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function writeUpdateCache(info) {
|
|
735
|
+
if (!info || typeof info !== 'object') return
|
|
736
|
+
lsSet(LS_UPDATE_CHECK, JSON.stringify({ latestVersion: String(info.latestVersion), checkedAt: Number(info.checkedAt) }))
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function readIgnoredVersion() {
|
|
740
|
+
const raw = lsGet(LS_IGNORED_UPDATE)
|
|
741
|
+
return typeof raw === 'string' && raw !== '' ? raw : null
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function writeIgnoredVersion(version) {
|
|
745
|
+
if (version === null || version === undefined || version === '') return
|
|
746
|
+
lsSet(LS_IGNORED_UPDATE, String(version))
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* 请求 npm registry 的最新版本(5s 超时、静默失败)。
|
|
751
|
+
* 浏览器直连 registry 可行:它的 GET 响应带 `access-control-allow-origin: *`。
|
|
752
|
+
* @returns 版本号字符串,任何异常返回 null。
|
|
753
|
+
*/
|
|
754
|
+
function fetchLatestVersion() {
|
|
755
|
+
if (typeof fetch !== 'function') return Promise.resolve(null)
|
|
756
|
+
const options = { headers: { Accept: 'application/vnd.npm.install-v1+json' } }
|
|
757
|
+
let controller = null
|
|
758
|
+
if (typeof AbortController === 'function') {
|
|
759
|
+
try {
|
|
760
|
+
controller = new AbortController()
|
|
761
|
+
options.signal = controller.signal
|
|
762
|
+
} catch (e) {
|
|
763
|
+
controller = null
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const request = fetch(UPDATE_REGISTRY_URL, options)
|
|
767
|
+
.then(function (response) {
|
|
768
|
+
if (!response || response.ok === false) throw new Error('registry status ' + (response && response.status))
|
|
769
|
+
return response.json()
|
|
770
|
+
})
|
|
771
|
+
.then(function (data) {
|
|
772
|
+
const latest = data && typeof data.version === 'string' ? data.version.trim() : ''
|
|
773
|
+
return latest !== '' ? latest : null
|
|
774
|
+
})
|
|
775
|
+
.catch(function (err) {
|
|
776
|
+
console.warn('[consumption-badge] update check failed (silent): ' + (err && err.message ? err.message : String(err)))
|
|
777
|
+
return null
|
|
778
|
+
})
|
|
779
|
+
if (controller === null) return request
|
|
780
|
+
return new Promise(function (resolve) {
|
|
781
|
+
const timer = setTimeout(function () {
|
|
782
|
+
try {
|
|
783
|
+
controller.abort()
|
|
784
|
+
} catch (e) { /* ignore */ }
|
|
785
|
+
resolve(null)
|
|
786
|
+
}, UPDATE_FETCH_TIMEOUT)
|
|
787
|
+
request.then(function (value) {
|
|
788
|
+
clearTimeout(timer)
|
|
789
|
+
resolve(value)
|
|
790
|
+
})
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 主流程:缓存命中(< TTL)直接用缓存比较,过期/缺失才请求 registry。
|
|
796
|
+
* 任何失败都返回 hasUpdate:false,绝不 reject —— 调用方无需 try/catch。
|
|
797
|
+
* @returns Promise<{hasUpdate, latestVersion, currentVersion, fromCache}>
|
|
798
|
+
*/
|
|
799
|
+
function checkForUpdate() {
|
|
800
|
+
const current = PLUGIN_VERSION
|
|
801
|
+
const ignored = readIgnoredVersion()
|
|
802
|
+
const cached = readUpdateCache()
|
|
803
|
+
const fresh = cached !== null && Date.now() - cached.checkedAt < UPDATE_CHECK_TTL
|
|
804
|
+
const base = fresh
|
|
805
|
+
? Promise.resolve(cached.latestVersion)
|
|
806
|
+
: fetchLatestVersion().then(function (latest) {
|
|
807
|
+
if (latest === null) return cached !== null ? cached.latestVersion : null
|
|
808
|
+
writeUpdateCache({ latestVersion: latest, checkedAt: Date.now() })
|
|
809
|
+
return latest
|
|
810
|
+
})
|
|
811
|
+
return base.then(function (latest) {
|
|
812
|
+
if (latest === null || latest === undefined) {
|
|
813
|
+
return { hasUpdate: false, latestVersion: null, currentVersion: current, fromCache: fresh, ignored: false }
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
hasUpdate: compareVersions(current, latest) < 0 && latest !== ignored,
|
|
817
|
+
latestVersion: latest,
|
|
818
|
+
currentVersion: current,
|
|
819
|
+
fromCache: fresh,
|
|
820
|
+
ignored: latest === ignored,
|
|
821
|
+
}
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
|
|
204
825
|
/* shared store: every pill and the single overlay panel read this */
|
|
205
826
|
function createStore() {
|
|
206
827
|
const state = {
|
|
@@ -211,11 +832,29 @@ window.__ModuleLoader__.load({
|
|
|
211
832
|
turns: [],
|
|
212
833
|
turnsState: 'idle',
|
|
213
834
|
turnsError: '',
|
|
835
|
+
// Error copy is stored language-neutral: the kind names the sentence and
|
|
836
|
+
// the raw detail rides turnsError, so a locale switch re-renders the text.
|
|
837
|
+
turnsErrorKind: '',
|
|
838
|
+
turnsErrorCode: '',
|
|
839
|
+
turnsErrorFallback: '',
|
|
214
840
|
account: null,
|
|
215
841
|
accountState: 'idle',
|
|
842
|
+
integrations: null,
|
|
843
|
+
modalStyle: readModalStyle(),
|
|
844
|
+
// 已解析的弹窗背景图地址:URL 直接用,本地图片是 IndexedDB 里的 blob: 地址。
|
|
845
|
+
modalImageUrl: '',
|
|
846
|
+
// 图片保存过程的可观测状态:'' | 'saving' | 'saved' | 以 'error:' 开头的失败原因
|
|
847
|
+
modalImageStatus: '',
|
|
848
|
+
// 更新提示弹窗:'idle' | 'checking' | 'ready';laterDismissed 只在本次会话有效
|
|
849
|
+
updateState: 'idle',
|
|
850
|
+
updateInfo: null,
|
|
851
|
+
updateDismissed: false,
|
|
216
852
|
balance: null,
|
|
217
853
|
balanceState: 'idle',
|
|
218
854
|
balanceError: '',
|
|
855
|
+
balanceErrorKind: '',
|
|
856
|
+
balanceErrorCode: '',
|
|
857
|
+
balanceErrorFallback: '',
|
|
219
858
|
balanceAt: 0,
|
|
220
859
|
}
|
|
221
860
|
const listeners = new Set()
|
|
@@ -243,6 +882,7 @@ window.__ModuleLoader__.load({
|
|
|
243
882
|
.then(function () { return fetchJson(API + '/account') })
|
|
244
883
|
.then(function (res) {
|
|
245
884
|
if (res && typeof res === 'object') state.account = res
|
|
885
|
+
if (res && res.integrations && typeof res.integrations === 'object') state.integrations = res.integrations
|
|
246
886
|
state.accountState = 'ready'
|
|
247
887
|
emit()
|
|
248
888
|
})
|
|
@@ -283,13 +923,26 @@ window.__ModuleLoader__.load({
|
|
|
283
923
|
}
|
|
284
924
|
state.turns = restoreTurns(sessionId, normalized)
|
|
285
925
|
state.turnsState = res && res.ok === false ? 'error' : 'ready'
|
|
286
|
-
|
|
926
|
+
if (res && res.ok === false) {
|
|
927
|
+
state.turnsErrorKind = 'host'
|
|
928
|
+
state.turnsErrorCode = typeof res.code === 'string' ? res.code : ''
|
|
929
|
+
state.turnsError = typeof res.detail === 'string' ? res.detail : ''
|
|
930
|
+
state.turnsErrorFallback = typeof res.error === 'string' ? res.error : ''
|
|
931
|
+
} else {
|
|
932
|
+
state.turnsErrorKind = ''
|
|
933
|
+
state.turnsErrorCode = ''
|
|
934
|
+
state.turnsError = ''
|
|
935
|
+
state.turnsErrorFallback = ''
|
|
936
|
+
}
|
|
287
937
|
persistTurns(sessionId, normalized)
|
|
288
938
|
emit()
|
|
289
939
|
})
|
|
290
940
|
.catch(function (e) {
|
|
291
941
|
state.turnsState = 'error'
|
|
292
|
-
state.
|
|
942
|
+
state.turnsErrorKind = 'read'
|
|
943
|
+
state.turnsErrorCode = ''
|
|
944
|
+
state.turnsError = msgOf(e)
|
|
945
|
+
state.turnsErrorFallback = ''
|
|
293
946
|
emit()
|
|
294
947
|
})
|
|
295
948
|
.then(function () { turnsInFlight = null })
|
|
@@ -304,17 +957,35 @@ window.__ModuleLoader__.load({
|
|
|
304
957
|
balanceInFlight = Promise.resolve()
|
|
305
958
|
.then(function () { return fetchJson(API + '/balance' + (force ? '?force=1' : '')) })
|
|
306
959
|
.then(function (res) {
|
|
307
|
-
const value = res && typeof res === 'object' ? res : { ok: false
|
|
960
|
+
const value = res && typeof res === 'object' ? res : { ok: false }
|
|
308
961
|
state.balance = value
|
|
309
962
|
state.balanceAt = Date.now()
|
|
310
963
|
state.balanceState = 'ready'
|
|
311
|
-
|
|
964
|
+
if (!res || typeof res !== 'object') {
|
|
965
|
+
state.balanceErrorKind = 'empty'
|
|
966
|
+
state.balanceError = ''
|
|
967
|
+
state.balanceErrorCode = ''
|
|
968
|
+
state.balanceErrorFallback = ''
|
|
969
|
+
} else if (value.ok) {
|
|
970
|
+
state.balanceErrorKind = ''
|
|
971
|
+
state.balanceError = ''
|
|
972
|
+
state.balanceErrorCode = ''
|
|
973
|
+
state.balanceErrorFallback = ''
|
|
974
|
+
} else {
|
|
975
|
+
state.balanceErrorKind = 'host'
|
|
976
|
+
state.balanceErrorCode = typeof value.code === 'string' ? value.code : ''
|
|
977
|
+
state.balanceError = typeof value.detail === 'string' ? value.detail : ''
|
|
978
|
+
state.balanceErrorFallback = typeof value.error === 'string' ? value.error : ''
|
|
979
|
+
}
|
|
312
980
|
emit()
|
|
313
981
|
})
|
|
314
982
|
.catch(function (e) {
|
|
315
983
|
state.balance = null
|
|
316
984
|
state.balanceState = 'error'
|
|
317
|
-
state.
|
|
985
|
+
state.balanceErrorKind = 'fetch'
|
|
986
|
+
state.balanceErrorCode = ''
|
|
987
|
+
state.balanceError = msgOf(e)
|
|
988
|
+
state.balanceErrorFallback = ''
|
|
318
989
|
emit()
|
|
319
990
|
})
|
|
320
991
|
.then(function () { balanceInFlight = null })
|
|
@@ -337,6 +1008,159 @@ window.__ModuleLoader__.load({
|
|
|
337
1008
|
function bump() {
|
|
338
1009
|
emit()
|
|
339
1010
|
}
|
|
1011
|
+
/** 更新弹窗背景配置:合并 → 落本地偏好 → 通知所有订阅者(弹层即时重绘)。 */
|
|
1012
|
+
function setModalStyle(patch) {
|
|
1013
|
+
const prev = state.modalStyle
|
|
1014
|
+
const next = {
|
|
1015
|
+
mode: prev.mode,
|
|
1016
|
+
color: prev.color,
|
|
1017
|
+
image: typeof prev.image === 'string' ? prev.image : '',
|
|
1018
|
+
imageKey: typeof prev.imageKey === 'string' ? prev.imageKey : '',
|
|
1019
|
+
opacity: typeof prev.opacity === 'number' ? prev.opacity : DEFAULT_MODAL_STYLE.opacity,
|
|
1020
|
+
blur: typeof prev.blur === 'number' ? prev.blur : DEFAULT_MODAL_STYLE.blur,
|
|
1021
|
+
glass: prev.glass === true,
|
|
1022
|
+
}
|
|
1023
|
+
if (patch && typeof patch === 'object') {
|
|
1024
|
+
if (patch.mode === 'default' || patch.mode === 'glass' || patch.mode === 'image' || patch.mode === 'solid') next.mode = patch.mode
|
|
1025
|
+
if (typeof patch.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(patch.color)) next.color = patch.color
|
|
1026
|
+
if (typeof patch.image === 'string') next.image = patch.image
|
|
1027
|
+
if (typeof patch.imageKey === 'string') next.imageKey = patch.imageKey
|
|
1028
|
+
if (typeof patch.opacity === 'number' && patch.opacity >= 0 && patch.opacity <= 100) next.opacity = patch.opacity
|
|
1029
|
+
if (typeof patch.blur === 'number' && patch.blur >= 0 && patch.blur <= 48) next.blur = patch.blur
|
|
1030
|
+
if (typeof patch.glass === 'boolean') next.glass = patch.glass
|
|
1031
|
+
}
|
|
1032
|
+
state.modalStyle = next
|
|
1033
|
+
lsSet(MODAL_STYLE_KEY, JSON.stringify(next))
|
|
1034
|
+
emit()
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** 切换 blob: 地址时先回收旧的,避免内存泄漏。 */
|
|
1038
|
+
function setResolvedImageUrl(url) {
|
|
1039
|
+
const prev = state.modalImageUrl
|
|
1040
|
+
state.modalImageUrl = url
|
|
1041
|
+
if (prev && prev.indexOf('blob:') === 0 && prev !== url && typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
|
|
1042
|
+
try {
|
|
1043
|
+
URL.revokeObjectURL(prev)
|
|
1044
|
+
} catch (e) { /* ignore */ }
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* 存一张本地图片:Blob 进 IndexedDB(不做 base64、无大小上限),偏好里只记 key。
|
|
1050
|
+
* 任何一步失败都会写进 `modalImageStatus` 并在控制台留一行带标记的日志,
|
|
1051
|
+
* 这样界面上能直接看到原因,不再是"静默失败 + 显示未设置"。
|
|
1052
|
+
* @param file 用户选择的 File。
|
|
1053
|
+
*/
|
|
1054
|
+
function setModalImage(file) {
|
|
1055
|
+
if (!file) return Promise.resolve()
|
|
1056
|
+
state.modalImageStatus = 'saving'
|
|
1057
|
+
emit()
|
|
1058
|
+
const key = 'modal-bg-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
1059
|
+
return idbPutImage(key, file).then(function (stored) {
|
|
1060
|
+
if (stored !== true) {
|
|
1061
|
+
console.warn('[consumption-badge] IndexedDB write failed, falling back to a data URL')
|
|
1062
|
+
return readAsDataUrl(file).then(function (dataUrl) {
|
|
1063
|
+
if (!dataUrl) {
|
|
1064
|
+
state.modalImageStatus = 'error: ' + tt('IndexedDB 与 FileReader 都不可用')
|
|
1065
|
+
emit()
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
setModalStyle({ mode: 'image', image: dataUrl, imageKey: '' })
|
|
1069
|
+
state.modalImageStatus = 'saved'
|
|
1070
|
+
emit()
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
return idbGetImage(key).then(function (blob) {
|
|
1074
|
+
const prevKey = state.modalStyle.imageKey
|
|
1075
|
+
setModalStyle({ mode: 'image', image: '', imageKey: key })
|
|
1076
|
+
if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function' && blob) {
|
|
1077
|
+
setResolvedImageUrl(URL.createObjectURL(blob))
|
|
1078
|
+
} else if (blob === null) {
|
|
1079
|
+
console.warn('[consumption-badge] image stored but could not be read back', key)
|
|
1080
|
+
}
|
|
1081
|
+
if (prevKey && prevKey !== key) idbDeleteImage(prevKey)
|
|
1082
|
+
state.modalImageStatus = 'saved'
|
|
1083
|
+
emit()
|
|
1084
|
+
})
|
|
1085
|
+
}).catch(function (err) {
|
|
1086
|
+
const text = err && err.message ? err.message : String(err)
|
|
1087
|
+
console.error('[consumption-badge] failed to save the image: ' + text)
|
|
1088
|
+
state.modalImageStatus = 'error: ' + text
|
|
1089
|
+
emit()
|
|
1090
|
+
})
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** FileReader 兜底:把文件读成 dataURL(无大小限制,能存多少算多少)。 */
|
|
1094
|
+
function readAsDataUrl(file) {
|
|
1095
|
+
if (typeof FileReader !== 'function') return Promise.resolve('')
|
|
1096
|
+
return new Promise(function (resolve) {
|
|
1097
|
+
try {
|
|
1098
|
+
const reader = new FileReader()
|
|
1099
|
+
reader.onload = function () { resolve(typeof reader.result === 'string' ? reader.result : '') }
|
|
1100
|
+
reader.onerror = function () { resolve('') }
|
|
1101
|
+
reader.readAsDataURL(file)
|
|
1102
|
+
} catch (e) {
|
|
1103
|
+
resolve('')
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** 清除本地图片:库里的记录、blob: 地址、偏好键一起清。 */
|
|
1109
|
+
function clearModalImage() {
|
|
1110
|
+
const prevKey = state.modalStyle.imageKey
|
|
1111
|
+
setResolvedImageUrl('')
|
|
1112
|
+
setModalStyle({ image: '', imageKey: '' })
|
|
1113
|
+
state.modalImageStatus = ''
|
|
1114
|
+
emit()
|
|
1115
|
+
return idbDeleteImage(prevKey)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** 启动时把已保存的本地图片取回来(生成新的 blob: 地址)。 */
|
|
1119
|
+
function hydrateModalImage() {
|
|
1120
|
+
const key = state.modalStyle.imageKey
|
|
1121
|
+
if (!key) return Promise.resolve()
|
|
1122
|
+
return idbGetImage(key).then(function (blob) {
|
|
1123
|
+
if (!blob) return
|
|
1124
|
+
if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') {
|
|
1125
|
+
setResolvedImageUrl(URL.createObjectURL(blob))
|
|
1126
|
+
emit()
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** 启动后异步检查更新(绝不阻塞首屏);失败静默。 */
|
|
1132
|
+
function checkUpdates() {
|
|
1133
|
+
if (state.updateState === 'checking') return Promise.resolve(state.updateInfo)
|
|
1134
|
+
state.updateState = 'checking'
|
|
1135
|
+
emit()
|
|
1136
|
+
return checkForUpdate()
|
|
1137
|
+
.then(function (info) {
|
|
1138
|
+
state.updateInfo = info
|
|
1139
|
+
state.updateState = 'ready'
|
|
1140
|
+
emit()
|
|
1141
|
+
return info
|
|
1142
|
+
})
|
|
1143
|
+
.catch(function () {
|
|
1144
|
+
state.updateState = 'ready'
|
|
1145
|
+
state.updateInfo = null
|
|
1146
|
+
emit()
|
|
1147
|
+
return null
|
|
1148
|
+
})
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* 关闭更新提示。
|
|
1153
|
+
* @param kind 'later' 只关本次会话;'ignore' 永久忽略该版本(写忽略键,与个性化插件同口径)。
|
|
1154
|
+
*/
|
|
1155
|
+
function dismissUpdate(kind) {
|
|
1156
|
+
if (kind === 'ignore' && state.updateInfo && state.updateInfo.latestVersion) {
|
|
1157
|
+
writeIgnoredVersion(state.updateInfo.latestVersion)
|
|
1158
|
+
if (state.updateInfo) state.updateInfo.hasUpdate = false
|
|
1159
|
+
}
|
|
1160
|
+
state.updateDismissed = true
|
|
1161
|
+
emit()
|
|
1162
|
+
}
|
|
1163
|
+
|
|
340
1164
|
return {
|
|
341
1165
|
state: state,
|
|
342
1166
|
subscribe: subscribe,
|
|
@@ -345,6 +1169,12 @@ window.__ModuleLoader__.load({
|
|
|
345
1169
|
ensureBalance: ensureBalance,
|
|
346
1170
|
openPanel: openPanel,
|
|
347
1171
|
closePanel: closePanel,
|
|
1172
|
+
setModalStyle: setModalStyle,
|
|
1173
|
+
setModalImage: setModalImage,
|
|
1174
|
+
clearModalImage: clearModalImage,
|
|
1175
|
+
hydrateModalImage: hydrateModalImage,
|
|
1176
|
+
checkUpdates: checkUpdates,
|
|
1177
|
+
dismissUpdate: dismissUpdate,
|
|
348
1178
|
bump: bump,
|
|
349
1179
|
}
|
|
350
1180
|
}
|
|
@@ -392,7 +1222,7 @@ window.__ModuleLoader__.load({
|
|
|
392
1222
|
const holder = React.useState(-1)
|
|
393
1223
|
const hover = holder[0]
|
|
394
1224
|
const setHover = holder[1]
|
|
395
|
-
if (!series.length) return h('div', { className: 'dcb-empty' }, '本会话还没有可统计的轮次')
|
|
1225
|
+
if (!series.length) return h('div', { className: 'dcb-empty' }, tt('本会话还没有可统计的轮次'))
|
|
396
1226
|
const innerW = CHART_W - CHART_PAD.l - CHART_PAD.r
|
|
397
1227
|
const innerH = CHART_H - CHART_PAD.t - CHART_PAD.b
|
|
398
1228
|
let maxToken = 1
|
|
@@ -459,18 +1289,18 @@ window.__ModuleLoader__.load({
|
|
|
459
1289
|
h('div', { className: 'dcb-tooltip-title' },
|
|
460
1290
|
'#' + String(hovered.turn),
|
|
461
1291
|
h('span', { className: 'dcb-tooltip-model' }, hovered.model || DEFAULT_MODEL),
|
|
462
|
-
h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, hovered.peak ? '峰时' : '谷时')),
|
|
463
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输入命中'), h('b', null, fmtTokens(hovered.hit))),
|
|
464
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输入未命中'), h('b', null, fmtTokens(hovered.miss))),
|
|
465
|
-
h('div', { className: 'dcb-tooltip-row' }, h('span', null, '输出'), h('b', null, fmtTokens(hovered.out))),
|
|
466
|
-
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, '本轮合计'), h('b', null, fmtTokens(hovered.tokens))),
|
|
467
|
-
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, '估算金额'), h('b', null, '≈¥' + fmtYuan(hovered.cost))))
|
|
1292
|
+
h('span', { className: hovered.peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(hovered.peak ? '峰时' : '谷时'))),
|
|
1293
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入命中')), h('b', null, fmtTokens(hovered.hit))),
|
|
1294
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输入未命中')), h('b', null, fmtTokens(hovered.miss))),
|
|
1295
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('输出')), h('b', null, fmtTokens(hovered.out))),
|
|
1296
|
+
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('本轮合计')), h('b', null, fmtTokens(hovered.tokens))),
|
|
1297
|
+
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('估算金额')), h('b', null, '≈¥' + fmtYuan(hovered.cost))))
|
|
468
1298
|
: null
|
|
469
1299
|
|
|
470
1300
|
return h('div', { className: 'dcb-chart-wrap' },
|
|
471
1301
|
h('div', { className: 'dcb-chart-legend' },
|
|
472
|
-
h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), '每轮 Token'),
|
|
473
|
-
h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), '每轮估算金额')),
|
|
1302
|
+
h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot' }), tt('每轮 Token')),
|
|
1303
|
+
h('span', { className: 'dcb-legend-item' }, h('i', { className: 'dcb-legend-dot dcb-dot-cost' }), tt('每轮估算金额'))),
|
|
474
1304
|
h('div', { className: 'dcb-chart-body' },
|
|
475
1305
|
h('svg', {
|
|
476
1306
|
className: 'dcb-chart-svg',
|
|
@@ -492,6 +1322,24 @@ window.__ModuleLoader__.load({
|
|
|
492
1322
|
|
|
493
1323
|
/* components: one store per plugin instance */
|
|
494
1324
|
const store = createStore()
|
|
1325
|
+
|
|
1326
|
+
/* Error copy is composed at render time (not at fetch time) so it follows the locale. */
|
|
1327
|
+
function turnsErrorText() {
|
|
1328
|
+
const st = store.state
|
|
1329
|
+
if (st.turnsErrorKind === 'host') return hostErrorText(st.turnsErrorCode, st.turnsError, st.turnsErrorFallback)
|
|
1330
|
+
if (st.turnsErrorKind === 'read') return tt('会话消耗读取失败:{msg}', { msg: st.turnsError })
|
|
1331
|
+
return st.turnsError
|
|
1332
|
+
}
|
|
1333
|
+
function balanceErrorText() {
|
|
1334
|
+
const st = store.state
|
|
1335
|
+
if (st.balanceErrorKind === 'empty') return tt('空响应')
|
|
1336
|
+
if (st.balanceErrorKind === 'host') {
|
|
1337
|
+
const text = hostErrorText(st.balanceErrorCode, st.balanceError, st.balanceErrorFallback)
|
|
1338
|
+
return text === '' ? tt('余额获取失败') : text
|
|
1339
|
+
}
|
|
1340
|
+
if (st.balanceErrorKind === 'fetch') return tt('余额获取失败:{msg}', { msg: st.balanceError })
|
|
1341
|
+
return st.balanceError
|
|
1342
|
+
}
|
|
495
1343
|
// Assigned by the overlay's callback ref, so Esc can reach a focused node without useRef.
|
|
496
1344
|
let panelRootEl = null
|
|
497
1345
|
|
|
@@ -545,7 +1393,7 @@ window.__ModuleLoader__.load({
|
|
|
545
1393
|
return h('button', {
|
|
546
1394
|
type: 'button',
|
|
547
1395
|
className: 'dcb-pill',
|
|
548
|
-
title: '本会话消耗(点击查看逐轮明细)',
|
|
1396
|
+
title: tt('本会话消耗(点击查看逐轮明细)'),
|
|
549
1397
|
onClick: function (event) {
|
|
550
1398
|
try {
|
|
551
1399
|
event.preventDefault()
|
|
@@ -559,7 +1407,7 @@ window.__ModuleLoader__.load({
|
|
|
559
1407
|
h('span', { className: 'dcb-pill-sep' }, '|'),
|
|
560
1408
|
h('span', null, pending ? '…' : '≈¥' + fmtYuan(cost)),
|
|
561
1409
|
h('span', { className: 'dcb-pill-sep' }, '|'),
|
|
562
|
-
h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, peak ? '峰' : '谷'))
|
|
1410
|
+
h('span', { className: peak ? 'dcb-tag is-peak' : 'dcb-tag is-valley' }, tt(peak ? '峰' : '谷')))
|
|
563
1411
|
}
|
|
564
1412
|
|
|
565
1413
|
function ConsumptionPanel() {
|
|
@@ -589,6 +1437,7 @@ window.__ModuleLoader__.load({
|
|
|
589
1437
|
if (!open) return null
|
|
590
1438
|
|
|
591
1439
|
const now = Date.now()
|
|
1440
|
+
const surface = modalSurface(state.modalStyle, state.modalImageUrl)
|
|
592
1441
|
const model = state.account && typeof state.account.model === 'string' && state.account.model ? state.account.model : DEFAULT_MODEL
|
|
593
1442
|
const price = priceFor(model)
|
|
594
1443
|
const peak = isPeakAt(now)
|
|
@@ -598,27 +1447,28 @@ window.__ModuleLoader__.load({
|
|
|
598
1447
|
const totalCost = costOf(aggregated, price.table, peak)
|
|
599
1448
|
const balance = state.balance
|
|
600
1449
|
const loading = state.balanceState === 'loading'
|
|
1450
|
+
const balanceErr = balanceErrorText()
|
|
601
1451
|
let balanceValue = '—'
|
|
602
|
-
let balanceHint =
|
|
1452
|
+
let balanceHint = balanceErr || tt('打开面板时请求一次')
|
|
603
1453
|
let balanceClass = 'dcb-card-value'
|
|
604
1454
|
if (loading && !balance) {
|
|
605
|
-
balanceValue = '读取中…'
|
|
1455
|
+
balanceValue = tt('读取中…')
|
|
606
1456
|
balanceHint = 'GET /user/balance'
|
|
607
1457
|
} else if (balance && balance.ok === false) {
|
|
608
|
-
balanceValue = '获取失败'
|
|
609
|
-
balanceHint =
|
|
1458
|
+
balanceValue = tt('获取失败')
|
|
1459
|
+
balanceHint = balanceErr || tt('余额接口不可用')
|
|
610
1460
|
balanceClass = 'dcb-card-value dcb-bad'
|
|
611
1461
|
} else if (balance && balance.available === false) {
|
|
612
|
-
balanceValue = '余额不足'
|
|
613
|
-
balanceHint = '请前往 DeepSeek 开放平台充值'
|
|
1462
|
+
balanceValue = tt('余额不足')
|
|
1463
|
+
balanceHint = tt('请前往 DeepSeek 开放平台充值')
|
|
614
1464
|
balanceClass = 'dcb-card-value dcb-bad'
|
|
615
1465
|
} else if (balance) {
|
|
616
1466
|
const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
|
|
617
1467
|
balanceValue = symbol + fmtYuan(balance.total)
|
|
618
1468
|
const parts = []
|
|
619
|
-
if (typeof balance.granted === 'number') parts.push('赠送 ¥'
|
|
620
|
-
if (typeof balance.toppedUp === 'number') parts.push('充值 ¥'
|
|
621
|
-
balanceHint = parts.length ? parts.join(' · ') : 'DeepSeek 账户余额'
|
|
1469
|
+
if (typeof balance.granted === 'number') parts.push(tt('赠送 ¥{v}', { v: fmtYuan(balance.granted) }))
|
|
1470
|
+
if (typeof balance.toppedUp === 'number') parts.push(tt('充值 ¥{v}', { v: fmtYuan(balance.toppedUp) }))
|
|
1471
|
+
balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
|
|
622
1472
|
}
|
|
623
1473
|
|
|
624
1474
|
function close() {
|
|
@@ -643,27 +1493,1128 @@ window.__ModuleLoader__.load({
|
|
|
643
1493
|
if (event && event.target === event.currentTarget) close()
|
|
644
1494
|
},
|
|
645
1495
|
},
|
|
646
|
-
h('div', {
|
|
1496
|
+
h('div', {
|
|
1497
|
+
className: surface.className,
|
|
1498
|
+
role: 'dialog',
|
|
1499
|
+
'aria-label': tt('本会话消耗'),
|
|
1500
|
+
style: surface.style,
|
|
1501
|
+
},
|
|
647
1502
|
h('div', { className: 'dcb-head' },
|
|
648
|
-
h('div', { className: 'dcb-head-title' }, '⛽ 本会话消耗'),
|
|
1503
|
+
h('div', { className: 'dcb-head-title' }, tt('⛽ 本会话消耗')),
|
|
649
1504
|
h('div', { className: 'dcb-head-actions' },
|
|
650
|
-
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, '刷新余额'),
|
|
651
|
-
h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: 'Esc 关闭' }, '✕'))),
|
|
1505
|
+
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.ensureBalance(true) } }, tt('刷新余额')),
|
|
1506
|
+
h('button', { type: 'button', className: 'dcb-btn dcb-btn-close', onClick: close, title: tt('Esc 关闭') }, '✕'))),
|
|
652
1507
|
h('div', { className: 'dcb-cards' },
|
|
653
|
-
h(Card, { label: '累计 Token', value: fmtTokens(tokens), hint: '命中 '
|
|
654
|
-
h(Card, { label: '估算金额', value: '≈¥' + fmtYuan(totalCost), hint: (price.known ? price.id :
|
|
655
|
-
h(Card, { label: '当前时段', value: peak ? '高峰' : '空闲', hint: '北京时间 工作日 09:00-12:00 / 14:00-18:00' }),
|
|
1508
|
+
h(Card, { label: tt('累计 Token'), value: fmtTokens(tokens), hint: tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(aggregated.hit), miss: fmtTokens(aggregated.miss), out: fmtTokens(aggregated.out) }) }),
|
|
1509
|
+
h(Card, { label: tt('估算金额'), value: '≈¥' + fmtYuan(totalCost), hint: (price.known ? price.id : tt('{id}(按 flash 估算)', { id: price.id })) + tt(peak ? ' · 峰时 ×2' : ' · 谷时') }),
|
|
1510
|
+
h(Card, { label: tt('当前时段'), value: tt(peak ? '高峰' : '空闲'), hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00') }),
|
|
656
1511
|
h('div', { className: 'dcb-card' },
|
|
657
|
-
h('div', { className: 'dcb-card-label' }, '账户余额'),
|
|
1512
|
+
h('div', { className: 'dcb-card-label' }, tt('账户余额')),
|
|
658
1513
|
h('div', { className: balanceClass }, balanceValue),
|
|
659
1514
|
h('div', { className: 'dcb-card-hint' }, balanceHint))),
|
|
660
|
-
h('div', { className: 'dcb-section-title' }, '逐轮消耗趋势'),
|
|
1515
|
+
h('div', { className: 'dcb-section-title' }, tt('逐轮消耗趋势')),
|
|
661
1516
|
h(UsageChart, { series: series }),
|
|
662
1517
|
h('div', { className: 'dcb-foot' },
|
|
663
|
-
state.turnsError ? h('span', { className: 'dcb-foot-warn' },
|
|
664
|
-
'金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。')))
|
|
1518
|
+
state.turnsError ? h('span', { className: 'dcb-foot-warn' }, turnsErrorText() + ' ') : null,
|
|
1519
|
+
tt('金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考;数据源为本会话日志,刷新后自动恢复。'))))
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/* ===================== settings section: history page ===================== */
|
|
1523
|
+
|
|
1524
|
+
// 逐轮记录超过这个条数就改在 Web Worker 里聚合(Worker 不可用时退回主线程)。
|
|
1525
|
+
const WORKER_THRESHOLD = 5000
|
|
1526
|
+
// 工作区下拉里的“未归属”哨兵:没有 session→workspace 映射的记录(例如会话已删除)。
|
|
1527
|
+
const UNKNOWN_WORKSPACE = '__none__'
|
|
1528
|
+
let aggSeq = 0
|
|
1529
|
+
let aggSeqAll = 0
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* 读出全部逐轮记录。键结构沿用徽章既有存储:`dsh-consumption:<sessionId>:<turn>`,
|
|
1533
|
+
* 值 {hit, miss, out, time, model}。不新增任何存储。
|
|
1534
|
+
* @returns 记录数组(损坏/空记录跳过)。
|
|
1535
|
+
*/
|
|
1536
|
+
function readAllRecords() {
|
|
1537
|
+
const out = []
|
|
1538
|
+
const keys = lsKeys(LS_PREFIX)
|
|
1539
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
1540
|
+
const key = keys[i]
|
|
1541
|
+
const rest = key.slice(LS_PREFIX.length)
|
|
1542
|
+
const sep = rest.lastIndexOf(':')
|
|
1543
|
+
if (sep <= 0) continue
|
|
1544
|
+
const sid = rest.slice(0, sep)
|
|
1545
|
+
const turn = Number(rest.slice(sep + 1))
|
|
1546
|
+
const raw = lsGet(key)
|
|
1547
|
+
if (!raw) continue
|
|
1548
|
+
try {
|
|
1549
|
+
const rec = JSON.parse(raw)
|
|
1550
|
+
if (!rec || typeof rec !== 'object') continue
|
|
1551
|
+
const hit = num(rec.hit)
|
|
1552
|
+
const miss = num(rec.miss)
|
|
1553
|
+
const produced = num(rec.out)
|
|
1554
|
+
if (hit + miss + produced === 0) continue
|
|
1555
|
+
out.push({
|
|
1556
|
+
sid: sid,
|
|
1557
|
+
turn: Number.isFinite(turn) ? turn : 0,
|
|
1558
|
+
time: typeof rec.time === 'number' ? rec.time : 0,
|
|
1559
|
+
model: typeof rec.model === 'string' ? rec.model : '',
|
|
1560
|
+
hit: hit,
|
|
1561
|
+
miss: miss,
|
|
1562
|
+
out: produced,
|
|
1563
|
+
})
|
|
1564
|
+
} catch (e) { /* 跳过损坏记录 */ }
|
|
1565
|
+
}
|
|
1566
|
+
return out
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* 解析 useWorkspaces 的签名串:既得到下拉选项,也得到**精确**的 session→workspace 映射
|
|
1571
|
+
* (WorkspaceView.sessionIds 就是归属账本,无需按路径猜)。
|
|
1572
|
+
* @param sig 由 useWorkspaces 选择器拼出的 `wid\u0001title\u0001sid,sid` 以 \u0002 连接。
|
|
1573
|
+
*/
|
|
1574
|
+
function buildWorkspaceIndex(sig) {
|
|
1575
|
+
const list = []
|
|
1576
|
+
const bySession = {}
|
|
1577
|
+
if (!sig) return { list: list, bySession: bySession }
|
|
1578
|
+
const groups = String(sig).split('\u0002')
|
|
1579
|
+
for (let i = 0; i < groups.length; i += 1) {
|
|
1580
|
+
const parts = groups[i].split('\u0001')
|
|
1581
|
+
if (parts.length < 3) continue
|
|
1582
|
+
const wid = parts[0]
|
|
1583
|
+
if (!wid) continue
|
|
1584
|
+
list.push({ id: wid, title: parts[1] || wid })
|
|
1585
|
+
const ids = parts[2] ? parts[2].split(',') : []
|
|
1586
|
+
for (let j = 0; j < ids.length; j += 1) {
|
|
1587
|
+
if (ids[j]) bySession[ids[j]] = wid
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return { list: list, bySession: bySession }
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** 北京时间当日 00:00 的毫秒值。 */
|
|
1594
|
+
function bjDayStart(ms) {
|
|
1595
|
+
const t = new Date(ms + 8 * 3600 * 1000)
|
|
1596
|
+
return Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate()) - 8 * 3600 * 1000
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** 北京时间某月 1 日 00:00 的毫秒值(monthDelta 可为负)。 */
|
|
1600
|
+
function bjMonthStart(ms, monthDelta) {
|
|
1601
|
+
const t = new Date(ms + 8 * 3600 * 1000)
|
|
1602
|
+
return Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + (monthDelta || 0), 1) - 8 * 3600 * 1000
|
|
665
1603
|
}
|
|
666
1604
|
|
|
1605
|
+
/** `<input type="date">` 的值 → 北京时间当日 00:00 / 23:59:59.999。 */
|
|
1606
|
+
function parseDateInput(value, endOfDay) {
|
|
1607
|
+
if (!value) return 0
|
|
1608
|
+
const ms = Date.parse(String(value) + (endOfDay ? 'T23:59:59.999+08:00' : 'T00:00:00+08:00'))
|
|
1609
|
+
return Number.isFinite(ms) ? ms : 0
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* 时间范围解析。日历区间优先(覆盖下拉预设),否则按预设取北京时间自然日:
|
|
1614
|
+
* 今天=当日 00:00→现在(**按小时**);近一周=含今天在内 7 天;近一月=30 天;近一年=12 个自然月。
|
|
1615
|
+
*/
|
|
1616
|
+
function resolveRange(rangeKey, calFrom, calTo, now) {
|
|
1617
|
+
if (calFrom || calTo) {
|
|
1618
|
+
const a = parseDateInput(calFrom, false)
|
|
1619
|
+
const b = parseDateInput(calTo, true)
|
|
1620
|
+
const lo = Math.min(a > 0 ? a : 0, b > 0 ? b : now)
|
|
1621
|
+
const hi = Math.max(a > 0 ? a : 0, b > 0 ? b : now)
|
|
1622
|
+
const days = Math.round((hi - lo) / 86400000) + 1
|
|
1623
|
+
// 单日区间(含“今天”)按小时看更有意义,多日再退回按日、跨度大按月。
|
|
1624
|
+
if (hi - lo <= 36 * 3600000) return { from: lo, to: hi, granularity: 'hour' }
|
|
1625
|
+
return { from: lo, to: hi, granularity: days > 92 ? 'month' : 'day' }
|
|
1626
|
+
}
|
|
1627
|
+
if (rangeKey === 'today') return { from: bjDayStart(now), to: now, granularity: 'hour' }
|
|
1628
|
+
if (rangeKey === 'month') return { from: bjDayStart(now - 29 * 86400000), to: now, granularity: 'day' }
|
|
1629
|
+
if (rangeKey === 'year') return { from: bjMonthStart(now, -11), to: now, granularity: 'month' }
|
|
1630
|
+
return { from: bjDayStart(now - 6 * 86400000), to: now, granularity: 'day' }
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* 纯聚合:时间过滤 → 工作区过滤 → 按小时/日/月分桶(空桶补 0)→ 峰谷与金额拆分。
|
|
1635
|
+
* 刻意自包含(只读 input,不引用闭包变量),所以能直接 String() 进 Web Worker。
|
|
1636
|
+
* @param input {records, from, to, wid, granularity: 'hour'|'day'|'month', prices, holidays, windows}
|
|
1637
|
+
*/
|
|
1638
|
+
function computeConsumptionStats(input) {
|
|
1639
|
+
const records = input.records || []
|
|
1640
|
+
const prices = input.prices || {}
|
|
1641
|
+
const holidays = input.holidays || {}
|
|
1642
|
+
const windows = input.windows || []
|
|
1643
|
+
const from = typeof input.from === 'number' ? input.from : 0
|
|
1644
|
+
const to = typeof input.to === 'number' ? input.to : 0
|
|
1645
|
+
const wid = typeof input.wid === 'string' ? input.wid : ''
|
|
1646
|
+
const granularity = input.granularity === 'month' ? 'month' : (input.granularity === 'hour' ? 'hour' : 'day')
|
|
1647
|
+
// noFill:只要合计不要时间轴(设置页顶部的“累计”卡片用),跳过分桶补齐。
|
|
1648
|
+
const noFill = input.noFill === true
|
|
1649
|
+
const OFF = 8 * 3600 * 1000
|
|
1650
|
+
|
|
1651
|
+
function n(v) { return typeof v === 'number' && isFinite(v) && v > 0 ? v : 0 }
|
|
1652
|
+
function p2(v) { return v < 10 ? '0' + String(v) : String(v) }
|
|
1653
|
+
function clock(ms) {
|
|
1654
|
+
const t = new Date(ms + OFF)
|
|
1655
|
+
return {
|
|
1656
|
+
y: t.getUTCFullYear(),
|
|
1657
|
+
m: t.getUTCMonth() + 1,
|
|
1658
|
+
d: t.getUTCDate(),
|
|
1659
|
+
h: t.getUTCHours(),
|
|
1660
|
+
dow: t.getUTCDay(),
|
|
1661
|
+
minutes: t.getUTCHours() * 60 + t.getUTCMinutes(),
|
|
1662
|
+
mmdd: p2(t.getUTCMonth() + 1) + '-' + p2(t.getUTCDate()),
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function isPeak(ms) {
|
|
1666
|
+
const c = clock(ms)
|
|
1667
|
+
if (c.dow === 0 || c.dow === 6) return false
|
|
1668
|
+
if (holidays[c.mmdd]) return false
|
|
1669
|
+
for (let i = 0; i < windows.length; i += 1) {
|
|
1670
|
+
if (c.minutes >= windows[i][0] && c.minutes < windows[i][1]) return true
|
|
1671
|
+
}
|
|
1672
|
+
return false
|
|
1673
|
+
}
|
|
1674
|
+
function priceOf(model) {
|
|
1675
|
+
const id = typeof model === 'string' ? model : ''
|
|
1676
|
+
if (prices[id]) return prices[id]
|
|
1677
|
+
if (id.toLowerCase().indexOf('pro') >= 0) return prices['deepseek-v4-pro'] || prices['deepseek-flash']
|
|
1678
|
+
return prices['deepseek-flash']
|
|
1679
|
+
}
|
|
1680
|
+
function keyOf(ms) {
|
|
1681
|
+
const c = clock(ms)
|
|
1682
|
+
if (granularity === 'month') return c.y + '-' + p2(c.m)
|
|
1683
|
+
if (granularity === 'hour') return c.y + '-' + p2(c.m) + '-' + p2(c.d) + ' ' + p2(c.h)
|
|
1684
|
+
return c.y + '-' + p2(c.m) + '-' + p2(c.d)
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const buckets = {}
|
|
1688
|
+
const totals = { tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, hit: 0, miss: 0, out: 0 }
|
|
1689
|
+
let count = 0
|
|
1690
|
+
let unknownWorkspace = false
|
|
1691
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
1692
|
+
const r = records[i]
|
|
1693
|
+
if (!r) continue
|
|
1694
|
+
const t = typeof r.time === 'number' ? r.time : 0
|
|
1695
|
+
if (!(t >= from && t <= to)) continue
|
|
1696
|
+
const rowWid = typeof r.wid === 'string' ? r.wid : ''
|
|
1697
|
+
if (wid) {
|
|
1698
|
+
if (wid === '__none__') { if (rowWid) continue; unknownWorkspace = true } else if (rowWid !== wid) continue
|
|
1699
|
+
}
|
|
1700
|
+
const hit = n(r.hit)
|
|
1701
|
+
const miss = n(r.miss)
|
|
1702
|
+
const produced = n(r.out)
|
|
1703
|
+
const tokens = hit + miss + produced
|
|
1704
|
+
if (tokens === 0) continue
|
|
1705
|
+
const peak = isPeak(t)
|
|
1706
|
+
const table = priceOf(r.model)
|
|
1707
|
+
const k = peak ? 2 : 1
|
|
1708
|
+
const cost = ((hit / 1e6) * table.hit + (miss / 1e6) * table.miss + (produced / 1e6) * table.out) * k
|
|
1709
|
+
const key = keyOf(t)
|
|
1710
|
+
let b = buckets[key]
|
|
1711
|
+
if (!b) {
|
|
1712
|
+
b = { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
|
|
1713
|
+
buckets[key] = b
|
|
1714
|
+
}
|
|
1715
|
+
b.tokens += tokens
|
|
1716
|
+
b.cost += cost
|
|
1717
|
+
b.records += 1
|
|
1718
|
+
totals.tokens += tokens
|
|
1719
|
+
totals.cost += cost
|
|
1720
|
+
totals.hit += hit
|
|
1721
|
+
totals.miss += miss
|
|
1722
|
+
totals.out += produced
|
|
1723
|
+
if (peak) {
|
|
1724
|
+
b.peakTokens += tokens
|
|
1725
|
+
b.peakCost += cost
|
|
1726
|
+
totals.peakTokens += tokens
|
|
1727
|
+
totals.peakCost += cost
|
|
1728
|
+
} else {
|
|
1729
|
+
b.valleyTokens += tokens
|
|
1730
|
+
b.valleyCost += cost
|
|
1731
|
+
totals.valleyTokens += tokens
|
|
1732
|
+
totals.valleyCost += cost
|
|
1733
|
+
}
|
|
1734
|
+
const model = typeof r.model === 'string' && r.model ? r.model : 'unknown'
|
|
1735
|
+
if (b.models.indexOf(model) < 0) b.models.push(model)
|
|
1736
|
+
count += 1
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const points = []
|
|
1740
|
+
function push(key, ms) {
|
|
1741
|
+
const b = buckets[key]
|
|
1742
|
+
const point = b || { key: key, tokens: 0, cost: 0, peakTokens: 0, peakCost: 0, valleyTokens: 0, valleyCost: 0, models: [], records: 0 }
|
|
1743
|
+
if (granularity === 'month') point.label = key
|
|
1744
|
+
else if (granularity === 'hour') point.label = key.slice(11) + ':00'
|
|
1745
|
+
else point.label = key.slice(5)
|
|
1746
|
+
point.stamp = ms
|
|
1747
|
+
points.push(point)
|
|
1748
|
+
}
|
|
1749
|
+
if (noFill) {
|
|
1750
|
+
// 合计已经在记录循环里累加完,这里不再铺时间轴。
|
|
1751
|
+
} else if (granularity === 'month') {
|
|
1752
|
+
const a = clock(from)
|
|
1753
|
+
const b = clock(to)
|
|
1754
|
+
let y = a.y
|
|
1755
|
+
let m = a.m
|
|
1756
|
+
let guard = 0
|
|
1757
|
+
while ((y < b.y || (y === b.y && m <= b.m)) && guard < 400) {
|
|
1758
|
+
push(y + '-' + p2(m), Date.UTC(y, m - 1, 1) - OFF)
|
|
1759
|
+
m += 1
|
|
1760
|
+
if (m > 12) {
|
|
1761
|
+
m = 1
|
|
1762
|
+
y += 1
|
|
1763
|
+
}
|
|
1764
|
+
guard += 1
|
|
1765
|
+
}
|
|
1766
|
+
} else if (granularity === 'hour') {
|
|
1767
|
+
let guard = 0
|
|
1768
|
+
for (let t = from; t <= to && guard < 48; t += 3600000) {
|
|
1769
|
+
push(keyOf(t), t)
|
|
1770
|
+
guard += 1
|
|
1771
|
+
}
|
|
1772
|
+
} else {
|
|
1773
|
+
let guard = 0
|
|
1774
|
+
for (let t = from; t <= to && guard < 800; t += 86400000) {
|
|
1775
|
+
push(keyOf(t), t)
|
|
1776
|
+
guard += 1
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
return {
|
|
1781
|
+
points: points,
|
|
1782
|
+
count: count,
|
|
1783
|
+
totalTokens: totals.tokens,
|
|
1784
|
+
totalCost: totals.cost,
|
|
1785
|
+
totalHit: totals.hit,
|
|
1786
|
+
totalMiss: totals.miss,
|
|
1787
|
+
totalOut: totals.out,
|
|
1788
|
+
peakTokens: totals.peakTokens,
|
|
1789
|
+
peakCost: totals.peakCost,
|
|
1790
|
+
valleyTokens: totals.valleyTokens,
|
|
1791
|
+
valleyCost: totals.valleyCost,
|
|
1792
|
+
granularity: granularity,
|
|
1793
|
+
from: from,
|
|
1794
|
+
to: to,
|
|
1795
|
+
hasUnknownWorkspace: unknownWorkspace,
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* 记录多时在 Web Worker 里聚合,避免阻塞 UI;Worker 不可用(CSP/浏览器限制)时退回主线程。
|
|
1801
|
+
* @param input computeConsumptionStats 的入参。
|
|
1802
|
+
*/
|
|
1803
|
+
function aggregateAsync(input) {
|
|
1804
|
+
const many = (input.records || []).length > WORKER_THRESHOLD
|
|
1805
|
+
const canWorker = typeof Worker === 'function' && typeof Blob === 'function'
|
|
1806
|
+
&& typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
|
|
1807
|
+
if (many && canWorker) {
|
|
1808
|
+
return new Promise(function (resolve, reject) {
|
|
1809
|
+
let url = ''
|
|
1810
|
+
let worker = null
|
|
1811
|
+
try {
|
|
1812
|
+
const src = 'self.onmessage=function(e){try{self.postMessage((' + String(computeConsumptionStats) + ')(e.data))}catch(err){self.postMessage({workerError:String(err&&err.message||err)})}}'
|
|
1813
|
+
url = URL.createObjectURL(new Blob([src], { type: 'application/javascript' }))
|
|
1814
|
+
worker = new Worker(url)
|
|
1815
|
+
} catch (e) {
|
|
1816
|
+
reject(e)
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
function cleanup() {
|
|
1820
|
+
try {
|
|
1821
|
+
worker.terminate()
|
|
1822
|
+
} catch (e) { /* 已结束 */ }
|
|
1823
|
+
try {
|
|
1824
|
+
URL.revokeObjectURL(url)
|
|
1825
|
+
} catch (e) { /* 已释放 */ }
|
|
1826
|
+
}
|
|
1827
|
+
worker.onmessage = function (event) {
|
|
1828
|
+
cleanup()
|
|
1829
|
+
const payload = event && event.data
|
|
1830
|
+
if (!payload || payload.workerError) {
|
|
1831
|
+
reject(new Error(payload && payload.workerError ? payload.workerError : 'worker failed'))
|
|
1832
|
+
return
|
|
1833
|
+
}
|
|
1834
|
+
payload.via = 'worker'
|
|
1835
|
+
resolve(payload)
|
|
1836
|
+
}
|
|
1837
|
+
worker.onerror = function () {
|
|
1838
|
+
cleanup()
|
|
1839
|
+
reject(new Error('worker failed'))
|
|
1840
|
+
}
|
|
1841
|
+
worker.postMessage(input)
|
|
1842
|
+
})
|
|
1843
|
+
}
|
|
1844
|
+
return Promise.resolve().then(function () {
|
|
1845
|
+
const out = computeConsumptionStats(input)
|
|
1846
|
+
out.via = 'main'
|
|
1847
|
+
return out
|
|
1848
|
+
})
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/* ---- history chart: dual axis, cost by default, tokens toggle ---- */
|
|
1852
|
+
const HIST_W = 760
|
|
1853
|
+
const HIST_H = 280
|
|
1854
|
+
const HIST_PAD = { l: 56, r: 58, t: 16, b: 26 }
|
|
1855
|
+
|
|
1856
|
+
function HistoryChart(props) {
|
|
1857
|
+
const points = props.points || []
|
|
1858
|
+
const showTokens = props.showTokens === true
|
|
1859
|
+
const holder = React.useState(-1)
|
|
1860
|
+
const hover = holder[0]
|
|
1861
|
+
const setHover = holder[1]
|
|
1862
|
+
const innerW = HIST_W - HIST_PAD.l - HIST_PAD.r
|
|
1863
|
+
const innerH = HIST_H - HIST_PAD.t - HIST_PAD.b
|
|
1864
|
+
let maxToken = 1
|
|
1865
|
+
let maxCost = 0
|
|
1866
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
1867
|
+
if (points[i].tokens > maxToken) maxToken = points[i].tokens
|
|
1868
|
+
if (points[i].cost > maxCost) maxCost = points[i].cost
|
|
1869
|
+
}
|
|
1870
|
+
const topToken = niceMax(maxToken)
|
|
1871
|
+
const topCost = niceMax(maxCost > 0 ? maxCost : 1e-6)
|
|
1872
|
+
const n = points.length
|
|
1873
|
+
function xAt(i) {
|
|
1874
|
+
return n <= 1 ? HIST_PAD.l + innerW / 2 : HIST_PAD.l + (i / (n - 1)) * innerW
|
|
1875
|
+
}
|
|
1876
|
+
function yToken(v) { return HIST_PAD.t + innerH - (v / topToken) * innerH }
|
|
1877
|
+
function yCost(v) { return HIST_PAD.t + innerH - (v / topCost) * innerH }
|
|
1878
|
+
|
|
1879
|
+
const costPts = []
|
|
1880
|
+
const tokenPts = []
|
|
1881
|
+
for (let i = 0; i < n; i += 1) {
|
|
1882
|
+
costPts.push(xAt(i).toFixed(2) + ',' + yCost(points[i].cost).toFixed(2))
|
|
1883
|
+
tokenPts.push(xAt(i).toFixed(2) + ',' + yToken(points[i].tokens).toFixed(2))
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const grid = []
|
|
1887
|
+
const rightLabels = []
|
|
1888
|
+
const leftLabels = []
|
|
1889
|
+
for (let g = 0; g <= 4; g += 1) {
|
|
1890
|
+
const ratio = g / 4
|
|
1891
|
+
const y = HIST_PAD.t + innerH * ratio
|
|
1892
|
+
grid.push(h('line', { key: 'hg' + g, x1: HIST_PAD.l, x2: HIST_PAD.l + innerW, y1: y, y2: y, className: 'dcb-grid-line' }))
|
|
1893
|
+
rightLabels.push(h('text', { key: 'hr' + g, x: HIST_PAD.l + innerW + 8, y: y + 3.5, textAnchor: 'start', className: 'dcb-axis-label' }, '¥' + fmtYuan(topCost * (1 - ratio))))
|
|
1894
|
+
if (showTokens) {
|
|
1895
|
+
leftLabels.push(h('text', { key: 'hl' + g, x: HIST_PAD.l - 8, y: y + 3.5, textAnchor: 'end', className: 'dcb-axis-label' }, fmtTokens(topToken * (1 - ratio))))
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const step = Math.max(1, Math.ceil(n / 10))
|
|
1900
|
+
const xLabels = []
|
|
1901
|
+
for (let i = 0; i < n; i += 1) {
|
|
1902
|
+
if (i % step !== 0 && i !== n - 1) continue
|
|
1903
|
+
xLabels.push(h('text', { key: 'hx' + i, x: xAt(i), y: HIST_H - 8, textAnchor: 'middle', className: 'dcb-axis-label' }, points[i].label))
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const dots = []
|
|
1907
|
+
for (let i = 0; i < n; i += 1) {
|
|
1908
|
+
const active = hover === i
|
|
1909
|
+
if (points[i].records > 0) {
|
|
1910
|
+
dots.push(h('circle', { key: 'hc' + i, cx: xAt(i), cy: yCost(points[i].cost), r: active ? 4 : 2.2, className: 'dcb-dot dcb-dot-cost' }))
|
|
1911
|
+
}
|
|
1912
|
+
if (showTokens && points[i].records > 0) {
|
|
1913
|
+
dots.push(h('circle', { key: 'ht' + i, cx: xAt(i), cy: yToken(points[i].tokens), r: active ? 4 : 2.2, className: 'dcb-dot' }))
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const hovered = hover >= 0 && hover < n ? points[hover] : null
|
|
1918
|
+
const crosshair = hovered
|
|
1919
|
+
? h('line', { x1: xAt(hover), x2: xAt(hover), y1: HIST_PAD.t, y2: HIST_PAD.t + innerH, className: 'dcb-hover-line' })
|
|
1920
|
+
: null
|
|
1921
|
+
|
|
1922
|
+
function onMove(event) {
|
|
1923
|
+
try {
|
|
1924
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
1925
|
+
if (!rect || rect.width <= 0) return
|
|
1926
|
+
const px = ((event.clientX - rect.left) / rect.width) * HIST_W
|
|
1927
|
+
const within = Math.min(1, Math.max(0, (px - HIST_PAD.l) / innerW))
|
|
1928
|
+
const idx = n <= 1 ? 0 : Math.round(within * (n - 1))
|
|
1929
|
+
if (idx !== hover) setHover(idx)
|
|
1930
|
+
} catch (e) { /* 忽略异常坐标 */ }
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
const tooltip = hovered
|
|
1934
|
+
? h('div', { className: 'dcb-tooltip', style: { left: String((xAt(hover) / HIST_W) * 100) + '%' } },
|
|
1935
|
+
h('div', { className: 'dcb-tooltip-title' },
|
|
1936
|
+
hovered.label,
|
|
1937
|
+
h('span', { className: 'dcb-tooltip-model' }, tt('{n} 轮', { n: hovered.records }))),
|
|
1938
|
+
h('div', { className: 'dcb-tooltip-row dcb-tooltip-total' }, h('span', null, tt('当日合计')), h('b', null, '≈¥' + fmtYuan(hovered.cost))),
|
|
1939
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, 'Token'), h('b', null, fmtTokens(hovered.tokens))),
|
|
1940
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('峰时')), h('b', null, '≈¥' + fmtYuan(hovered.peakCost) + ' / ' + fmtTokens(hovered.peakTokens))),
|
|
1941
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('谷时')), h('b', null, '≈¥' + fmtYuan(hovered.valleyCost) + ' / ' + fmtTokens(hovered.valleyTokens))),
|
|
1942
|
+
h('div', { className: 'dcb-tooltip-row' }, h('span', null, tt('模型')), h('b', null, hovered.models.length ? hovered.models.join(', ') : '—')))
|
|
1943
|
+
: null
|
|
1944
|
+
|
|
1945
|
+
return h('div', { className: 'dcb-chart-body' },
|
|
1946
|
+
h('svg', {
|
|
1947
|
+
className: 'dcb-hist-svg',
|
|
1948
|
+
viewBox: '0 0 ' + HIST_W + ' ' + HIST_H,
|
|
1949
|
+
preserveAspectRatio: 'none',
|
|
1950
|
+
onMouseMove: onMove,
|
|
1951
|
+
onMouseLeave: function () { setHover(-1) },
|
|
1952
|
+
},
|
|
1953
|
+
h('g', null, grid),
|
|
1954
|
+
crosshair,
|
|
1955
|
+
showTokens ? h('polyline', { className: 'dcb-line', points: tokenPts.join(' ') }) : null,
|
|
1956
|
+
h('polyline', { className: 'dcb-line dcb-line-cost', points: costPts.join(' ') }),
|
|
1957
|
+
h('g', null, dots),
|
|
1958
|
+
h('g', null, leftLabels),
|
|
1959
|
+
h('g', null, rightLabels),
|
|
1960
|
+
h('g', null, xLabels)),
|
|
1961
|
+
tooltip)
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/** 设置页「消耗」整页:三个筛选 + 折线 + 汇总。筛选状态不持久化,每次打开回到“近一周 / 全部工作区”。 */
|
|
1965
|
+
function ConsumptionSettings(props) {
|
|
1966
|
+
const s0 = React.useState('week')
|
|
1967
|
+
const rangeKey = s0[0]
|
|
1968
|
+
const setRangeKey = s0[1]
|
|
1969
|
+
const s1 = React.useState('')
|
|
1970
|
+
const wid = s1[0]
|
|
1971
|
+
const setWid = s1[1]
|
|
1972
|
+
const s2 = React.useState('')
|
|
1973
|
+
const calFrom = s2[0]
|
|
1974
|
+
const setCalFrom = s2[1]
|
|
1975
|
+
const s3 = React.useState('')
|
|
1976
|
+
const calTo = s3[0]
|
|
1977
|
+
const setCalTo = s3[1]
|
|
1978
|
+
const s4 = React.useState(null)
|
|
1979
|
+
const records = s4[0]
|
|
1980
|
+
const setRecords = s4[1]
|
|
1981
|
+
const s5 = React.useState(null)
|
|
1982
|
+
const stats = s5[0]
|
|
1983
|
+
const setStats = s5[1]
|
|
1984
|
+
const s6 = React.useState(0)
|
|
1985
|
+
const reloadTick = s6[0]
|
|
1986
|
+
const setReloadTick = s6[1]
|
|
1987
|
+
const s7 = React.useState(false)
|
|
1988
|
+
const showTokens = s7[0]
|
|
1989
|
+
const setShowTokens = s7[1]
|
|
1990
|
+
const s8 = React.useState(null)
|
|
1991
|
+
const allStats = s8[0]
|
|
1992
|
+
const setAllStats = s8[1]
|
|
1993
|
+
const s9 = React.useState(0)
|
|
1994
|
+
const setStoreTick = s9[1]
|
|
1995
|
+
|
|
1996
|
+
// 账户/余额复用徽章共用的 store(同一份 60s 缓存,不额外发请求)。
|
|
1997
|
+
React.useEffect(function () {
|
|
1998
|
+
return store.subscribe(function () { setStoreTick(function (v) { return v + 1 }) })
|
|
1999
|
+
}, [])
|
|
2000
|
+
React.useEffect(function () {
|
|
2001
|
+
store.ensureAccount()
|
|
2002
|
+
store.ensureBalance(false)
|
|
2003
|
+
return everyTick(60000, function () { store.ensureBalance(false) })
|
|
2004
|
+
}, [])
|
|
2005
|
+
|
|
2006
|
+
// 工作区签名:id + 标题 + 归属会话(WorkspaceView.sessionIds 即归属账本)。
|
|
2007
|
+
const wsSig = props && typeof props.useWorkspaces === 'function'
|
|
2008
|
+
? props.useWorkspaces(function (w) {
|
|
2009
|
+
const items = w && w.items ? w.items : []
|
|
2010
|
+
const out = []
|
|
2011
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
2012
|
+
const it = items[i]
|
|
2013
|
+
if (!it) continue
|
|
2014
|
+
out.push(String(it.workspaceId || '') + '\u0001' + String(it.title || '') + '\u0001' + (it.sessionIds || []).join(','))
|
|
2015
|
+
}
|
|
2016
|
+
return out.join('\u0002')
|
|
2017
|
+
})
|
|
2018
|
+
: ''
|
|
2019
|
+
|
|
2020
|
+
React.useEffect(function () {
|
|
2021
|
+
setRecords(readAllRecords())
|
|
2022
|
+
}, [reloadTick])
|
|
2023
|
+
|
|
2024
|
+
React.useEffect(function () {
|
|
2025
|
+
if (!records) return undefined
|
|
2026
|
+
const now = Date.now()
|
|
2027
|
+
const range = resolveRange(rangeKey, calFrom, calTo, now)
|
|
2028
|
+
const index = buildWorkspaceIndex(wsSig)
|
|
2029
|
+
const rows = []
|
|
2030
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
2031
|
+
const r = records[i]
|
|
2032
|
+
rows.push({ time: r.time, hit: r.hit, miss: r.miss, out: r.out, model: r.model, wid: index.bySession[r.sid] || '' })
|
|
2033
|
+
}
|
|
2034
|
+
const input = {
|
|
2035
|
+
records: rows,
|
|
2036
|
+
from: range.from,
|
|
2037
|
+
to: range.to,
|
|
2038
|
+
wid: wid,
|
|
2039
|
+
granularity: range.granularity,
|
|
2040
|
+
prices: PRICES,
|
|
2041
|
+
holidays: CN_FIXED_HOLIDAYS,
|
|
2042
|
+
windows: PEAK_WINDOWS,
|
|
2043
|
+
}
|
|
2044
|
+
const seq = ++aggSeq
|
|
2045
|
+
aggregateAsync(input).then(function (result) {
|
|
2046
|
+
if (seq === aggSeq) setStats(result)
|
|
2047
|
+
}).catch(function () {
|
|
2048
|
+
if (seq !== aggSeq) return
|
|
2049
|
+
const fallback = computeConsumptionStats(input)
|
|
2050
|
+
fallback.via = 'main'
|
|
2051
|
+
setStats(fallback)
|
|
2052
|
+
})
|
|
2053
|
+
return undefined
|
|
2054
|
+
}, [records, rangeKey, wid, calFrom, calTo, wsSig])
|
|
2055
|
+
|
|
2056
|
+
// 顶部“累计”卡片:全部历史合计(不受筛选影响),只算合计不铺时间轴。
|
|
2057
|
+
React.useEffect(function () {
|
|
2058
|
+
if (!records) return undefined
|
|
2059
|
+
const index = buildWorkspaceIndex(wsSig)
|
|
2060
|
+
const rows = []
|
|
2061
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
2062
|
+
const r = records[i]
|
|
2063
|
+
rows.push({ time: r.time, hit: r.hit, miss: r.miss, out: r.out, model: r.model, wid: index.bySession[r.sid] || '' })
|
|
2064
|
+
}
|
|
2065
|
+
const input = {
|
|
2066
|
+
records: rows,
|
|
2067
|
+
from: 0,
|
|
2068
|
+
to: Date.now(),
|
|
2069
|
+
wid: '',
|
|
2070
|
+
granularity: 'day',
|
|
2071
|
+
noFill: true,
|
|
2072
|
+
prices: PRICES,
|
|
2073
|
+
holidays: CN_FIXED_HOLIDAYS,
|
|
2074
|
+
windows: PEAK_WINDOWS,
|
|
2075
|
+
}
|
|
2076
|
+
const seq = ++aggSeqAll
|
|
2077
|
+
aggregateAsync(input).then(function (result) {
|
|
2078
|
+
if (seq === aggSeqAll) setAllStats(result)
|
|
2079
|
+
}).catch(function () {
|
|
2080
|
+
if (seq !== aggSeqAll) return
|
|
2081
|
+
setAllStats(computeConsumptionStats(input))
|
|
2082
|
+
})
|
|
2083
|
+
return undefined
|
|
2084
|
+
}, [records, wsSig])
|
|
2085
|
+
|
|
2086
|
+
const st = store.state
|
|
2087
|
+
const accountModel = st.account && typeof st.account.model === 'string' && st.account.model ? st.account.model : DEFAULT_MODEL
|
|
2088
|
+
const accountPrice = priceFor(accountModel)
|
|
2089
|
+
const nowPeak = isPeakAt(Date.now())
|
|
2090
|
+
const balance = st.balance
|
|
2091
|
+
const balanceLoading = st.balanceState === 'loading'
|
|
2092
|
+
const balanceErr = balanceErrorText()
|
|
2093
|
+
let balanceValue = '—'
|
|
2094
|
+
let balanceHint = balanceErr || tt('打开设置页时请求一次')
|
|
2095
|
+
let balanceClass = 'dcb-card-value'
|
|
2096
|
+
if (balanceLoading && !balance) {
|
|
2097
|
+
balanceValue = tt('读取中…')
|
|
2098
|
+
balanceHint = 'GET /user/balance'
|
|
2099
|
+
} else if (balance && balance.ok === false) {
|
|
2100
|
+
balanceValue = tt('获取失败')
|
|
2101
|
+
balanceHint = balanceErr || tt('余额接口不可用')
|
|
2102
|
+
balanceClass = 'dcb-card-value dcb-bad'
|
|
2103
|
+
} else if (balance && balance.available === false) {
|
|
2104
|
+
balanceValue = tt('余额不足')
|
|
2105
|
+
balanceHint = tt('请前往 DeepSeek 开放平台充值')
|
|
2106
|
+
balanceClass = 'dcb-card-value dcb-bad'
|
|
2107
|
+
} else if (balance) {
|
|
2108
|
+
const symbol = balance.currency && balance.currency !== 'CNY' ? String(balance.currency) + ' ' : '¥'
|
|
2109
|
+
balanceValue = symbol + fmtYuan(balance.total)
|
|
2110
|
+
const parts = []
|
|
2111
|
+
if (typeof balance.granted === 'number') parts.push(tt('赠送 ¥{v}', { v: fmtYuan(balance.granted) }))
|
|
2112
|
+
if (typeof balance.toppedUp === 'number') parts.push(tt('充值 ¥{v}', { v: fmtYuan(balance.toppedUp) }))
|
|
2113
|
+
balanceHint = parts.length ? parts.join(' · ') : tt('DeepSeek 账户余额')
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
const index = buildWorkspaceIndex(wsSig)
|
|
2117
|
+
const points = stats ? stats.points : []
|
|
2118
|
+
const hasData = !!stats && stats.count > 0
|
|
2119
|
+
const rangeOptions = [
|
|
2120
|
+
{ id: 'today', label: tt('今天') },
|
|
2121
|
+
{ id: 'week', label: tt('近一周') },
|
|
2122
|
+
{ id: 'month', label: tt('近一月') },
|
|
2123
|
+
{ id: 'year', label: tt('近一年') },
|
|
2124
|
+
]
|
|
2125
|
+
|
|
2126
|
+
return h('div', { className: 'dcb-set' },
|
|
2127
|
+
h('div', { className: 'dcb-set-head' },
|
|
2128
|
+
h('div', { className: 'dcb-set-title' }, tt('消耗统计')),
|
|
2129
|
+
h('div', { className: 'dcb-set-note' }, tt('本地估算,非官方账单'))),
|
|
2130
|
+
// 顶部四卡:累计(全部历史,不受下面筛选影响)+ 当前时段 + 账户余额
|
|
2131
|
+
h('div', { className: 'dcb-cards dcb-cards-set' },
|
|
2132
|
+
h(Card, {
|
|
2133
|
+
label: tt('累计 Token'),
|
|
2134
|
+
value: allStats ? fmtTokens(allStats.totalTokens) : '—',
|
|
2135
|
+
hint: allStats
|
|
2136
|
+
? tt('命中 {hit} · 未命中 {miss} · 输出 {out}', { hit: fmtTokens(allStats.totalHit), miss: fmtTokens(allStats.totalMiss), out: fmtTokens(allStats.totalOut) })
|
|
2137
|
+
: tt('全部历史 · 含所有工作区'),
|
|
2138
|
+
}),
|
|
2139
|
+
h(Card, {
|
|
2140
|
+
label: tt('估算金额'),
|
|
2141
|
+
value: allStats ? '≈¥' + fmtYuan(allStats.totalCost) : '—',
|
|
2142
|
+
hint: (accountPrice.known ? accountPrice.id : tt('{id}(按 flash 估算)', { id: accountPrice.id })) + tt(' · 全部历史逐轮计价'),
|
|
2143
|
+
}),
|
|
2144
|
+
h(Card, {
|
|
2145
|
+
label: tt('当前时段'),
|
|
2146
|
+
value: tt(nowPeak ? '高峰' : '空闲'),
|
|
2147
|
+
hint: tt('北京时间 工作日 09:00-12:00 / 14:00-18:00'),
|
|
2148
|
+
}),
|
|
2149
|
+
h('div', { className: 'dcb-card' },
|
|
2150
|
+
h('div', { className: 'dcb-card-label' }, tt('账户余额')),
|
|
2151
|
+
h('div', { className: balanceClass }, balanceValue),
|
|
2152
|
+
h('div', { className: 'dcb-card-hint' }, balanceHint))),
|
|
2153
|
+
h('div', { className: 'dcb-set-controls' },
|
|
2154
|
+
h('div', { className: 'dcb-field' },
|
|
2155
|
+
h('label', { className: 'dcb-field-label', htmlFor: 'dcb-range-select' }, tt('时间范围')),
|
|
2156
|
+
h('select', {
|
|
2157
|
+
id: 'dcb-range-select',
|
|
2158
|
+
className: 'dcb-select',
|
|
2159
|
+
value: calFrom || calTo ? 'custom' : rangeKey,
|
|
2160
|
+
onChange: function (event) {
|
|
2161
|
+
setRangeKey(event.target.value)
|
|
2162
|
+
setCalFrom('')
|
|
2163
|
+
setCalTo('')
|
|
2164
|
+
},
|
|
2165
|
+
},
|
|
2166
|
+
rangeOptions.map(function (opt) { return h('option', { key: opt.id, value: opt.id }, opt.label) }),
|
|
2167
|
+
(calFrom || calTo) ? h('option', { key: 'custom', value: 'custom' }, tt('自定义区间')) : null)),
|
|
2168
|
+
h('div', { className: 'dcb-field' },
|
|
2169
|
+
h('label', { className: 'dcb-field-label', htmlFor: 'dcb-workspace-select' }, tt('工作区')),
|
|
2170
|
+
h('select', {
|
|
2171
|
+
id: 'dcb-workspace-select',
|
|
2172
|
+
className: 'dcb-select',
|
|
2173
|
+
value: wid,
|
|
2174
|
+
onChange: function (event) { setWid(event.target.value) },
|
|
2175
|
+
},
|
|
2176
|
+
h('option', { key: 'all', value: '' }, tt('全部工作区')),
|
|
2177
|
+
index.list.map(function (it) { return h('option', { key: it.id, value: it.id }, it.title) }),
|
|
2178
|
+
h('option', { key: 'none', value: UNKNOWN_WORKSPACE }, tt('未归属工作区')))),
|
|
2179
|
+
h('div', { className: 'dcb-field' },
|
|
2180
|
+
h('span', { className: 'dcb-field-label' }, tt('日历选择(覆盖时间范围)')),
|
|
2181
|
+
h('div', { className: 'dcb-range' },
|
|
2182
|
+
h('input', {
|
|
2183
|
+
type: 'date',
|
|
2184
|
+
className: 'dcb-date',
|
|
2185
|
+
value: calFrom,
|
|
2186
|
+
onChange: function (event) { setCalFrom(event.target.value) },
|
|
2187
|
+
}),
|
|
2188
|
+
h('span', { className: 'dcb-range-sep' }, '~'),
|
|
2189
|
+
h('input', {
|
|
2190
|
+
type: 'date',
|
|
2191
|
+
className: 'dcb-date',
|
|
2192
|
+
value: calTo,
|
|
2193
|
+
onChange: function (event) { setCalTo(event.target.value) },
|
|
2194
|
+
}),
|
|
2195
|
+
(calFrom || calTo)
|
|
2196
|
+
? h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setCalFrom(''); setCalTo('') } }, tt('清除'))
|
|
2197
|
+
: null)),
|
|
2198
|
+
h('div', { className: 'dcb-field' },
|
|
2199
|
+
h('span', { className: 'dcb-field-label' }, tt('数据')),
|
|
2200
|
+
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { setReloadTick(function (v) { return v + 1 }) } }, tt('刷新')))),
|
|
2201
|
+
h('div', { className: 'dcb-set-body' },
|
|
2202
|
+
h('div', { className: 'dcb-hist-legend' },
|
|
2203
|
+
h('span', { className: 'dcb-tog dcb-tog-cost is-on' }, h('i', null), tt('估算金额(右轴)')),
|
|
2204
|
+
h('button', {
|
|
2205
|
+
type: 'button',
|
|
2206
|
+
className: showTokens ? 'dcb-tog is-on' : 'dcb-tog',
|
|
2207
|
+
onClick: function () { setShowTokens(function (v) { return v === true ? false : true }) },
|
|
2208
|
+
}, h('i', null), tt('Token(左轴,默认隐藏)'))),
|
|
2209
|
+
hasData
|
|
2210
|
+
? h(HistoryChart, { points: points, showTokens: showTokens })
|
|
2211
|
+
: h('div', { className: 'dcb-hist-empty' }, tt('该范围内暂无消耗记录'))),
|
|
2212
|
+
hasData
|
|
2213
|
+
? h('div', { className: 'dcb-sum' },
|
|
2214
|
+
h('span', null, tt('总消耗 '), h('b', null, '≈¥' + fmtYuan(stats.totalCost))),
|
|
2215
|
+
h('span', null, tt('总 Token '), h('b', null, fmtTokens(stats.totalTokens))),
|
|
2216
|
+
h('span', null, tt('记录 '), h('b', null, String(stats.count)), tt(' 条')),
|
|
2217
|
+
h('span', null, tt('峰时 '), h('b', null, '≈¥' + fmtYuan(stats.peakCost))),
|
|
2218
|
+
h('span', null, tt('谷时 '), h('b', null, '≈¥' + fmtYuan(stats.valleyCost))),
|
|
2219
|
+
h('span', null, tt('粒度 '), h('b', null, tt(stats.granularity === 'month' ? '按月' : (stats.granularity === 'hour' ? '按小时' : '按日')))))
|
|
2220
|
+
: null,
|
|
2221
|
+
h('div', { className: 'dcb-hist-aside' },
|
|
2222
|
+
tt('数据源:本机逐轮记录(localStorage {prefix}<sessionId>:<turn>);按北京时间聚合(今天与单日区间逐小时,多日逐日,近一年逐月){worker}。金额按 DeepSeek 官方价估算(谷/峰 ×1/×2),仅供参考。', {
|
|
2223
|
+
prefix: LS_PREFIX,
|
|
2224
|
+
worker: stats && stats.via === 'worker' ? tt(';聚合已在 Web Worker 中完成') : '',
|
|
2225
|
+
})))
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/* ============ 与 dsh-plugin-personalization 联动:个性化页内新增「消耗弹窗」栏 ============ */
|
|
2229
|
+
// 取官方 UI 组件(与个性化页其他选项同款外观);取不到时退回普通元素。
|
|
2230
|
+
let dcbPrimitives = null
|
|
2231
|
+
try {
|
|
2232
|
+
dcbPrimitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
2233
|
+
} catch (e) {
|
|
2234
|
+
dcbPrimitives = null
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function ModalPill(props) {
|
|
2238
|
+
const Pill = dcbPrimitives && dcbPrimitives.Pill ? dcbPrimitives.Pill : null
|
|
2239
|
+
if (typeof Pill === 'function') {
|
|
2240
|
+
return h(Pill, { active: props.active, onClick: props.onClick }, props.children)
|
|
2241
|
+
}
|
|
2242
|
+
return h('button', {
|
|
2243
|
+
type: 'button',
|
|
2244
|
+
className: props.active ? 'dcb-tog is-on' : 'dcb-tog',
|
|
2245
|
+
onClick: props.onClick,
|
|
2246
|
+
}, props.children)
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/** 注入到「个性化」页里的「消耗弹窗」栏目:排版沿用 pp-block / pp-field / pp-modes。 */
|
|
2250
|
+
function ConsumptionModalSettings() {
|
|
2251
|
+
const tick = React.useState(0)
|
|
2252
|
+
const setTick = tick[1]
|
|
2253
|
+
const fileRef = React.useRef(null)
|
|
2254
|
+
React.useEffect(function () {
|
|
2255
|
+
return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
|
|
2256
|
+
}, [])
|
|
2257
|
+
// This block renders in its own React root outside the slot system, so it
|
|
2258
|
+
// subscribes to the locale face itself (slot outlets do that for us).
|
|
2259
|
+
React.useEffect(function () {
|
|
2260
|
+
return subscribeLocale(function () { setTick(function (v) { return v + 1 }) })
|
|
2261
|
+
}, [])
|
|
2262
|
+
const cfg = store.state.modalStyle
|
|
2263
|
+
|
|
2264
|
+
function patch(next) {
|
|
2265
|
+
store.setModalStyle(next)
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/** 清除图片:库里的图片、数据、URL 输入、文件选择框一起清(否则选同一个文件不会再触发 change)。 */
|
|
2269
|
+
function clearImage() {
|
|
2270
|
+
const input = fileRef.current
|
|
2271
|
+
if (input) {
|
|
2272
|
+
try {
|
|
2273
|
+
input.value = ''
|
|
2274
|
+
} catch (e) { /* ignore */ }
|
|
2275
|
+
}
|
|
2276
|
+
store.clearModalImage()
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/** 选图:Blob 直接进 IndexedDB,不做 base64、不设大小上限。 */
|
|
2280
|
+
function onPickFile(event) {
|
|
2281
|
+
const input = event && event.target ? event.target : null
|
|
2282
|
+
const file = input && input.files && input.files.length > 0 ? input.files[0] : null
|
|
2283
|
+
if (!file) return
|
|
2284
|
+
store.setModalImage(file).then(function () {
|
|
2285
|
+
const el = fileRef.current
|
|
2286
|
+
if (el) {
|
|
2287
|
+
try {
|
|
2288
|
+
el.value = ''
|
|
2289
|
+
} catch (e) { /* ignore */ }
|
|
2290
|
+
}
|
|
2291
|
+
})
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
const rows = []
|
|
2295
|
+
rows.push(h('div', { className: 'pp-field', key: 'mode' },
|
|
2296
|
+
h('div', { className: 'pp-fieldLabelWrap' },
|
|
2297
|
+
h('span', { className: 'pp-fieldLabel' }, tt('背景')),
|
|
2298
|
+
h('span', { className: 'pp-fieldHint' }, tt('点击对话里的 ⛽ 胶囊弹出的消耗面板'))),
|
|
2299
|
+
h('div', { className: 'pp-fieldControl' },
|
|
2300
|
+
h('div', { className: 'pp-modes', role: 'radiogroup', 'aria-label': tt('消耗弹窗背景') },
|
|
2301
|
+
MODAL_MODES.map(function (mode) {
|
|
2302
|
+
return h(ModalPill, {
|
|
2303
|
+
key: mode.value,
|
|
2304
|
+
active: cfg.mode === mode.value,
|
|
2305
|
+
onClick: function () { patch({ mode: mode.value }) },
|
|
2306
|
+
}, tt(mode.label))
|
|
2307
|
+
})))))
|
|
2308
|
+
|
|
2309
|
+
if (cfg.mode === 'solid') {
|
|
2310
|
+
rows.push(h('div', { className: 'pp-field', key: 'solid' },
|
|
2311
|
+
h('div', { className: 'pp-fieldLabelWrap' },
|
|
2312
|
+
h('span', { className: 'pp-fieldLabel' }, tt('纯色')),
|
|
2313
|
+
h('span', { className: 'pp-fieldHint' }, tt('弹窗底色;文字颜色会按明暗自动切换'))),
|
|
2314
|
+
h('div', { className: 'pp-fieldControl' },
|
|
2315
|
+
h('div', { className: 'pp-colorRow' },
|
|
2316
|
+
h('input', {
|
|
2317
|
+
type: 'color',
|
|
2318
|
+
className: 'pp-colorSwatch',
|
|
2319
|
+
value: cfg.color,
|
|
2320
|
+
onChange: function (event) { patch({ color: event.target.value }) },
|
|
2321
|
+
}),
|
|
2322
|
+
h('input', {
|
|
2323
|
+
type: 'text',
|
|
2324
|
+
className: 'dcb-date',
|
|
2325
|
+
style: { minWidth: '96px' },
|
|
2326
|
+
value: cfg.color,
|
|
2327
|
+
onChange: function (event) { patch({ color: event.target.value }) },
|
|
2328
|
+
})))))
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
if (cfg.mode === 'image') {
|
|
2332
|
+
// 只要「有 key」或「内存里已有 blob: 地址」都算已选:避免持久化失败时界面谎报未设置。
|
|
2333
|
+
const localImage = (typeof cfg.imageKey === 'string' && cfg.imageKey !== '')
|
|
2334
|
+
|| String(store.state.modalImageUrl).indexOf('blob:') === 0
|
|
2335
|
+
const urlImage = typeof cfg.image === 'string' && cfg.image !== ''
|
|
2336
|
+
const hasImage = localImage || urlImage
|
|
2337
|
+
const previewSrc = localImage ? store.state.modalImageUrl : cfg.image
|
|
2338
|
+
const status = store.state.modalImageStatus
|
|
2339
|
+
let statusText = ''
|
|
2340
|
+
if (status === 'saving') statusText = tt('正在保存…')
|
|
2341
|
+
else if (status === 'saved') statusText = tt('已保存到本机')
|
|
2342
|
+
else if (typeof status === 'string' && status.indexOf('error:') === 0) statusText = status
|
|
2343
|
+
else if (localImage && !store.state.modalImageUrl) statusText = tt('取回失败,请重新选择')
|
|
2344
|
+
const summary = localImage
|
|
2345
|
+
? (statusText ? tt('当前:本地图片({status})', { status: statusText }) : tt('当前:本地图片(已保存到本机)'))
|
|
2346
|
+
: (urlImage ? tt('当前:图片 URL') : tt('未设置图片(此时按默认背景显示)'))
|
|
2347
|
+
rows.push(h('div', { className: 'pp-field', key: 'image' },
|
|
2348
|
+
h('div', { className: 'pp-fieldLabelWrap' },
|
|
2349
|
+
h('span', { className: 'pp-fieldLabel' }, tt('图片')),
|
|
2350
|
+
h('span', { className: 'pp-fieldHint' }, summary + tt(';可选 URL 或本地图片,不限大小'))),
|
|
2351
|
+
h('div', { className: 'pp-fieldControl' },
|
|
2352
|
+
h('div', { className: 'pp-urlRow' },
|
|
2353
|
+
// 原生 file 输入隐藏起来:它在保存后必然显示"未选择文件",容易误判成没存上。
|
|
2354
|
+
h('input', {
|
|
2355
|
+
ref: fileRef,
|
|
2356
|
+
type: 'file',
|
|
2357
|
+
accept: 'image/*',
|
|
2358
|
+
style: { display: 'none' },
|
|
2359
|
+
onChange: onPickFile,
|
|
2360
|
+
}),
|
|
2361
|
+
h('button', {
|
|
2362
|
+
type: 'button',
|
|
2363
|
+
className: 'pp-blockReset',
|
|
2364
|
+
onClick: function () {
|
|
2365
|
+
const el = fileRef.current
|
|
2366
|
+
if (el && typeof el.click === 'function') el.click()
|
|
2367
|
+
},
|
|
2368
|
+
}, hasImage ? tt('更换图片…') : tt('选择图片…')),
|
|
2369
|
+
h('button', {
|
|
2370
|
+
type: 'button',
|
|
2371
|
+
className: 'pp-blockReset',
|
|
2372
|
+
disabled: !hasImage,
|
|
2373
|
+
onClick: clearImage,
|
|
2374
|
+
}, tt('清除')),
|
|
2375
|
+
previewSrc
|
|
2376
|
+
? h('img', { className: 'pp-thumb', src: previewSrc, alt: '' })
|
|
2377
|
+
: null,
|
|
2378
|
+
h('input', {
|
|
2379
|
+
type: 'text',
|
|
2380
|
+
className: 'dcb-date',
|
|
2381
|
+
style: { flex: '1 1 160px', minWidth: '0' },
|
|
2382
|
+
placeholder: localImage ? tt('已使用本地图片,输入 URL 可替换') : 'https://…',
|
|
2383
|
+
value: localImage ? '' : cfg.image,
|
|
2384
|
+
onChange: function (event) { patch({ image: event.target.value, imageKey: '' }) },
|
|
2385
|
+
})))))
|
|
2386
|
+
rows.push(h('div', { className: 'pp-field', key: 'opacity' },
|
|
2387
|
+
h('div', { className: 'pp-fieldLabelWrap' },
|
|
2388
|
+
h('span', { className: 'pp-fieldLabel' }, tt('不透明度')),
|
|
2389
|
+
h('span', { className: 'pp-fieldHint' }, tt('背景图片自身的透明度(0% 完全不可见,100% 完整显示)'))),
|
|
2390
|
+
h('div', { className: 'pp-fieldControl' },
|
|
2391
|
+
h('div', { className: 'pp-opacityRow' },
|
|
2392
|
+
h('input', {
|
|
2393
|
+
className: 'pp-range',
|
|
2394
|
+
type: 'range',
|
|
2395
|
+
min: '0',
|
|
2396
|
+
max: '100',
|
|
2397
|
+
step: '1',
|
|
2398
|
+
'aria-label': tt('不透明度'),
|
|
2399
|
+
value: String(typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity),
|
|
2400
|
+
onChange: function (event) { patch({ opacity: Number(event.target.value) }) },
|
|
2401
|
+
}),
|
|
2402
|
+
h('span', { className: 'pp-opacityValue' },
|
|
2403
|
+
String(typeof cfg.opacity === 'number' ? cfg.opacity : DEFAULT_MODAL_STYLE.opacity) + '%')))))
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// 毛玻璃开关 + 强度:自定义背景 / 纯色 都能叠加(「毛玻璃」选项本身已隐含开启)。
|
|
2407
|
+
if (cfg.mode === 'image' || cfg.mode === 'solid') {
|
|
2408
|
+
rows.push(h('div', { className: 'pp-field', key: 'glass' },
|
|
2409
|
+
h('div', { className: 'pp-fieldLabelWrap' },
|
|
2410
|
+
h('span', { className: 'pp-fieldLabel' }, tt('毛玻璃')),
|
|
2411
|
+
h('span', { className: 'pp-fieldHint' },
|
|
2412
|
+
tt(cfg.mode === 'image' ? '把背景图片磨砂化(对图片做真模糊)' : '底色半透明并模糊背后的页面'))),
|
|
2413
|
+
h('div', { className: 'pp-fieldControl' },
|
|
2414
|
+
h('button', {
|
|
2415
|
+
type: 'button',
|
|
2416
|
+
role: 'switch',
|
|
2417
|
+
'aria-checked': cfg.glass === true ? 'true' : 'false',
|
|
2418
|
+
className: cfg.glass === true ? 'pp-switch pp-switchOn' : 'pp-switch',
|
|
2419
|
+
onClick: function () { patch({ glass: cfg.glass !== true }) },
|
|
2420
|
+
}))))
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// 毛玻璃强度:只要当前处于玻璃态(选了「毛玻璃」或打开了开关)就露出滑杆。
|
|
2424
|
+
if (cfg.mode === 'glass' || cfg.glass === true) {
|
|
2425
|
+
rows.push(h('div', { className: 'pp-field', key: 'blur' },
|
|
2426
|
+
h('div', { className: 'pp-fieldLabelWrap' },
|
|
2427
|
+
h('span', { className: 'pp-fieldLabel' }, tt('毛玻璃强度')),
|
|
2428
|
+
h('span', { className: 'pp-fieldHint' }, tt('模糊半径(0–48px)'))),
|
|
2429
|
+
h('div', { className: 'pp-fieldControl' },
|
|
2430
|
+
h('div', { className: 'pp-opacityRow' },
|
|
2431
|
+
h('input', {
|
|
2432
|
+
type: 'range',
|
|
2433
|
+
min: '0',
|
|
2434
|
+
max: '48',
|
|
2435
|
+
step: '1',
|
|
2436
|
+
value: String(typeof cfg.blur === 'number' ? cfg.blur : DEFAULT_MODAL_STYLE.blur),
|
|
2437
|
+
onChange: function (event) { patch({ blur: Number(event.target.value) }) },
|
|
2438
|
+
}),
|
|
2439
|
+
h('span', { className: 'pp-fieldHint' }, String(typeof cfg.blur === 'number' ? cfg.blur : DEFAULT_MODAL_STYLE.blur) + 'px')))))
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
return h('section', { className: 'pp-block', 'data-section': 'dcb-consumption', 'data-dcb-build': BUILD_TAG },
|
|
2443
|
+
h('header', { className: 'pp-blockHead' },
|
|
2444
|
+
h('div', { className: 'pp-blockHeadMain' },
|
|
2445
|
+
h('h3', { className: 'pp-blockTitle' }, tt('消耗弹窗')),
|
|
2446
|
+
h('p', { className: 'pp-blockDesc' }, tt('消耗徽章弹层的背景样式;不影响对话区与侧边栏背景。{tag}', { tag: BUILD_TAG }))),
|
|
2447
|
+
h('button', {
|
|
2448
|
+
type: 'button',
|
|
2449
|
+
className: 'pp-blockReset',
|
|
2450
|
+
onClick: function () {
|
|
2451
|
+
store.setModalStyle(DEFAULT_MODAL_STYLE)
|
|
2452
|
+
const input = fileRef.current
|
|
2453
|
+
if (input) {
|
|
2454
|
+
try {
|
|
2455
|
+
input.value = ''
|
|
2456
|
+
} catch (e) { /* ignore */ }
|
|
2457
|
+
}
|
|
2458
|
+
},
|
|
2459
|
+
}, tt('重置本栏'))),
|
|
2460
|
+
h('div', { className: 'pp-blockBody' }, rows))
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* 把「消耗弹窗」栏注入个性化页。个性化插件没有暴露子插槽,因此:
|
|
2465
|
+
* - 只在页面上真的出现 `section.pp-block`(即个性化页已挂载)时才注入,等价于“同时安装”的运行时探测;
|
|
2466
|
+
* - 自建容器 + 自建 React root,个性化页重渲染把我的节点摘掉时,把**同一个容器**重新挂回去(状态不丢)。
|
|
2467
|
+
*/
|
|
2468
|
+
function startPersonalizationBridge() {
|
|
2469
|
+
if (typeof document === 'undefined' || typeof MutationObserver !== 'function') {
|
|
2470
|
+
return function () {}
|
|
2471
|
+
}
|
|
2472
|
+
let container = null
|
|
2473
|
+
let root = null
|
|
2474
|
+
let scheduled = false
|
|
2475
|
+
let stopped = false
|
|
2476
|
+
let announced = false
|
|
2477
|
+
let reactDom = null
|
|
2478
|
+
try {
|
|
2479
|
+
reactDom = require('react-dom/client')
|
|
2480
|
+
} catch (e) {
|
|
2481
|
+
reactDom = null
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function ensureContainer() {
|
|
2485
|
+
if (container) return container
|
|
2486
|
+
container = document.createElement('div')
|
|
2487
|
+
container.setAttribute('data-dcb-personalization', '1')
|
|
2488
|
+
if (reactDom !== null && typeof reactDom.createRoot === 'function') {
|
|
2489
|
+
root = reactDom.createRoot(container)
|
|
2490
|
+
root.render(h(ConsumptionModalSettings, null))
|
|
2491
|
+
} else {
|
|
2492
|
+
container.textContent = tt('消耗弹窗设置需要 react-dom,当前环境不可用。')
|
|
2493
|
+
}
|
|
2494
|
+
return container
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function attach() {
|
|
2498
|
+
if (stopped) return
|
|
2499
|
+
// 快路径:已经挂在文档里就不用再找页面(避免每次 DOM 变动都查一次 selector)。
|
|
2500
|
+
if (container && container.isConnected) return
|
|
2501
|
+
const anchor = document.querySelector('section.pp-block')
|
|
2502
|
+
if (!anchor || !anchor.parentElement) return
|
|
2503
|
+
const page = anchor.parentElement
|
|
2504
|
+
const node = ensureContainer()
|
|
2505
|
+
if (node.parentNode !== page) page.appendChild(node)
|
|
2506
|
+
if (!announced) {
|
|
2507
|
+
announced = true
|
|
2508
|
+
console.log('[consumption-badge] dsh-plugin-personalization detected: injected the usage popup section into its settings page')
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function schedule() {
|
|
2513
|
+
if (stopped || scheduled) return
|
|
2514
|
+
scheduled = true
|
|
2515
|
+
setTimeout(function () {
|
|
2516
|
+
scheduled = false
|
|
2517
|
+
try {
|
|
2518
|
+
attach()
|
|
2519
|
+
} catch (e) { /* 注入失败不影响主功能 */ }
|
|
2520
|
+
}, 0)
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const observer = new MutationObserver(schedule)
|
|
2524
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
2525
|
+
schedule()
|
|
2526
|
+
|
|
2527
|
+
return function () {
|
|
2528
|
+
stopped = true
|
|
2529
|
+
try {
|
|
2530
|
+
observer.disconnect()
|
|
2531
|
+
} catch (e) { /* 已断开 */ }
|
|
2532
|
+
if (root !== null && typeof root.unmount === 'function') {
|
|
2533
|
+
try {
|
|
2534
|
+
root.unmount()
|
|
2535
|
+
} catch (e) { /* 已卸载 */ }
|
|
2536
|
+
}
|
|
2537
|
+
if (container && container.parentNode) {
|
|
2538
|
+
try {
|
|
2539
|
+
container.parentNode.removeChild(container)
|
|
2540
|
+
} catch (e) { /* 已移除 */ }
|
|
2541
|
+
}
|
|
2542
|
+
container = null
|
|
2543
|
+
root = null
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/* ============== 更新提示弹窗(仿 dsh-plugin-personalization 的 UpdateNotice) ============== */
|
|
2548
|
+
/**
|
|
2549
|
+
* 右下角浮层弹窗:只在「本地版本 < 最新版本」且未被忽略、未在本次会话点过「稍后提醒」时出现。
|
|
2550
|
+
* 结构照抄个性化插件的提示条(标题 / 版本行 / 命令框 / 复制·忽略·稍后提醒),但不依赖它的样式表,
|
|
2551
|
+
* 自带 dcb-* 样式,因此没有装个性化插件也能显示。
|
|
2552
|
+
*/
|
|
2553
|
+
function UpdatePopup() {
|
|
2554
|
+
const state = store.state
|
|
2555
|
+
const tick = React.useState(0)
|
|
2556
|
+
const setTick = tick[1]
|
|
2557
|
+
const copiedState = React.useState(false)
|
|
2558
|
+
const copied = copiedState[0]
|
|
2559
|
+
const setCopied = copiedState[1]
|
|
2560
|
+
React.useEffect(function () {
|
|
2561
|
+
return store.subscribe(function () { setTick(function (v) { return v + 1 }) })
|
|
2562
|
+
}, [])
|
|
2563
|
+
React.useEffect(function () {
|
|
2564
|
+
if (copied !== true) return undefined
|
|
2565
|
+
return everyTick(2000, function () { setCopied(false) })
|
|
2566
|
+
}, [copied])
|
|
2567
|
+
|
|
2568
|
+
const info = state.updateInfo
|
|
2569
|
+
const visible = state.updateState === 'ready'
|
|
2570
|
+
&& state.updateDismissed !== true
|
|
2571
|
+
&& !!info
|
|
2572
|
+
&& info.hasUpdate === true
|
|
2573
|
+
if (!visible) return null
|
|
2574
|
+
|
|
2575
|
+
function copyCommand() {
|
|
2576
|
+
const clipboard = typeof navigator !== 'undefined' && navigator !== null ? navigator.clipboard : null
|
|
2577
|
+
if (clipboard === null || clipboard === undefined || typeof clipboard.writeText !== 'function') {
|
|
2578
|
+
console.warn('[consumption-badge] clipboard unavailable, copy the command manually')
|
|
2579
|
+
return
|
|
2580
|
+
}
|
|
2581
|
+
try {
|
|
2582
|
+
const pending = clipboard.writeText(UPDATE_COMMAND)
|
|
2583
|
+
if (pending && typeof pending.then === 'function') {
|
|
2584
|
+
pending.then(function () { setCopied(true) }, function () {
|
|
2585
|
+
console.warn('[consumption-badge] clipboard write failed')
|
|
2586
|
+
})
|
|
2587
|
+
return
|
|
2588
|
+
}
|
|
2589
|
+
setCopied(true)
|
|
2590
|
+
} catch (e) {
|
|
2591
|
+
console.warn('[consumption-badge] clipboard write threw')
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
return h('div', { className: 'dcb-notice', role: 'status', 'aria-live': 'polite' },
|
|
2596
|
+
h('div', { className: 'dcb-notice-head' },
|
|
2597
|
+
h('span', { className: 'dcb-notice-title' }, tt('消耗徽章有新版本')),
|
|
2598
|
+
h('span', { className: 'dcb-notice-versions' },
|
|
2599
|
+
tt('当前 v{current} → 最新 v{latest}', { current: String(info.currentVersion), latest: String(info.latestVersion) })),
|
|
2600
|
+
h('button', {
|
|
2601
|
+
type: 'button',
|
|
2602
|
+
className: 'dcb-notice-x',
|
|
2603
|
+
title: tt('稍后提醒'),
|
|
2604
|
+
onClick: function () { store.dismissUpdate('later') },
|
|
2605
|
+
}, '✕')),
|
|
2606
|
+
h('div', { className: 'dcb-notice-cmd', title: UPDATE_COMMAND }, UPDATE_COMMAND),
|
|
2607
|
+
h('div', { className: 'dcb-notice-actions' },
|
|
2608
|
+
h('button', { type: 'button', className: 'dcb-btn dcb-btn-primary', onClick: copyCommand },
|
|
2609
|
+
copied ? tt('已复制') : tt('复制更新命令')),
|
|
2610
|
+
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.dismissUpdate('ignore') } }, tt('忽略此版本')),
|
|
2611
|
+
h('button', { type: 'button', className: 'dcb-btn', onClick: function () { store.dismissUpdate('later') } }, tt('稍后提醒')),
|
|
2612
|
+
h('span', { className: 'dcb-notice-hint' }, tt('更新后需重启 dsh 进程'))),
|
|
2613
|
+
h('div', { className: 'dcb-notice-foot' },
|
|
2614
|
+
tt('版本来自 npm registry(24h 缓存);「忽略此版本」会记住,直到出现更高版本。')))
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
/* styles + registration */
|
|
667
2618
|
const CSS = `
|
|
668
2619
|
.dcb-pill{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;font-variant-numeric:tabular-nums;cursor:pointer;white-space:nowrap;transition:border-color .15s,color .15s}
|
|
669
2620
|
.dcb-pill:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
|
|
@@ -672,8 +2623,25 @@ window.__ModuleLoader__.load({
|
|
|
672
2623
|
.dcb-tag{font-weight:600}
|
|
673
2624
|
.dcb-tag.is-peak{color:var(--dsw-alias-state-error-primary)}
|
|
674
2625
|
.dcb-tag.is-valley{color:var(--dsw-alias-state-success-primary)}
|
|
675
|
-
|
|
676
|
-
.dcb-
|
|
2626
|
+
/* 遮罩层只做压暗/淡纱,永远不做毛玻璃(周围不模糊)。注意:不写任何 backdrop-filter。 */
|
|
2627
|
+
.dcb-overlay{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-base) 46%,transparent);animation:dcb-fade .16s ease-out;outline:none}
|
|
2628
|
+
.dcb-modal{width:min(780px,94vw);max-height:88vh;overflow:auto;border-radius:16px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 24px 60px rgba(0,0,0,.3);padding:18px 20px 14px;animation:dcb-pop .18s ease-out;position:relative;isolation:isolate}
|
|
2629
|
+
/* 毛玻璃:不压暗,纯 backdrop-filter(与 dsh-plugin-personalization 的设置面板同款做法) */
|
|
2630
|
+
.dcb-modal.is-glass{background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 18%,transparent);-webkit-backdrop-filter:blur(var(--dcb-glass-blur,22px)) saturate(1.6);backdrop-filter:blur(var(--dcb-glass-blur,22px)) saturate(1.6);border-color:color-mix(in srgb,var(--dsw-alias-border-l2) 70%,transparent);box-shadow:0 24px 60px rgba(0,0,0,.26),inset 0 1px 0 rgba(255,255,255,.12)}
|
|
2631
|
+
/* 纯色:底色只给变量,透明度由是否毛玻璃决定 */
|
|
2632
|
+
.dcb-modal.has-solid{background:var(--dcb-solid,#17181c)}
|
|
2633
|
+
.dcb-modal.has-solid.is-glass{background:color-mix(in srgb,var(--dcb-solid,#17181c) 52%,transparent);-webkit-backdrop-filter:blur(calc(var(--dcb-glass-blur,22px) * 0.8)) saturate(1.3);backdrop-filter:blur(calc(var(--dcb-glass-blur,22px) * 0.8)) saturate(1.3)}
|
|
2634
|
+
/* 自定义背景:图片放在伪元素上,opacity 作用在图片自身(真半透明,不与底色混成发白);
|
|
2635
|
+
底下垫主题面板色,所以不透明度越低越淡,而不是越暗;开毛玻璃时对图片本身做模糊。 */
|
|
2636
|
+
.dcb-modal.has-image{background:var(--dsw-alias-bg-overlay)}
|
|
2637
|
+
.dcb-modal.has-image::before{content:'';position:absolute;inset:0;z-index:-1;border-radius:inherit;background-image:var(--dcb-bg-image);background-size:cover;background-position:center;background-repeat:no-repeat;opacity:var(--dcb-bg-opacity,.85)}
|
|
2638
|
+
.dcb-modal.has-image.is-glass{background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 18%,transparent)}
|
|
2639
|
+
.dcb-modal.has-image.is-glass::before{-webkit-filter:blur(var(--dcb-glass-blur,22px)) saturate(1.15);filter:blur(var(--dcb-glass-blur,22px)) saturate(1.15);transform:scale(1.06)}
|
|
2640
|
+
@supports not (background: color-mix(in srgb, red 50%, transparent)){
|
|
2641
|
+
.dcb-overlay{background:rgba(18,20,26,.42)}
|
|
2642
|
+
.dcb-modal.is-glass{background:rgba(127,127,127,.16)}
|
|
2643
|
+
.dcb-modal.has-solid.is-glass{background:rgba(127,127,127,.18)}
|
|
2644
|
+
}
|
|
677
2645
|
.dcb-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
|
|
678
2646
|
.dcb-head-title{font-size:14px;font-weight:600;letter-spacing:.2px}
|
|
679
2647
|
.dcb-head-actions{display:flex;gap:8px}
|
|
@@ -712,6 +2680,47 @@ window.__ModuleLoader__.load({
|
|
|
712
2680
|
.dcb-foot-warn{color:var(--dsw-alias-state-error-primary)}
|
|
713
2681
|
@keyframes dcb-fade{from{opacity:0}to{opacity:1}}
|
|
714
2682
|
@keyframes dcb-pop{from{opacity:0;transform:translateY(6px) scale(.99)}to{opacity:1;transform:none}}
|
|
2683
|
+
/* ---- settings section: history page ---- */
|
|
2684
|
+
.dcb-set{padding:2px 2px 18px;color:var(--dsw-alias-label-primary)}
|
|
2685
|
+
.dcb-set-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:14px}
|
|
2686
|
+
.dcb-set-title{font-size:15px;font-weight:600;letter-spacing:.2px}
|
|
2687
|
+
.dcb-set-note{font-size:10.5px;color:var(--dsw-alias-label-secondary);opacity:.9;white-space:nowrap}
|
|
2688
|
+
.dcb-set-controls{display:flex;flex-wrap:wrap;gap:10px 12px;align-items:flex-end;margin-bottom:14px}
|
|
2689
|
+
.dcb-field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
2690
|
+
.dcb-field-label{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
2691
|
+
.dcb-select,.dcb-date{height:28px;padding:0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;min-width:0}
|
|
2692
|
+
.dcb-select{min-width:132px;cursor:pointer}
|
|
2693
|
+
.dcb-date{min-width:126px}
|
|
2694
|
+
.dcb-select:focus,.dcb-date:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
|
2695
|
+
.dcb-range{display:flex;align-items:center;gap:6px}
|
|
2696
|
+
.dcb-range-sep{color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
2697
|
+
.dcb-set-body{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px 8px}
|
|
2698
|
+
.dcb-hist-svg{display:block;width:100%;height:280px}
|
|
2699
|
+
.dcb-hist-legend{display:flex;gap:14px;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:6px;flex-wrap:wrap}
|
|
2700
|
+
.dcb-tog{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;cursor:pointer;line-height:16px}
|
|
2701
|
+
.dcb-tog.is-on{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
|
|
2702
|
+
.dcb-tog i{display:inline-block;width:12px;height:3px;border-radius:2px;background:currentColor;opacity:.55}
|
|
2703
|
+
.dcb-tog.is-on i{opacity:1;background:var(--dsw-alias-brand-primary)}
|
|
2704
|
+
.dcb-tog.dcb-tog-cost.is-on i{background:var(--dsw-alias-state-success-primary)}
|
|
2705
|
+
.dcb-sum{margin-top:12px;display:flex;gap:18px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
2706
|
+
.dcb-sum b{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;font-weight:600}
|
|
2707
|
+
.dcb-cards-set{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:14px}
|
|
2708
|
+
/* ---- 更新提示弹窗(右下角浮层;结构对齐个性化插件的 pp-notice 提示条)---- */
|
|
2709
|
+
.dcb-notice{position:fixed;right:18px;bottom:18px;z-index:70;width:min(420px,calc(100vw - 32px));display:flex;flex-direction:column;gap:10px;padding:12px 14px;border-radius:var(--pp-radius-lg,12px);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 18px 44px rgba(0,0,0,.24);pointer-events:auto;animation:dcb-pop .18s ease-out}
|
|
2710
|
+
.dcb-notice-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0}
|
|
2711
|
+
.dcb-notice-title{font-size:14px;line-height:22px;font-weight:600}
|
|
2712
|
+
.dcb-notice-versions{font-size:12px;line-height:20px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
|
|
2713
|
+
.dcb-notice-x{margin-left:auto;flex:none;width:22px;height:22px;padding:0;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1;cursor:pointer;border-radius:6px}
|
|
2714
|
+
.dcb-notice-x:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
|
|
2715
|
+
.dcb-notice-cmd{min-width:0;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11.5px;line-height:18px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
|
|
2716
|
+
.dcb-notice-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
2717
|
+
.dcb-notice-hint{flex:1 1 160px;min-width:0;font-size:11px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
2718
|
+
.dcb-notice-foot{font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary);opacity:.85}
|
|
2719
|
+
.dcb-btn-primary{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
|
|
2720
|
+
.dcb-set .dcb-card{padding:9px 11px}
|
|
2721
|
+
.dcb-set .dcb-card-value{font-size:17px}
|
|
2722
|
+
.dcb-hist-empty{padding:56px 0;text-align:center;font-size:12.5px;color:var(--dsw-alias-label-secondary)}
|
|
2723
|
+
.dcb-hist-aside{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-top:6px;opacity:.85}
|
|
715
2724
|
`
|
|
716
2725
|
|
|
717
2726
|
/** 样式标签:覆盖式写入(外壳热重挂插件时旧标签可能还在),随插件卸载摘除。 */
|
|
@@ -736,9 +2745,15 @@ window.__ModuleLoader__.load({
|
|
|
736
2745
|
|
|
737
2746
|
exports.apply = function apply(ctx) {
|
|
738
2747
|
insertCss(ctx)
|
|
2748
|
+
// Bind the shell locale first: every render below reads through tt().
|
|
2749
|
+
appCtx = ctx
|
|
2750
|
+
attachLocale(ctx)
|
|
2751
|
+
// Slot labels are thunks so the settings nav / action row re-read the copy
|
|
2752
|
+
// after a language switch (the slot system resolves labels at read time).
|
|
2753
|
+
const labelThunk = function () { return tt('消耗') }
|
|
739
2754
|
ctx.slots.inject('conversation.chat.assistant-actions', function () {
|
|
740
2755
|
const dispose = ctx.slots.register(
|
|
741
|
-
{ name: 'conversation.chat.assistant-actions', id: 'consumption', order: 20, label:
|
|
2756
|
+
{ name: 'conversation.chat.assistant-actions', id: 'consumption', order: 20, label: labelThunk },
|
|
742
2757
|
function (props) { return h(ConsumptionBadge, props) },
|
|
743
2758
|
)
|
|
744
2759
|
return function () { dispose() }
|
|
@@ -750,6 +2765,30 @@ window.__ModuleLoader__.load({
|
|
|
750
2765
|
)
|
|
751
2766
|
return function () { dispose() }
|
|
752
2767
|
})
|
|
2768
|
+
// 设置页整页:settings.section(不是 settings.plugin.item 的卡片位)。
|
|
2769
|
+
ctx.slots.inject('settings.section', function () {
|
|
2770
|
+
const dispose = ctx.slots.register(
|
|
2771
|
+
{ name: 'settings.section', id: 'balance-dsh-consumption', order: 26, label: labelThunk },
|
|
2772
|
+
function (props) { return h(ConsumptionSettings, props) },
|
|
2773
|
+
)
|
|
2774
|
+
return function () { dispose() }
|
|
2775
|
+
})
|
|
2776
|
+
// 与 dsh-plugin-personalization 联动:在它的设置页注入「消耗弹窗」背景控制。
|
|
2777
|
+
ctx.effect(function () { return startPersonalizationBridge() })
|
|
2778
|
+
// 把已存进 IndexedDB 的背景图取回来(生成新的 blob: 地址)。
|
|
2779
|
+
store.hydrateModalImage()
|
|
2780
|
+
// 更新提示弹窗:启动后延迟异步检查,绝不阻塞首屏;显示在右下角浮层,不遮挡任何控件。
|
|
2781
|
+
ctx.slots.inject('shell.overlay', function () {
|
|
2782
|
+
const dispose = ctx.slots.register(
|
|
2783
|
+
{ name: 'shell.overlay', id: 'consumption-update-notice', order: 50 },
|
|
2784
|
+
function () { return h(UpdatePopup, null) },
|
|
2785
|
+
)
|
|
2786
|
+
return function () { dispose() }
|
|
2787
|
+
})
|
|
2788
|
+
ctx.effect(function () {
|
|
2789
|
+
const timer = setTimeout(function () { store.checkUpdates() }, 2500)
|
|
2790
|
+
return function () { clearTimeout(timer) }
|
|
2791
|
+
})
|
|
753
2792
|
}
|
|
754
2793
|
|
|
755
2794
|
return module.exports
|