@linxin666/dsh-usage 0.3.20 → 0.3.22

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The Whale-yuan voucher (鲸元券): the DeepSeek official family's retained
3
+ * ledger totals stamped onto the banknote artwork as a denomination, ready
4
+ * to save or share. The canvas copy is deliberately locale-neutral (digits,
5
+ * latin captions, ISO dates) so the exported image needs no dictionary;
6
+ * everything user-facing around it lives in the section's locales. The pure
7
+ * helpers are unit-tested; the draw itself degrades to a thrown error the
8
+ * section renders as its failure line.
9
+ * @module @linxin666/dsh-usage/client/voucher
10
+ */
11
+ import type { UsageWindowSummary } from '../core/types.ts';
12
+ /** The facts stamped onto one voucher. */
13
+ export interface VoucherData {
14
+ /** DeepSeek official family total tokens over the retained window. */
15
+ tokens: number;
16
+ /** Provider calls that reported usage. */
17
+ calls: number;
18
+ /** Fold-time spend estimate in CNY (the only priced family). */
19
+ cost: number;
20
+ /** Retained window bounds (local date keys, inclusive). */
21
+ from: string;
22
+ to: string;
23
+ }
24
+ /** Anti-inflation exchange rate: 1000 tokens mint one whale yuan. */
25
+ export declare const TOKENS_PER_WHALE_YUAN = 1000;
26
+ /** The note's face value in whale yuan; the smallest denomination is 1. */
27
+ export declare function faceValue(tokens: number): number;
28
+ /** Local calendar day (`YYYY-MM-DD`) for an epoch ms timestamp. */
29
+ export declare function formatDay(ms: number): string;
30
+ /**
31
+ * Sum the DeepSeek official family rows out of a usage window (both the
32
+ * `deepseek` catalog alias and the live `deepseek-official` route fold into
33
+ * one voucher). Undefined when the window is absent (older host) or the
34
+ * family has no usage — a zero-token note mints nothing.
35
+ */
36
+ export declare function deepseekVoucherData(window: UsageWindowSummary | undefined): VoucherData | undefined;
37
+ /** Banknote denomination: full digits with thousands separators. */
38
+ export declare function formatDenomination(value: number): string;
39
+ /** Deterministic serial number flavor: the face value mod 1e9, zero-padded. */
40
+ export declare function voucherSerial(face: number): string;
41
+ /** The decoded note artwork plus its intrinsic size. */
42
+ export interface VoucherArt {
43
+ image: CanvasImageSource;
44
+ width: number;
45
+ height: number;
46
+ }
47
+ /** Decode the note artwork once per page; a failed decode retries next call. */
48
+ export declare function loadVoucherArt(): Promise<VoucherArt>;
49
+ /**
50
+ * Stamp the denomination block onto a canvas sized to the artwork: the
51
+ * minted whale yuan as the face value under the note title, a small
52
+ * `whale yuan` caption, and a seal-red serial line with the minting
53
+ * window. All geometry is relative to the artwork size so a regenerated
54
+ * asset reflows.
55
+ */
56
+ export declare function drawVoucher(canvas: HTMLCanvasElement, art: VoucherArt, data: VoucherData): void;
@@ -1,13 +1,19 @@
1
1
  /**
2
- * DeepSeek official peak/off-peak pricing: the published V4 price book plus
3
- * the peak-window clock, folded into a per-call spend estimate the ledger
4
- * stamps at fold time (the provider bills each request in the period the
5
- * request ran in, so pricing at the fold is the honest estimate).
2
+ * DeepSeek official peak/off-peak pricing: the published price book plus the
3
+ * peak-window clock, folded into a per-call spend estimate the ledger stamps
4
+ * at fold time (the provider bills each request in the period the request ran
5
+ * in, so pricing at the fold is the honest estimate).
6
6
  *
7
- * Policy (api-docs.deepseek.com pricing page, effective 2026-08-17): peak
8
- * hours are Beijing time Monday-Friday 09:00-12:00 and 14:00-18:00; every
9
- * other hour (nights, weekends) is off-peak and billed at half the peak
10
- * price. All prices here are CNY per million tokens.
7
+ * Policy (api-docs.deepseek.com pricing page, effective 2026-09-10 12:00
8
+ * Beijing): peak hours are Beijing time Monday-Friday 09:00-12:00 and
9
+ * 14:00-18:00; every other hour (nights, weekends) is off-peak and billed at
10
+ * half the peak price. All prices here are CNY per million tokens.
11
+ *
12
+ * The published rows are `deepseek-flash` (DeepSeek-V4.1-Flash) and
13
+ * `deepseek-v4-pro` (DeepSeek-V4-Pro-0813). The retired flash ids
14
+ * (`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`) stay accepted and bill
15
+ * at the flash row; DeepSeek routes `deepseek-v4-pro` to V4.1-Flash from the
16
+ * retirement instant below until V4.1 Pro ships.
11
17
  * @module @linxin666/dsh-usage/core/pricing
12
18
  */
13
19
  import type { UsageTokenTotals } from './types.ts';
@@ -81,6 +81,20 @@ export interface PlanView {
81
81
  }
82
82
  /** How the credential backing one provider route was resolved. */
83
83
  export type CredentialKind = 'api-key' | 'env' | 'oauth' | 'none';
84
+ /** One aggregated usage window (the 30-day trend, or the whole retained ledger). */
85
+ export interface UsageWindowSummary {
86
+ from: string;
87
+ to: string;
88
+ totals: UsageTokenTotals;
89
+ providers: UsageProviderSummary[];
90
+ }
91
+ /** Real spend observed from an official balance series rather than priced locally. */
92
+ export interface ObservedSpendView {
93
+ /** Accrued spend in the account currency (CNY for the DeepSeek official watch). */
94
+ cny: number;
95
+ /** Epoch ms of the first balance observation the accrual starts at. */
96
+ since: number;
97
+ }
84
98
  /** One provider row of the overview snapshot. */
85
99
  export interface ProviderSnapshotView {
86
100
  /** Provider route key (`deepseek`, `kimi-coding`, custom routes, ...). */
@@ -123,12 +137,20 @@ export interface UsageOverviewView {
123
137
  * The same window aggregated per provider and model — the trend card's
124
138
  * bar-chart data. Optional so an older host document still renders.
125
139
  */
126
- range?: {
127
- from: string;
128
- to: string;
129
- totals: UsageTokenTotals;
130
- providers: UsageProviderSummary[];
131
- };
140
+ range?: UsageWindowSummary;
141
+ /**
142
+ * The whole retained ledger (up to `retainDays`, today included)
143
+ * aggregated per provider — the voucher's minted total. Optional for the
144
+ * same older-host tolerance as `range`.
145
+ */
146
+ all?: UsageWindowSummary;
147
+ /**
148
+ * Real CNY spend of the official DeepSeek family, accrued from observed
149
+ * decreases of the official balance (top-ups never count). Present once
150
+ * a decrease has been observed; until then the section falls back to the
151
+ * fold-time estimate.
152
+ */
153
+ observedSpend?: ObservedSpendView;
132
154
  };
133
155
  }
134
156
  /**
@@ -10,6 +10,21 @@ import type { Context } from '@deepseek-ai/cordis';
10
10
  import type { ProviderSnapshotView, UsageOverviewView, UsageTokenTotals } from '../core/types.ts';
11
11
  /** Source tag the plugin stamps onto pet announcements. */
12
12
  export declare const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
13
+ /**
14
+ * Persisted accrual state for the official DeepSeek family's real spend:
15
+ * consecutive observations of the official CNY balance, decreases counted
16
+ * as spent (a rise is a top-up and never accrues). `since` is the first
17
+ * observation; the ledger keeps no such fact, so this watch is the only
18
+ * source of the voucher's observed-spend figure.
19
+ */
20
+ export interface SpendWatch {
21
+ /** Total observed decrease since `since`, in CNY. */
22
+ accruedCny: number;
23
+ /** Epoch ms of the first balance observation. */
24
+ since: number;
25
+ /** The balance the previous observation ended on, when one exists. */
26
+ lastBalanceCny?: number;
27
+ }
13
28
  /** Poll-loop and announce options; re-applied live on settings change. */
14
29
  export interface UsageServiceOptions {
15
30
  pollIntervalSec: number;
@@ -66,6 +81,8 @@ export declare class UsageService {
66
81
  private readonly snapshotsPath;
67
82
  private ledger;
68
83
  private readonly snapshots;
84
+ /** The official DeepSeek family's real-spend watch (see SpendWatch). */
85
+ private spendWatch;
69
86
  /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
70
87
  private readonly sessionRoutes;
71
88
  /** The most recent route seen this boot; the pet bubble follows it. */
@@ -105,6 +122,15 @@ export declare class UsageService {
105
122
  private familyCostToday;
106
123
  /** Today's ledger totals for one provider route (the exact id, not the family). */
107
124
  private providerUsageToday;
125
+ /**
126
+ * Accrue the official DeepSeek family's real spend from observed CNY
127
+ * balance decreases; a balance rise is a top-up and never counts. The
128
+ * family's route ids can alias one account, so the watch follows the
129
+ * largest balance seen this cycle and accrues only decreases of that
130
+ * series — a failed probe keeps the stale balance and accrues nothing
131
+ * until the next success reports the whole drop at once.
132
+ */
133
+ private accrueDeepSeekSpend;
108
134
  /**
109
135
  * The display name for a route key: the LLM runtime's first, then the
110
136
  * adapter's, then the id itself — the snapshot may not exist for
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-usage",
3
3
  "description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a dedicated pet bubble for the current provider",
4
- "version": "0.3.20",
4
+ "version": "0.3.22",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -66,7 +66,7 @@
66
66
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
67
67
  "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
68
68
  "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
69
- "@linxin666/dsh-pet": "0.3.20",
69
+ "@linxin666/dsh-pet": "0.3.22",
70
70
  "@testing-library/dom": "^10.4.1",
71
71
  "@testing-library/react": "^16.3.2",
72
72
  "@types/node": "^22.20.0",
@@ -1,20 +1,22 @@
1
1
  /**
2
- * The usage statistics settings section: two tabs (用量: today's usage,
3
- * balances, trend; 个人套餐: per-provider plan quota windows) plus a compact
4
- * settings row. Data comes from the host's loopback-fenced
2
+ * The usage statistics settings section: three tabs (用量: today's usage,
3
+ * balances, trend; 个人套餐: per-provider plan quota windows; Token 银行:
4
+ * the whale-yuan voucher minted from the DeepSeek official family's usage)
5
+ * plus a compact settings row. Data comes from the host's loopback-fenced
5
6
  * /api/dsh-usage/overview document; polling runs only while the section is
6
7
  * mounted and the tab is visible.
7
8
  * @module @linxin666/dsh-usage/client/UsageSectionCard
8
9
  */
9
10
 
10
- import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'
11
+ import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
11
12
  import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
12
13
  import type { UsageStoreInstance } from './usage-store.ts'
13
14
  import { t } from './locales.ts'
14
15
  import styles from './usage.module.css'
15
16
  import { isDeepSeekProviderRoute } from '../core/adapters.ts'
16
17
  import { deepseekPeriodAt } from '../core/pricing.ts'
17
- import type { ProviderSnapshotView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals } from '../core/types.ts'
18
+ import { deepseekVoucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
19
+ import type { ObservedSpendView, ProviderSnapshotView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
18
20
 
19
21
  /** The settings fields this section edits (immediate-apply semantics). */
20
22
  export interface UsageSettings {
@@ -150,13 +152,22 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
150
152
  const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
151
153
  const settingsSnapshot = settings.getSnapshot()
152
154
  const settingsValue = settingsSnapshot.value ?? {}
153
- const [tab, setTab] = useState<'usage' | 'plans'>('usage')
155
+ const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
154
156
  const [refreshing, setRefreshing] = useState(false)
155
-
156
- // Poll while mounted and visible; the overview is cheap (no probes — the
157
- // host's own cycle owns those) so 10 s keeps balances fresh-ish between
157
+ // The enable checkbox writes through the settings scope, so subscribing here
158
+ // keeps the flag below live: the poll starts and stops with it instead of
159
+ // waiting for an unrelated render.
160
+ const [, bumpSettings] = useState(0)
161
+ useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
162
+ const enabled = settingsValue.enabled ?? true
163
+
164
+ // Poll while mounted, enabled and visible; the overview is cheap (no probes —
165
+ // the host's own cycle owns those) so 10 s keeps balances fresh-ish between
158
166
  // manual refreshes.
159
167
  useEffect(() => {
168
+ // A disabled plugin deregisters its host routes, so the poll must stop with
169
+ // it; the section re-enables by writing the flag back through the checkbox.
170
+ if (!enabled) return undefined
160
171
  poll()
161
172
  let timer: number | undefined
162
173
  const start = (): void => {
@@ -177,7 +188,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
177
188
  if (timer !== undefined) window.clearInterval(timer)
178
189
  document.removeEventListener('visibilitychange', onVisibility)
179
190
  }
180
- }, [poll])
191
+ }, [poll, enabled])
181
192
 
182
193
  const snapshot = ui.snapshot
183
194
 
@@ -191,11 +202,22 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
191
202
  }
192
203
  }
193
204
 
194
- if (ui.status === 'error') {
195
- return <div className={styles.section} data-dsh-plugin="usage">{t('usage.error', { error: ui.error ?? '' })}</div>
196
- }
197
- if (snapshot === null) {
198
- return <div className={styles.section} data-dsh-plugin="usage">{t('usage.loading')}</div>
205
+ // Disabled, failed and still-loading states keep the settings row mounted:
206
+ // it owns the enable checkbox, so replacing the whole panel would leave the
207
+ // user no way back from the UI.
208
+ if (!enabled || ui.status === 'error' || snapshot === null) {
209
+ return (
210
+ <div className={styles.section} data-dsh-plugin="usage">
211
+ <span className={styles.muted} data-dsh-part="status-line">
212
+ {!enabled
213
+ ? t('usage.disabled')
214
+ : ui.status === 'error'
215
+ ? t('usage.error', { error: ui.error ?? '' })
216
+ : t('usage.loading')}
217
+ </span>
218
+ <SettingsRow settings={settings} snapshot={settingsSnapshot.status === 'ready' ? settingsSnapshot : undefined} value={settingsValue} />
219
+ </div>
220
+ )
199
221
  }
200
222
 
201
223
  const current = snapshot.current
@@ -234,6 +256,9 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
234
256
  <button type="button" role="tab" aria-selected={tab === 'plans'} className={tab === 'plans' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('plans')}>
235
257
  {t('usage.tab.plans')}
236
258
  </button>
259
+ <button type="button" role="tab" aria-selected={tab === 'bank'} className={tab === 'bank' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('bank')}>
260
+ {t('usage.tab.bank')}
261
+ </button>
237
262
  </div>
238
263
 
239
264
  {tab === 'usage' && (
@@ -299,6 +324,8 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
299
324
  ? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noneConfigured')}</span></div>
300
325
  : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
301
326
  )}
327
+
328
+ {tab === 'bank' && <VoucherCard window={snapshot.usage.all ?? snapshot.usage.range} observedSpend={snapshot.usage.observedSpend} />}
302
329
  </div>
303
330
  )
304
331
  }
@@ -372,6 +399,105 @@ function totalOf(totals: UsageTokenTotals): number {
372
399
  return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens
373
400
  }
374
401
 
402
+ /**
403
+ * The Token 银行 card: the DeepSeek official family's retained-ledger usage
404
+ * minted onto the whale-yuan note at 1000 tokens per whale yuan. The window
405
+ * prefers the host's whole-ledger aggregate and falls back to the 30-day
406
+ * trend when an older host serves no `all`; the spend line prefers the
407
+ * official balance watch and falls back to the fold-time estimate; the
408
+ * artwork draw failure degrades to an error line and never takes the
409
+ * section down.
410
+ */
411
+ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView }): ReactNode {
412
+ const { window: ledger, observedSpend } = props
413
+ const data = deepseekVoucherData(ledger)
414
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
415
+ const [drawError, setDrawError] = useState<string | undefined>(undefined)
416
+ const dataKey = data === undefined ? '' : `${data.from}|${data.to}|${data.tokens}|${data.calls}|${data.cost}`
417
+
418
+ useEffect(() => {
419
+ if (data === undefined) return
420
+ const voucher = data
421
+ let cancelled = false
422
+ loadVoucherArt().then((art) => {
423
+ if (cancelled) return
424
+ const canvas = canvasRef.current
425
+ if (canvas !== null) {
426
+ try {
427
+ drawVoucher(canvas, art, voucher)
428
+ } catch (error) {
429
+ if (!cancelled) setDrawError(error instanceof Error ? error.message : String(error))
430
+ }
431
+ }
432
+ }, (error) => {
433
+ if (!cancelled) setDrawError(error instanceof Error ? error.message : String(error))
434
+ })
435
+ return () => {
436
+ cancelled = true
437
+ }
438
+ // dataKey covers every field the draw and the buttons read.
439
+ }, [dataKey])
440
+
441
+ const onSave = (): void => {
442
+ const canvas = canvasRef.current
443
+ if (canvas === null || data === undefined) return
444
+ canvas.toBlob((blob) => {
445
+ if (blob === null) return
446
+ const url = URL.createObjectURL(blob)
447
+ const anchor = document.createElement('a')
448
+ anchor.href = url
449
+ anchor.download = `dsh-whale-voucher-${data.to}.png`
450
+ anchor.click()
451
+ window.setTimeout(() => URL.revokeObjectURL(url), 10_000)
452
+ }, 'image/png')
453
+ }
454
+
455
+ const shareSupported = typeof navigator !== 'undefined' && typeof navigator.canShare === 'function'
456
+ const onShare = (): void => {
457
+ const canvas = canvasRef.current
458
+ if (canvas === null || data === undefined || !shareSupported) return
459
+ canvas.toBlob(async (blob) => {
460
+ if (blob === null) return
461
+ const file = new File([blob], `dsh-whale-voucher-${data.to}.png`, { type: 'image/png' })
462
+ if (!navigator.canShare({ files: [file] })) return
463
+ try {
464
+ await navigator.share({ files: [file], title: t('usage.bank.title') })
465
+ } catch {
466
+ // A user-cancelled share sheet rejects; nothing to report.
467
+ }
468
+ }, 'image/png')
469
+ }
470
+
471
+ return (
472
+ <div className={styles.card} data-dsh-part="bank-card">
473
+ <span className={styles.cardTitle}>{t('usage.bank.title')}</span>
474
+ {data === undefined
475
+ ? <span className={styles.muted}>{t('usage.bank.noUsage')}</span>
476
+ : <>
477
+ <span className={styles.muted}>{t('usage.bank.hint')}</span>
478
+ <div className={styles.voucherPreview} data-dsh-part="voucher-preview">
479
+ <canvas ref={canvasRef} aria-label={t('usage.bank.title')} />
480
+ </div>
481
+ {drawError !== undefined && <span className={styles.errorLine}>{t('usage.bank.drawError', { error: drawError })}</span>}
482
+ <div className={styles.providerRow}>
483
+ <span className={styles.providerName}>{t('usage.bank.minted', { minted: formatDenomination(faceValue(data.tokens)), tokens: formatTokens(data.tokens) })}</span>
484
+ <span className={styles.providerTokens}>{t('usage.calls', { n: data.calls })}</span>
485
+ </div>
486
+ <span className={styles.muted}>
487
+ {observedSpend !== undefined
488
+ ? t('usage.bank.spend.observed', { cost: observedSpend.cny.toFixed(2), since: formatDay(observedSpend.since) })
489
+ : t('usage.bank.spend.estimated', { cost: data.cost.toFixed(2) })}
490
+ </span>
491
+ <span className={styles.muted}>{t('usage.bank.window', { from: data.from, to: data.to })}</span>
492
+ <div className={styles.buttonRow}>
493
+ <button type="button" className={styles.refreshBtn} onClick={onSave}>{t('usage.bank.save')}</button>
494
+ {shareSupported && <button type="button" className={styles.refreshBtn} onClick={onShare}>{t('usage.bank.share')}</button>}
495
+ </div>
496
+ </>}
497
+ </div>
498
+ )
499
+ }
500
+
375
501
  function PlanCard(props: { provider: ProviderSnapshotView; current?: string }): ReactNode {
376
502
  const { provider, current } = props
377
503
  return (
@@ -98,9 +98,12 @@ export function apply(ctx: ClientContext): void {
98
98
  usageApi.overview().then((snapshot) => {
99
99
  if (seq !== pollSeq) return
100
100
  store.actions.setSnapshot(snapshot)
101
- }, () => {
101
+ }, (error: unknown) => {
102
102
  if (seq !== pollSeq) return
103
- store.actions.setState('error', 'usage.overview transport error')
103
+ // Surface the transport's own message: a 404 here means the host has no
104
+ // /api/dsh-usage/overview route (plugin disabled), which the panel must be
105
+ // able to tell apart from a genuine failure.
106
+ store.actions.setState('error', error instanceof Error ? error.message : String(error))
104
107
  })
105
108
  }
106
109
  // The refresh response is authoritative: it reflects the completed probe
@@ -112,9 +115,9 @@ export function apply(ctx: ClientContext): void {
112
115
  usageApi.refresh().then((snapshot) => {
113
116
  pollSeq = seq
114
117
  store.actions.setSnapshot(snapshot)
115
- }, () => {
118
+ }, (error: unknown) => {
116
119
  if (seq !== pollSeq) return
117
- store.actions.setState('error', 'usage.refresh transport error')
120
+ store.actions.setState('error', error instanceof Error ? error.message : String(error))
118
121
  })
119
122
  }
120
123
 
@@ -12,11 +12,13 @@ export const zh = {
12
12
  'usage.title': '使用统计',
13
13
  'usage.tab.usage': '用量',
14
14
  'usage.tab.plans': '个人套餐',
15
+ 'usage.tab.bank': 'Token 银行',
15
16
  'usage.refresh': '刷新',
16
17
  'usage.refreshing': '刷新中…',
17
18
  'usage.updated': '更新于 {time}',
18
19
  'usage.loading': '正在加载用量数据…',
19
20
  'usage.error': '加载失败:{error}',
21
+ 'usage.disabled': '插件已停用:勾选下方「启用插件」即可恢复统计。',
20
22
  'usage.current': '当前',
21
23
  'usage.today': '今日用量',
22
24
  'usage.today.cost': '今日消费(估算)',
@@ -50,6 +52,16 @@ export const zh = {
50
52
  'usage.config.bubbleMode.always': '常驻显示',
51
53
  'usage.config.bubbleMode.change': '仅变化时',
52
54
  'usage.config.bubbleMode.off': '关闭',
55
+ 'usage.bank.title': '鲸元券',
56
+ 'usage.bank.hint': '官方 API 每消耗 1000 tokens 铸造 1 鲸元;保存或分享这张票券。',
57
+ 'usage.bank.noUsage': '暂无 DeepSeek 官方用量数据(统计自插件启用起)',
58
+ 'usage.bank.minted': '累计铸造 {minted} 鲸元({tokens} tokens)',
59
+ 'usage.bank.spend.observed': '官方余额实测花费 ¥{cost}(自 {since} 起)',
60
+ 'usage.bank.spend.estimated': '消费估算:约 ¥{cost}',
61
+ 'usage.bank.window': '统计窗口 {from} ~ {to}',
62
+ 'usage.bank.save': '保存图片',
63
+ 'usage.bank.share': '分享',
64
+ 'usage.bank.drawError': '票券生成失败:{error}',
53
65
  }
54
66
 
55
67
  /** English mirror; every zh key present. */
@@ -57,11 +69,13 @@ export const en: Record<UsageKey, string> = {
57
69
  'usage.title': 'Usage Statistics',
58
70
  'usage.tab.usage': 'Usage',
59
71
  'usage.tab.plans': 'Plans',
72
+ 'usage.tab.bank': 'Token Bank',
60
73
  'usage.refresh': 'Refresh',
61
74
  'usage.refreshing': 'Refreshing…',
62
75
  'usage.updated': 'Updated {time}',
63
76
  'usage.loading': 'Loading usage data…',
64
77
  'usage.error': 'Failed to load: {error}',
78
+ 'usage.disabled': 'Plugin disabled. Tick "Enable plugin" below to resume statistics.',
65
79
  'usage.current': 'Current',
66
80
  'usage.today': 'Today',
67
81
  'usage.today.cost': 'Today spend (estimated)',
@@ -95,6 +109,16 @@ export const en: Record<UsageKey, string> = {
95
109
  'usage.config.bubbleMode.always': 'Always visible',
96
110
  'usage.config.bubbleMode.change': 'On change',
97
111
  'usage.config.bubbleMode.off': 'Off',
112
+ 'usage.bank.title': 'Whale-yuan voucher',
113
+ 'usage.bank.hint': 'Every 1,000 tokens spent on the official API mint one whale yuan; save or share the note.',
114
+ 'usage.bank.noUsage': 'No official DeepSeek usage yet (counting starts when the plugin is enabled)',
115
+ 'usage.bank.minted': 'Minted {minted} whale yuan ({tokens} tokens)',
116
+ 'usage.bank.spend.observed': 'Spent ¥{cost} observed on the official balance (watching since {since})',
117
+ 'usage.bank.spend.estimated': 'Estimated spend: about ¥{cost}',
118
+ 'usage.bank.window': 'Window {from} - {to}',
119
+ 'usage.bank.save': 'Save image',
120
+ 'usage.bank.share': 'Share',
121
+ 'usage.bank.drawError': 'Failed to render the voucher: {error}',
98
122
  }
99
123
 
100
124
  export type UsageKey = keyof typeof zh
@@ -259,6 +259,19 @@
259
259
  opacity: 0.6;
260
260
  }
261
261
 
262
+ /* Token 银行:票面 canvas 按容器宽度缩放;按钮行沿用 ghost 按钮样式。 */
263
+ .voucherPreview canvas {
264
+ display: block;
265
+ width: 100%;
266
+ height: auto;
267
+ border-radius: 8px;
268
+ }
269
+
270
+ .buttonRow {
271
+ display: flex;
272
+ gap: 8px;
273
+ }
274
+
262
275
  .errorLine {
263
276
  font-size: 12px;
264
277
  opacity: 0.75;