@ponydev/dsh-model-usage 0.0.0-stage → 1.0.0

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/lib/client.js ADDED
@@ -0,0 +1,1149 @@
1
+ // dsh-model-usage · Client 半区(手写 ModuleLoader bundle,UI 全部对齐 DSH 原生规范)
2
+ // 入口:侧边栏底部(原生 42px 行,Codex 式当前供应商)/ 设置 → 模型用量。
3
+ // 组件:官方 dsh-client-ui-primitives(Modal/Button/Pill/StateDot/Tooltip/锚定弹层)。
4
+ window.__ModuleLoader__.load({
5
+ id: 'dsh-model-usage',
6
+ factory(require) {
7
+ const React = require('react')
8
+ const ReactDOM = require('react-dom')
9
+ const P = require('@deepseek-ai/dsh-client-ui-primitives')
10
+ const h = React.createElement
11
+ const { useState, useEffect, useRef, useCallback } = React
12
+
13
+ const NS = 'dsh-model-usage'
14
+
15
+ // ---------------------------------------------------------------- i18n
16
+ const zh = {
17
+ nav: '模型用量',
18
+ footName: '模型额度',
19
+ refresh: '刷新',
20
+ refreshAll: '刷新全部',
21
+ close: '关闭',
22
+ totalBalance: '总余额',
23
+ grantedBalance: '赠金',
24
+ toppedUpBalance: '充值余额',
25
+ available: '可用',
26
+ unavailable: '余额不足',
27
+ needsCred: '未配置凭据',
28
+ limitRolling: '5 小时限额',
29
+ limitWeekly: '每周限额',
30
+ remaining: '剩余 {n}%',
31
+ resetIn: '{t}后重置',
32
+ warnHigh: '已用偏高',
33
+ critHigh: '即将用尽',
34
+ relJustNow: '刚刚',
35
+ relSecAgo: '{n} 秒前',
36
+ relMinAgo: '{n} 分钟前',
37
+ relHourAgo: '{n} 小时前',
38
+ stale: '已过期 · {ago}',
39
+ waitingHost: '正在等待宿主接口(/model-usage)… 重启 dsh web 后可用',
40
+ waitingStats: '正在加载统计…',
41
+ tabOverview: '总览', tabQuota: '额度', tabLog: '请求日志', tabToday: '当天', tab7d: '近 7 天', tab30d: '近 30 天', tabsLabel: '模型用量视图',
42
+ chipAll: '全部供应商', metricTokens: 'Tokens', metricRequests: '请求数', modelsQuotaTitle: '供应商额度', filterProvider: '按供应商筛选', exportShort: 'CSV', modalStatsTitle: '今日用量', fullStatsHint: '完整统计在 设置 → 模型用量',
43
+ spendTitle: '今日实际消耗', spentWord: '支出', rechargedWord: '充值', windowSpentWord: '额度消耗', obsNote: '来自服务商余额/套餐额度变动的实测值,非估算', thTime: '时间', thInput: '输入', thOutput: '输出', thCache: '缓存读', thDur: '耗时', thTtft: '首字', noLog: '暂无请求记录', backfillNote: '已从本机会话日志回填近 30 天',
44
+ shortAccount: '账号', remainCompact: '剩 {n}%', remainPrefix: '剩 ', remainSuffix: '', statsError: '统计加载失败', retry: '重试', emptyData: '暂无数据', emptyUsage: '今天还没有调用记录',
45
+ kpiRequests: '模型调用', kpiTokens: '消耗 Tokens', kpiCacheHit: '缓存命中率',
46
+ trendTitle: '使用趋势',
47
+ thProvider: '供应商', thModel: '模型', thRequests: '请求数', thTokens: 'Tokens',
48
+ lang: 'zh',
49
+ }
50
+ const en = {
51
+ nav: 'Model usage',
52
+ footName: 'Model quota',
53
+ refresh: 'Refresh',
54
+ refreshAll: 'Refresh all',
55
+ close: 'Close',
56
+ totalBalance: 'Total',
57
+ grantedBalance: 'Granted',
58
+ toppedUpBalance: 'Topped up',
59
+ available: 'Available',
60
+ unavailable: 'Low balance',
61
+ needsCred: 'Credential missing',
62
+ limitRolling: '5-hour limit',
63
+ limitWeekly: 'Weekly limit',
64
+ remaining: '{n}% left',
65
+ resetIn: 'resets in {t}',
66
+ warnHigh: 'elevated',
67
+ critHigh: 'nearly exhausted',
68
+ relJustNow: 'now',
69
+ relSecAgo: '{n}s ago',
70
+ relMinAgo: '{n}m ago',
71
+ relHourAgo: '{n}h ago',
72
+ stale: 'stale · {ago}',
73
+ waitingHost: 'Waiting for the host API (/model-usage)… restart dsh web to enable it',
74
+ waitingStats: 'Loading stats…',
75
+ tabOverview: 'Overview', tabQuota: 'Quota', tabLog: 'Request log', tabToday: 'Today', tab7d: 'Last 7 days', tab30d: 'Last 30 days', tabsLabel: 'Model usage views',
76
+ chipAll: 'All providers', metricTokens: 'Tokens', metricRequests: 'Requests', modelsQuotaTitle: 'Provider quota', filterProvider: 'Filter by provider', exportShort: 'CSV', modalStatsTitle: 'Today\'s usage', fullStatsHint: 'Full stats in Settings → Model usage',
77
+ spendTitle: 'Today\'s actual usage', spentWord: 'Spent', rechargedWord: 'Recharged', windowSpentWord: 'Quota used', obsNote: 'Measured from provider balance/quota changes — not an estimate', thTime: 'Time', thInput: 'Input', thOutput: 'Output', thCache: 'Cache', thDur: 'Duration', thTtft: 'TTFT', noLog: 'No requests yet', backfillNote: 'Backfilled from local session logs (30d)',
78
+ shortAccount: 'Account', remainCompact: '{n}% left', remainPrefix: '', remainSuffix: ' left', statsError: 'Failed to load stats', retry: 'Retry', emptyData: 'No data', emptyUsage: 'No calls today yet',
79
+ kpiRequests: 'Model calls', kpiTokens: 'Tokens', kpiCacheHit: 'Cache hit rate',
80
+ trendTitle: 'Usage trend',
81
+ thProvider: 'Provider', thModel: 'Model', thRequests: 'Requests', thTokens: 'Tokens',
82
+ lang: 'en',
83
+ }
84
+ const dictOf = (t) => (t || (String(navigator.language || '').toLowerCase().startsWith('zh') ? zh : en))
85
+ const trWith = (t) => {
86
+ const d = dictOf(t)
87
+ return (key, vars) => {
88
+ let s = d[key] != null ? d[key] : (zh[key] != null ? zh[key] : key)
89
+ if (vars) for (const k of Object.keys(vars)) s = s.split('{' + k + '}').join(String(vars[k]))
90
+ return s
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------- css(行规格照抄 VOzbGW_trigger*,全部官方 token)
95
+ const CSS = `
96
+ .dmu-row{display:flex;align-items:center;gap:8px}
97
+ .dmu-footrow{flex:none;width:calc(100% - 16px);margin:2px 8px 4px}
98
+ .dmu-footcard{background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#fff));border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l1,#e3e7ed));border-radius:var(--dsw-radius-lg,14px);padding:10px 12px;cursor:pointer}
99
+ .dmu-footcard:focus-visible,.dmu-railbtn:focus-visible,.dmu-rbtn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176E6));outline-offset:2px}
100
+ .dmu-footcard:hover{border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1,#d8dce2))}
101
+ .dmu-footcard .name{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary,#191f28);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
102
+ .dmu-footcard .metric{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary,#637182);font-variant-numeric:tabular-nums;white-space:nowrap}
103
+ .dmu-footcard .metric b{font-weight:650}
104
+ .dmu-mwin{margin-top:8px}
105
+ .dmu-mwin:first-child{margin-top:9px}
106
+ .dmu-mwin .t{display:flex;font-size:10.5px;color:var(--dsw-alias-label-secondary,#637182);margin-bottom:3px}
107
+ .dmu-mwin .t b{margin-left:auto;color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums}
108
+ .dmu-mwin .track{height:6px;border-radius:4px;background:var(--dsw-alias-bg-base,#e4e8ee);overflow:hidden}
109
+ .dmu-mwin .fill{height:100%;border-radius:4px;transition:width .4s ease}
110
+ .dmu-railbtn{width:36px;height:36px;border-radius:var(--dsw-radius-md);display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative}
111
+ .dmu-railbtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
112
+ .dmu-raildot{position:absolute;top:4px;right:4px}
113
+ .dmu-rbtn{width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:none;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex;font-size:12px;flex:none}
114
+ .dmu-rbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
115
+ .dmu-logo{border-radius:50%;display:flex;align-items:center;justify-content:center;flex:none;user-select:none;overflow:hidden}
116
+ .dmu-pop{width:300px;background:var(--dsw-alias-bg-layer-2,#fff);border:0;border-radius:var(--dsw-radius-lg,14px);box-shadow:var(--dsw-shadow-lv2,0 12px 40px rgba(2,6,16,.16));padding:13px 14px 11px;position:fixed;z-index:2147483000}
117
+ .dmu-pop .nm{font-size:var(--dsh-content-font-size-secondary,13px);font-weight:600;color:var(--dsw-alias-label-primary,#191f28)}
118
+ .dmu-pop .win{margin-top:10px}
119
+ .dmu-pop .win .t{display:flex;font-size:11px;color:var(--dsw-alias-label-secondary,#637182);margin-bottom:4px}
120
+ .dmu-pop .win .t b{margin-left:auto;color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums}
121
+ .dmu-pop .track{height:7px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-bg-base,#e4e8ee);overflow:hidden}
122
+ .dmu-pop .fill{height:100%;border-radius:var(--dsw-radius-sm,6px)}
123
+ .dmu-pop .foot{margin-top:10px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1,#e3e7ed);display:flex;align-items:center;font-size:11px;color:var(--dsw-alias-link,var(--dsw-alias-state-business-primary,#4176E6));cursor:pointer}
124
+ .dmu-pop .foot:hover{text-decoration:underline}
125
+ .dmu-modal-w{width:640px;max-width:calc(100vw - 48px)}
126
+ .dmu-mbody{display:flex;flex-direction:column;gap:12px}
127
+ .dmu-mbody > :first-child{padding-bottom:6px}
128
+ .dmu-mbody > :nth-child(2){margin-top:6px;max-height:58vh;overflow:auto;padding-bottom:6px}
129
+ .dmu-mbody .dmu-pcard{margin:12px 0 0}
130
+ .dmu-mbody .dmu-pcard:first-child{margin-top:0}
131
+ .dmu-mbody .dmu-kicker{margin:12px 0 0}
132
+ .dmu-sumhead{display:flex;align-items:center;gap:8px}
133
+ .dmu-sumhead .k{font-size:10.5px;font-weight:700;letter-spacing:1px;color:var(--dsw-alias-label-tertiary,#98a2b3)}
134
+ .dmu-summrow{display:flex;align-items:flex-end;gap:26px;margin-top:11px}
135
+ .dmu-sumstat .k{font-size:10.5px;color:var(--dsw-alias-label-secondary,#637182)}
136
+ .dmu-sumstat .v{margin-top:3px;font-size:16px;font-weight:700;color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums;line-height:1}
137
+ .dmu-spark{display:flex;align-items:flex-end;gap:3px;height:22px;width:120px;margin-left:auto}
138
+ .dmu-spark i{flex:1;background:var(--dsw-alias-state-business-primary,#4176E6);border-radius:2px 2px 0 0;min-height:2px;display:block;opacity:.85}
139
+ .dmu-sumtext{margin-top:9px;font-size:10.5px;color:var(--dsw-alias-label-tertiary,#98a2b3)}
140
+ .dmu-kicker{font-size:10.5px;font-weight:700;letter-spacing:1px;color:var(--dsw-alias-label-tertiary,#98a2b3);margin:6px 0 6px}
141
+ .dmu-pcard{background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#fff));border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l1,#e3e7ed));border-radius:var(--dsw-radius-lg,14px);padding:13px 15px}
142
+ .dmu-pcard.crit{border-top:2px solid var(--dsw-alias-state-error-primary,#f04438)}
143
+ .dmu-pcard .ph{display:flex;align-items:center;gap:8px;font-size:var(--dsh-content-font-size-secondary,13px);font-weight:600;color:var(--dsw-alias-label-primary,#191f28);white-space:nowrap}
144
+ .dmu-pcard .ph .dmu-pill{white-space:nowrap}
145
+ .dmu-pcard .route{font-size:10.5px;color:var(--dsw-alias-label-tertiary,#98a2b3);font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:26%}
146
+ .dmu-big{margin-top:11px;font-size:28px;font-weight:700;letter-spacing:-.5px;line-height:1;color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums}
147
+ .dmu-big small{font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary,#637182);margin-left:3px;letter-spacing:0}
148
+ .dmu-cap{margin-top:6px;font-size:11px;color:var(--dsw-alias-label-secondary,#637182)}
149
+ .dmu-seg{display:flex;height:10px;border-radius:var(--dsw-radius-sm,6px);overflow:hidden;margin-top:12px;background:var(--dsw-alias-bg-base,#e4e8ee)}
150
+ .dmu-seg span{height:100%}
151
+ .dmu-legend{display:flex;gap:14px;margin-top:8px;font-size:10.5px;color:var(--dsw-alias-label-secondary,#637182)}
152
+ .dmu-legend i{width:8px;height:8px;border-radius:3px;display:inline-block;margin-right:5px;vertical-align:-1px}
153
+ .dmu-pcard .winrow{margin-top:12px;display:flex;flex-direction:column;gap:12px}
154
+ .dmu-pcard .win .t{display:flex;align-items:baseline;font-size:11.5px;color:var(--dsw-alias-label-secondary,#637182);margin-bottom:5px}
155
+ .dmu-pcard .win .t .lb{font-weight:600;color:var(--dsw-alias-label-primary,#191f28);margin-right:8px}
156
+ .dmu-pcard .win .t b{margin-left:auto;font-size:12.5px;color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums}
157
+ .dmu-pcard .win .track{height:10px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-bg-base,#e4e8ee);overflow:hidden}
158
+ .dmu-pcard .win .fill{height:100%;border-radius:var(--dsw-radius-sm,6px);transition:width .4s ease}
159
+ .dmu-pcard .win .f{display:flex;margin-top:5px;font-size:10.5px;color:var(--dsw-alias-label-tertiary,#98a2b3)}
160
+ .dmu-pcard .win .f .wh{margin-left:auto;font-weight:600}
161
+ .dmu-errline{display:flex;gap:8px;align-items:flex-start;font-size:11px;color:var(--dsw-alias-state-error-primary,#f04438);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#f04438) 8%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary,#f04438) 25%,transparent);padding:7px 10px;border-radius:var(--dsw-radius-md,10px);margin-top:10px;word-break:break-all}
162
+ .dmu-warnline{color:var(--dsw-alias-state-warn-primary,#f79009);font-size:11px;margin-top:10px}
163
+ .dmu-mutedline{color:var(--dsw-alias-label-tertiary,#98a2b3);font-size:11px;margin-top:10px}
164
+ .dmu-settings{padding:4px 2px}
165
+ .dmu-settings h2{font-size:16px;font-weight:650;margin:0 0 2px;color:var(--dsw-alias-label-primary,#191f28)}
166
+ .dmu-settings .desc{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#98a2b3);margin-bottom:14px}
167
+ .dmu-settings .list{display:flex;flex-direction:column;gap:12px}
168
+ .dmu-empty{padding:18px 12px;text-align:center;font-size:11.5px;color:var(--dsw-alias-label-tertiary,#98a2b3)}
169
+ .dmu-settings,.dmu-mbody{--dmu-violet:#7c5cf0}
170
+ @media (prefers-reduced-motion:reduce){.dmu-pcard .win .fill,.dmu-mwin .fill{transition:none}}
171
+ .dmu-kpis{display:flex;gap:10px;flex-wrap:wrap}
172
+ .dmu-kpi{flex:1;min-width:120px;background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#fff));border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l1,#e3e7ed));border-radius:var(--dsw-radius-lg,14px);padding:12px 14px}
173
+ .dmu-kpi .k{font-size:10.5px;color:var(--dsw-alias-label-tertiary,#98a2b3)}
174
+ .dmu-kpi .v{margin-top:5px;font-size:22px;font-weight:700;letter-spacing:-.3px;color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums}
175
+ .dmu-kpi .v small{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#637182);margin-left:2px}
176
+ .dmu-chart{background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#fff));border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l1,#e3e7ed));border-radius:var(--dsw-radius-lg,14px);padding:14px 16px 10px;margin-top:12px}
177
+ .dmu-chart .bars{display:flex;align-items:flex-end;gap:3px;height:120px}
178
+ .dmu-chart .bar{flex:1;border-radius:3px 3px 1px 1px;background:var(--dsw-alias-state-business-primary,#4176E6);min-height:2px;position:relative}
179
+ .dmu-chart .bar:hover{opacity:.75}
180
+ .dmu-chart .labels{display:flex;gap:3px;margin-top:5px}
181
+ .dmu-chart .labels span{flex:1;text-align:center;font-size:9px;color:var(--dsw-alias-label-tertiary,#98a2b3);overflow:visible;white-space:nowrap}
182
+ .dmu-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:12px;background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#fff));border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l1,#e3e7ed));border-radius:var(--dsw-radius-lg,14px);overflow:hidden}
183
+ .dmu-table th{ text-align:left;font-size:10.5px;font-weight:600;color:var(--dsw-alias-label-tertiary,#98a2b3);padding:9px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,#e3e7ed)}
184
+ .dmu-table td{padding:9px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,#e3e7ed);color:var(--dsw-alias-label-primary,#191f28);font-variant-numeric:tabular-nums}
185
+ .dmu-table tr:last-child td{border-bottom:none}
186
+ .dmu-table .num{text-align:right;font-variant-numeric:tabular-nums}
187
+ .dmu-table th.num{text-align:right}
188
+ `
189
+
190
+ // ---------------------------------------------------------------- helpers
191
+ const curSym = (cur) => (cur === 'CNY' ? '¥' : cur === 'USD' ? '$' : '')
192
+ const shortName = (route, tr) =>
193
+ route === 'deepseek-official' ? 'DeepSeek'
194
+ : route === 'deepseek-account' ? (tr ? tr('shortAccount') : '账号')
195
+ : route && route.startsWith('zai') ? 'GLM'
196
+ : route || ''
197
+
198
+ function fmtResetLong(resetAt, tr) {
199
+ if (!resetAt) return ''
200
+ const d = resetAt - Date.now()
201
+ if (d <= 0) return ''
202
+ const days = Math.floor(d / 86400000)
203
+ const hh = Math.floor((d % 86400000) / 3600000)
204
+ const mm = Math.floor((d % 3600000) / 60000)
205
+ const zhMode = tr('lang') === 'zh'
206
+ if (zhMode) {
207
+ if (days > 0) return days + ' 天 ' + hh + ' 小时'
208
+ if (hh > 0) return hh + ' 小时 ' + mm + ' 分钟'
209
+ return Math.max(1, mm) + ' 分钟'
210
+ }
211
+ if (days > 0) return days + 'd ' + hh + 'h'
212
+ if (hh > 0) return hh + 'h ' + mm + 'm'
213
+ return Math.max(1, mm) + 'm'
214
+ }
215
+
216
+ function relTime(ts, tr) {
217
+ if (!ts) return ''
218
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000))
219
+ if (s < 10) return tr('relJustNow')
220
+ if (s < 60) return tr('relSecAgo', { n: s })
221
+ if (s < 3600) return tr('relMinAgo', { n: Math.floor(s / 60) })
222
+ return tr('relHourAgo', { n: Math.floor(s / 3600) })
223
+ }
224
+
225
+ function pctColor(pc) {
226
+ return pc >= 80 ? 'var(--dsw-alias-state-error-primary,#f04438)' : pc >= 50 ? 'var(--dsw-alias-state-warn-primary,#f79009)' : 'var(--dsw-alias-state-success-primary,#12b76a)'
227
+ }
228
+ function dotState(p) {
229
+ const ws = (p && p.windows) || []
230
+ if (ws.length) {
231
+ const max = Math.max.apply(null, ws.map((w) => w.usedPct || 0))
232
+ return max >= 80 ? 'error' : max >= 50 ? 'warning' : 'done'
233
+ }
234
+ if (p && p.balance) return p.balance.available === false ? 'error' : 'done'
235
+ return 'idle'
236
+ }
237
+ function statusColor(p) {
238
+ const st = dotState(p)
239
+ return st === 'error' ? 'var(--dsw-alias-state-error-primary,#f04438)' : st === 'warning' ? 'var(--dsw-alias-state-warn-primary,#f79009)' : st === 'done' ? 'var(--dsw-alias-state-success-primary,#12b76a)' : 'var(--dsw-alias-label-tertiary,#98a2b3)'
240
+ }
241
+
242
+ function summaryOf(snap, tr) {
243
+ const provs = ((snap && snap.providers) || []).filter((p) => p && (p.balance || (p.windows && p.windows.length)))
244
+ const multi = provs.length > 1
245
+ const trFn = tr || ((k, v) => {
246
+ let out = (zh[k] != null ? zh[k] : k)
247
+ if (v) for (const kk of Object.keys(v)) out = out.split('{' + kk + '}').join(String(v[kk]))
248
+ return out
249
+ })
250
+ const parts = []
251
+ for (const p of provs) {
252
+ const nm = multi ? shortName(p.route, trFn) + ' ' : ''
253
+ let seg = ''
254
+ if (p.balance) seg += curSym(p.balance.currency) + p.balance.total
255
+ if (p.windows && p.windows.length) {
256
+ seg += (seg ? ' · ' : '') + p.windows.map((w) => w.label + ' ' + trFn('remainCompact', { n: 100 - Math.round(w.usedPct) })).join(' · ')
257
+ }
258
+ if (seg) parts.push(nm + seg)
259
+ }
260
+ return parts.join(' · ')
261
+ }
262
+ function hasDisplayable(snap) {
263
+ return !!((snap && snap.providers) || []).some((p) => p && (p.balance || (p.windows && p.windows.length)))
264
+ }
265
+
266
+ // ---- 原型归类 + 紧急度 + 分组排序
267
+ function kindOf(p) {
268
+ if (p && p.windows && p.windows.length) return 'sub'
269
+ if (p && p.balance) return p.adapter === 'deepseek-account' ? 'wallet' : 'api'
270
+ return 'other'
271
+ }
272
+ function urgencyOf(p) {
273
+ if (p && p.windows && p.windows.length) return Math.max.apply(null, p.windows.map((w) => w.usedPct || 0))
274
+ if (p && p.balance && p.balance.available === false) return 100
275
+ return 0
276
+ }
277
+ function groupProviders(list, currentRoute) {
278
+ const order = [
279
+ { key: 'current', labelKey: 'groupCurrent' },
280
+ { key: 'sub', labelKey: 'groupSub' },
281
+ { key: 'api', labelKey: 'groupApi' },
282
+ { key: 'wallet', labelKey: 'groupWallet' },
283
+ { key: 'other', labelKey: 'groupOther' },
284
+ ]
285
+ const groups = { current: [], sub: [], api: [], wallet: [], other: [] }
286
+ const rest = []
287
+ for (const p of list || []) {
288
+ if (currentRoute && p.route === currentRoute) groups.current.push(p)
289
+ else rest.push(p)
290
+ }
291
+ rest
292
+ .slice()
293
+ .sort((a, b) => {
294
+ const ka = ['sub', 'api', 'wallet', 'other'].indexOf(kindOf(a))
295
+ const kb = ['sub', 'api', 'wallet', 'other'].indexOf(kindOf(b))
296
+ if (ka !== kb) return ka - kb
297
+ return urgencyOf(b) - urgencyOf(a)
298
+ })
299
+ .forEach((p) => groups[kindOf(p)].push(p))
300
+ const out = order
301
+ .map((o) => ({ key: o.key, labelKey: o.labelKey, items: groups[o.key] }))
302
+ .filter((g) => g.items.length > 0)
303
+ const nonCurrent = out.filter((g) => g.key !== 'current')
304
+ if (!groups.current.length && nonCurrent.length <= 1) return null
305
+ return out
306
+ }
307
+
308
+ // ---------------------------------------------------------------- api
309
+ function createApi(ctx) {
310
+ const unwrap = async (endpoint, payload) => {
311
+ const res = await fetch('model-usage/' + endpoint, {
312
+ method: 'POST',
313
+ headers: { 'content-type': 'application/json' },
314
+ body: JSON.stringify({ endpoint, payload }),
315
+ })
316
+ if (!res.ok) throw new Error('HTTP ' + res.status)
317
+ const r = await res.json()
318
+ if (r && r.ok) return r.value
319
+ throw new Error((r && r.error && r.error.message) || ('rpc failed: ' + endpoint))
320
+ }
321
+ return {
322
+ snapshot: (opts) => unwrap('snapshot', opts || {}),
323
+ sessionProvider: (sessionId) => unwrap('session-provider', { sessionId }),
324
+ stats: (opts) => unwrap('stats', opts || {}),
325
+ config: () => unwrap('config', {}),
326
+ }
327
+ }
328
+
329
+ let CFG_PROMISE = null
330
+ function useCfg(api) {
331
+ const [cfg, setCfg] = useState(null)
332
+ useEffect(() => {
333
+ let alive = true
334
+ CFG_PROMISE = CFG_PROMISE || api.config().catch(() => null)
335
+ CFG_PROMISE.then((c) => { if (!alive) return; setCfg(c) })
336
+ return () => { alive = false }
337
+ }, [])
338
+ return cfg
339
+ }
340
+
341
+ function useSnapshot(api) {
342
+ const [snap, setSnap] = useState(null)
343
+ const [, setTick] = useState(0)
344
+ const [busy, setBusy] = useState(false)
345
+ useEffect(() => {
346
+ let alive = true
347
+ const load = async (force) => {
348
+ try {
349
+ const s = await api.snapshot(force ? { refresh: true } : {})
350
+ if (!alive) return
351
+ setSnap(s)
352
+ } catch {}
353
+ }
354
+ load()
355
+ const iv = setInterval(() => { if (document.visibilityState === 'visible') load() }, 60000)
356
+ const tv = setInterval(() => setTick((x) => x + 1), 10000)
357
+ const vis = () => { if (document.visibilityState === 'visible') load() }
358
+ document.addEventListener('visibilitychange', vis)
359
+ return () => {
360
+ alive = false
361
+ clearInterval(iv)
362
+ clearInterval(tv)
363
+ document.removeEventListener('visibilitychange', vis)
364
+ }
365
+ }, [])
366
+ const refresh = useCallback(async () => {
367
+ setBusy(true)
368
+ try { setSnap(await api.snapshot({ refresh: true })) } catch {} finally { setBusy(false) }
369
+ }, [])
370
+ return { snap, busy, refresh }
371
+ }
372
+
373
+ function useUsageSummary(api) {
374
+ const [stats, setStats] = useState(null)
375
+ useEffect(() => {
376
+ let alive = true
377
+ api.stats({ days: 7 }).then((v) => { if (alive) setStats(v) }).catch(() => {})
378
+ return () => { alive = false }
379
+ }, [])
380
+ return stats
381
+ }
382
+
383
+ /** 当前供应商控制器:客户端会话投影(模型切换即时生效)+ 宿主 lastActive 兜底 */
384
+ function createSelectionController(ctx, api) {
385
+ const ctrl = {
386
+ route: null,
387
+ listeners: new Set(),
388
+ get() { return this.route },
389
+ set(r) {
390
+ if (!r || r === this.route) return
391
+ this.route = r
392
+ for (const l of [...this.listeners]) { try { l(r) } catch {} }
393
+ },
394
+ subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
395
+ }
396
+ api.sessionProvider(null).then((r) => {
397
+ if (r && r.route && !ctrl.route) ctrl.set(r.route)
398
+ }).catch(() => {})
399
+ try {
400
+ const sessions = ctx.sessions
401
+ const attached = new WeakSet()
402
+ const attach = (session) => {
403
+ if (!session || attached.has(session)) return
404
+ try {
405
+ const face = session.projections.faceOf('modelSelection')
406
+ attached.add(session)
407
+ const read = () => {
408
+ try {
409
+ const snapv = face.getSnapshot()
410
+ const pick = (snapv && (snapv.next || snapv.lastUsed)) || null
411
+ if (pick && pick.provider) ctrl.set(pick.provider)
412
+ } catch {}
413
+ }
414
+ read()
415
+ face.subscribe(read)
416
+ } catch {}
417
+ }
418
+ const attachAll = () => {
419
+ try {
420
+ const snap = sessions.list.getSnapshot()
421
+ for (const sid of Object.keys(snap.byId || {})) {
422
+ try {
423
+ const b = sessions.binding(sid)
424
+ if (b && b.session) attach(b.session)
425
+ } catch {}
426
+ }
427
+ } catch {}
428
+ }
429
+ attachAll()
430
+ sessions.list.subscribe(attachAll)
431
+ } catch {}
432
+ return ctrl
433
+ }
434
+
435
+ // ---------------------------------------------------------------- atoms
436
+ const LOGO_SLUGS = [
437
+ [/^deepseek/, 'deepseek'],
438
+ [/^zai/, 'zai'],
439
+ [/^openai$/, 'openai'],
440
+ [/^anthropic/, 'anthropic'],
441
+ [/^(google-)?gemini/, 'googlegemini'],
442
+ [/^moonshot/, 'moonshotai'],
443
+ [/^kimi/, 'kimi'],
444
+ [/^xai/, 'xai'],
445
+ ]
446
+ function logoSlug(route) {
447
+ for (const [re, slug] of LOGO_SLUGS) if (re.test(route || '')) return slug
448
+ return null
449
+ }
450
+
451
+ function Logo({ route, size = 24 }) {
452
+ const [failed, setFailed] = useState(false)
453
+ const slug = logoSlug(route)
454
+ if (slug && !failed) {
455
+ return h('span', {
456
+ className: 'dmu-logo',
457
+ style: { width: size, height: size, background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l1,#e3e7ed)' },
458
+ title: route || '',
459
+ },
460
+ h('img', {
461
+ src: 'model-usage/logo/' + slug + '.svg',
462
+ alt: route || '', width: size - 6, height: size - 6,
463
+ style: { display: 'block', margin: 'auto', objectFit: 'contain' },
464
+ onError: () => setFailed(true),
465
+ }))
466
+ }
467
+ const s = { width: size, height: size, fontSize: Math.round(size * 0.46) }
468
+ let bg = 'linear-gradient(135deg,#8b93a1,#6b7480)'
469
+ let ch = '◇'
470
+ if (route === 'deepseek-official' || route === 'deepseek') { bg = 'linear-gradient(135deg,#4D6BFE,#7a94ff)'; ch = 'D' }
471
+ else if (route === 'deepseek-account') { bg = 'linear-gradient(135deg,#4D6BFE,#7c5cf0)'; ch = 'A' }
472
+ else if (route && route.startsWith('zai')) { bg = 'linear-gradient(135deg,#0ea5a0,#12b76a)'; ch = 'G' }
473
+ return h('span', { className: 'dmu-logo', style: { ...s, background: bg } }, ch)
474
+ }
475
+
476
+ function StatusPill({ ok, okText, badText }) {
477
+ return h(P.Pill, { active: false, style: {
478
+ background: ok ? 'color-mix(in srgb,var(--dsw-alias-state-success-primary,#12b76a) 12%,transparent)' : 'color-mix(in srgb,var(--dsw-alias-state-error-primary,#f04438) 12%,transparent)',
479
+ color: ok ? 'var(--dsw-alias-state-success-primary,#12b76a)' : 'var(--dsw-alias-state-error-primary,#f04438)',
480
+ fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 999,
481
+ } }, ok ? '✓ ' + okText : '✕ ' + badText)
482
+ }
483
+ function PlanPill({ children }) {
484
+ return h(P.Pill, { active: true, style: { fontSize: 10, fontWeight: 600 } }, children)
485
+ }
486
+
487
+ /** 图1 规范式窗口行:名称 + 剩余 N% + 重置倒计时 + 剩余填充细条 */
488
+ function WinRow({ w, tr }) {
489
+ const used = Math.round(w.usedPct)
490
+ const remain = 100 - used
491
+ const resetLong = fmtResetLong(w.resetAt, tr)
492
+ const fillColor = used >= 80 ? 'var(--dsw-alias-state-error-primary,#f04438)' : used >= 50 ? 'var(--dsw-alias-state-warn-primary,#f79009)' : 'var(--dsw-alias-label-primary,#191f28)'
493
+ return h('div', { className: 'win' },
494
+ h('div', { className: 't' },
495
+ h('span', { className: 'lb' }, w.key === 'rolling' ? tr('limitRolling') : tr('limitWeekly')),
496
+ h('b', null, tr('remaining', { n: remain }))),
497
+ h('div', { className: 'track' },
498
+ h('div', { className: 'fill', style: { width: remain + '%', background: fillColor } })),
499
+ h('div', { className: 'f' },
500
+ h('span', null, resetLong ? tr('resetIn', { t: resetLong }) : ''),
501
+ used >= 50 ? h('span', { className: 'wh', style: { color: used >= 80 ? 'var(--dsw-alias-state-error-primary,#f04438)' : 'var(--dsw-alias-state-warn-primary,#f79009)' } }, used >= 80 ? tr('critHigh') : tr('warnHigh')) : null),
502
+ )
503
+ }
504
+
505
+ /** 四原型卡片 */
506
+ function ProviderCard({ p, tr }) {
507
+ const kind = kindOf(p)
508
+ const maxPct = urgencyOf(p)
509
+ const kids = []
510
+
511
+ const headerKids = [h(Logo, { route: p.route, size: 22 }), h('span', null, p.displayName || p.route)]
512
+ if (p.route) headerKids.push(h('span', { className: 'route' }, p.route))
513
+ if (kind === 'sub' && p.plan) headerKids.push(h(PlanPill, null, p.plan))
514
+ if (kind === 'api' && p.balance) {
515
+ headerKids.push(h(StatusPill, { ok: p.balance.available !== false, okText: tr('available'), badText: tr('unavailable') }))
516
+ }
517
+ headerKids.push(h('span', { className: 'dmu-meta' },
518
+ p.status === 'stale'
519
+ ? tr('stale', { ago: relTime(p.fetchedAt, tr) })
520
+ : p.fetchedAt ? relTime(p.fetchedAt, tr) : ''))
521
+ kids.push(h('div', { className: 'ph', key: 'ph' }, headerKids))
522
+
523
+ if (kind === 'sub') {
524
+ kids.push(h('div', { className: 'winrow', key: 'win' },
525
+ p.windows.map((w, i) => h(WinRow, { key: w.key || i, w, tr }))))
526
+ }
527
+
528
+ if (kind === 'api' && p.balance) {
529
+ kids.push(h('div', { className: 'dmu-big', key: 'big' },
530
+ curSym(p.balance.currency) + p.balance.total,
531
+ h('small', null, p.balance.currency || '')))
532
+ kids.push(h('div', { className: 'dmu-cap', key: 'cap' },
533
+ tr('totalBalance') + ' · ' + tr('toppedUpBalance') + ' ' + curSym(p.balance.currency) + (p.balance.toppedUp || '0')
534
+ + ' · ' + tr('grantedBalance') + ' ' + curSym(p.balance.currency) + (p.balance.granted || '0')))
535
+ }
536
+
537
+ if (kind === 'wallet' && p.balance) {
538
+ const totalN = Number(p.balance.total) || 0
539
+ const topN = Number(p.balance.toppedUp) || 0
540
+ const graN = Number(p.balance.granted) || 0
541
+ const base = (totalN > 0) ? totalN : ((topN + graN) || 1)
542
+ const wTop = Math.round((topN / base) * 100)
543
+ const wGra = Math.max(0, 100 - wTop)
544
+ kids.push(h('div', { className: 'dmu-big', key: 'big' },
545
+ curSym(p.balance.currency) + p.balance.total,
546
+ h('small', null, tr('totalBalance'))))
547
+ kids.push(h('div', { className: 'dmu-seg', key: 'seg' },
548
+ h('span', { style: { width: wTop + '%', background: 'var(--dsw-alias-state-business-primary,#4176E6)' } }),
549
+ h('span', { style: { width: wGra + '%', background: 'var(--dmu-violet,#7c5cf0)' } })))
550
+ kids.push(h('div', { className: 'dmu-legend', key: 'seg-leg' },
551
+ h('span', null, h('i', { style: { background: 'var(--dsw-alias-state-business-primary,#4176E6)' } }), tr('toppedUpBalance') + ' ' + curSym(p.balance.currency) + (p.balance.toppedUp || '0')),
552
+ h('span', null, h('i', { style: { background: 'var(--dmu-violet,#7c5cf0)' } }), tr('grantedBalance') + ' ' + curSym(p.balance.currency) + (p.balance.granted || '0'))))
553
+ }
554
+
555
+ if (p.status === 'needs-credential') {
556
+ kids.push(h('div', { className: 'dmu-warnline', key: 'st' }, '⚠ ' + tr('needsCred') + ':' + ((p.credential && p.credential.refs) || []).join(' / ')))
557
+ } else if (p.error) {
558
+ kids.push(h('div', { className: 'dmu-errline', key: 'st' }, '⚠ ' + (p.error.message || '')))
559
+ }
560
+
561
+ const cls = 'dmu-pcard' + (kind === 'sub' && maxPct >= 80 ? ' crit' : '')
562
+ return h('div', { className: cls }, kids)
563
+ }
564
+
565
+ function renderGroups(list, tr, currentRoute) {
566
+ // 与第一方「模型」页同规:未登录的账号路由、无额度接口的供应商不渲染
567
+ const filtered = (list || []).filter((p) => p && p.status !== 'idle' && p.status !== 'needs-signin')
568
+ const groups = groupProviders(filtered, currentRoute)
569
+ const out = []
570
+ if (groups === null) {
571
+ for (const p of filtered) out.push(h(ProviderCard, { key: p.route, p, tr }))
572
+ return out
573
+ }
574
+ let first = true
575
+ for (const g of groups) {
576
+ out.push(h('div', { className: 'dmu-kicker', key: 'k-' + g.key }, tr(g.labelKey)))
577
+ for (const p of g.items) out.push(h(ProviderCard, { key: p.route + ':' + g.key, p, tr }))
578
+ first = false
579
+ }
580
+ return out
581
+ }
582
+
583
+ function UsageModal({ api, snap, busy, refresh, onClose, tr, sessionId }) {
584
+ useEffect(() => {
585
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
586
+ document.addEventListener('keydown', onKey)
587
+ return () => document.removeEventListener('keydown', onKey)
588
+ }, [onClose])
589
+ const providers = (snap && snap.providers) || []
590
+ return h(P.Modal, {
591
+ open: true,
592
+ onClose,
593
+ className: 'dmu-modal-w',
594
+ title: tr('nav'),
595
+ closeLabel: tr('close'),
596
+ contentClassName: 'dmu-mbody',
597
+ footer: h('div', { className: 'dmu-row', style: { justifyContent: 'flex-end' } },
598
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: refresh, disabled: busy }, (busy ? '… ' : '') + '↻ ' + tr('refreshAll')),
599
+ h(P.Button, { variant: 'primary', size: 'sm', onClick: onClose }, tr('close'))),
600
+ },
601
+ [
602
+ h(ModalUsageSummary, { key: 'summary', api, tr }),
603
+ ...(providers.length ? renderGroups(providers, tr, sessionId) : [h('div', { key: 'empty', className: 'dmu-mutedline' }, '…')]),
604
+ ])
605
+ }
606
+
607
+ function ModalUsageSummary({ api, tr }) {
608
+ const stats = useUsageSummary(api)
609
+ if (!stats) return null
610
+ const total7 = (stats.days || []).reduce((s2, d) => s2 + d.tokens, 0)
611
+ const max7 = Math.max.apply(null, (stats.days || []).map((d) => d.tokens).concat([1]))
612
+ const stat = (label, value) => h('div', { className: 'dmu-sumstat', key: label },
613
+ h('div', { className: 'k' }, label),
614
+ h('div', { className: 'v' }, value))
615
+ return h('div', { className: 'dmu-pcard' },
616
+ h('div', { className: 'dmu-sumhead' },
617
+ h('span', { className: 'k' }, tr('modalStatsTitle')),
618
+ h('div', { className: 'dmu-spark', role: 'img', 'aria-label': tr('trendTitle') },
619
+ (stats.days || []).map((d, i) => h('i', {
620
+ key: i,
621
+ style: { height: Math.max(2, Math.round((d.tokens / max7) * 22)) },
622
+ title: d.date + ' · ' + fmtTokens(d.tokens, tr) + ' tokens',
623
+ })))),
624
+ h('div', { className: 'dmu-summrow' },
625
+ stat(tr('kpiRequests'), String(stats.today.requests)),
626
+ stat(tr('kpiTokens'), fmtTokens(stats.today.tokens, tr)),
627
+ stats.today.cacheHitRate != null ? stat(tr('kpiCacheHit'), stats.today.cacheHitRate + '%') : null),
628
+ h('div', { className: 'dmu-sumtext' },
629
+ tr('trendTitle') + ' · ' + fmtTokens(total7, tr) + ' tokens · ' + tr('fullStatsHint')))
630
+ }
631
+
632
+ // ---------------------------------------------------------------- 入口 1:侧边栏底部(原生 42px 行 · Codex 式当前供应商)
633
+ function metricTextOf(p, tr) {
634
+ if (!p) return ''
635
+ if (p.windows && p.windows.length) {
636
+ return p.windows.map((w) => w.label + ' ' + tr('remainCompact', { n: 100 - Math.round(w.usedPct) })).join(' · ')
637
+ }
638
+ if (p.balance) return curSym(p.balance.currency) + p.balance.total
639
+ return ''
640
+ }
641
+ function metricNodeOf(p, tr) {
642
+ if (!p) return null
643
+ if (p.windows && p.windows.length) {
644
+ return h('span', null, p.windows.map((w, i) => {
645
+ const remain = 100 - Math.round(w.usedPct)
646
+ const used = Math.round(w.usedPct)
647
+ return h('span', { key: w.key || i },
648
+ i > 0 ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary,#98a2b3)' } }, ' · ') : null,
649
+ w.label + ' ' + tr('remainPrefix'),
650
+ h('b', { style: { color: pctColor(used) } }, remain + '%'),
651
+ tr('remainSuffix'))
652
+ }))
653
+ }
654
+ if (p.balance) {
655
+ return h('span', null, h('b', null, curSym(p.balance.currency) + p.balance.total))
656
+ }
657
+ return null
658
+ }
659
+
660
+ function FooterEntry(props) {
661
+ const { api, sel } = props
662
+ const tr = trWith(props.t)
663
+ const cfg = useCfg(api)
664
+ const { snap, busy, refresh } = useSnapshot(api)
665
+ const [modal, setModal] = useState(false)
666
+ const [hostRoute, setHostRoute] = useState(null)
667
+ const [selRoute, setSelRoute] = useState(sel ? sel.get() : null)
668
+ const cardRef = useRef(null)
669
+ useEffect(() => {
670
+ if (!sel) return
671
+ return sel.subscribe((r) => setSelRoute(r))
672
+ }, [sel])
673
+ useEffect(() => {
674
+ let alive = true
675
+ const load = async () => {
676
+ try {
677
+ const r = await api.sessionProvider(null)
678
+ if (!alive) return
679
+ setHostRoute(r && r.route)
680
+ } catch {}
681
+ }
682
+ load()
683
+ const iv = setInterval(load, 60000)
684
+ return () => { alive = false; clearInterval(iv) }
685
+ }, [])
686
+ const currentRoute = selRoute || hostRoute
687
+ if (cfg && cfg.entries && cfg.entries.footer === false) return null
688
+ if (!snap || !hasDisplayable(snap)) return null
689
+ const provs = ((snap && snap.providers) || []).filter((p) => p && (p.balance || (p.windows && p.windows.length)))
690
+ const current = currentRoute ? provs.find((p) => p.route === currentRoute) : null
691
+ const color = statusColor(current || (provs.find((p) => p.windows && p.windows.length) || provs[0]))
692
+ const fallbackSum = summaryOf(snap, tr)
693
+ const metricNode = metricNodeOf(current, tr)
694
+ const titleText = current ? ((current.displayName || shortName(current.route, tr)) + ' · ' + (metricTextOf(current, tr) || '')) : (tr('footName') + (fallbackSum ? ' · ' + fallbackSum : ''))
695
+ const openModal = () => setModal(true)
696
+ const guardedOpen = (e) => {
697
+ if (cardRef.current && cardRef.current.contains(e.target)) openModal()
698
+ }
699
+ const refreshBtn = h('button', {
700
+ className: 'dmu-rbtn', title: tr('refresh'), 'aria-label': tr('refresh'),
701
+ onClick: (e) => { e.stopPropagation(); refresh() },
702
+ }, busy ? '…' : '↻')
703
+ if (!props.wide) {
704
+ return h('div', { className: 'dmu-railbtn', role: 'button', tabIndex: 0, 'aria-label': tr('footName'), onClick: openModal, onKeyDown: (e) => { if (e.key === 'Enter') openModal() } },
705
+ h(P.Tooltip, { label: titleText, side: 'right', portal: true },
706
+ h('span', { style: { position: 'relative', display: 'flex' } },
707
+ h(Logo, { route: current ? current.route : 'generic', size: 18 }),
708
+ h('span', { className: 'dmu-raildot' }, h(P.StateDot, { state: dotState(current || provs.find((p) => p.windows && p.windows.length) || provs[0]), size: 8 })))))
709
+ }
710
+ // 小型卡片:弹窗卡片的迷你版(头部 + 订阅迷你窗口条 / 余额)
711
+ const bodyKids = []
712
+ if (current && current.windows && current.windows.length) {
713
+ bodyKids.push(h('div', { className: 'dmu-mwin', key: 'w0' },
714
+ h('div', { className: 't' },
715
+ h('span', null, current.windows[0].key === 'rolling' ? tr('limitRolling') : tr('limitWeekly')),
716
+ h('b', null, tr('remaining', { n: 100 - Math.round(current.windows[0].usedPct) }))),
717
+ h('div', { className: 'track' },
718
+ h('div', { className: 'fill', style: { width: (100 - Math.round(current.windows[0].usedPct)) + '%', background: current.windows[0].usedPct >= 80 ? 'var(--dsw-alias-state-error-primary,#f04438)' : current.windows[0].usedPct >= 50 ? 'var(--dsw-alias-state-warn-primary,#f79009)' : 'var(--dsw-alias-label-primary,#191f28)' } }))))
719
+ if (current.windows[1]) {
720
+ bodyKids.push(h('div', { className: 'dmu-mwin', key: 'w1' },
721
+ h('div', { className: 't' },
722
+ h('span', null, current.windows[1].key === 'rolling' ? tr('limitRolling') : tr('limitWeekly')),
723
+ h('b', null, tr('remaining', { n: 100 - Math.round(current.windows[1].usedPct) }))),
724
+ h('div', { className: 'track' },
725
+ h('div', { className: 'fill', style: { width: (100 - Math.round(current.windows[1].usedPct)) + '%', background: current.windows[1].usedPct >= 80 ? 'var(--dsw-alias-state-error-primary,#f04438)' : current.windows[1].usedPct >= 50 ? 'var(--dsw-alias-state-warn-primary,#f79009)' : 'var(--dsw-alias-label-primary,#191f28)' } }))))
726
+ }
727
+ } else if (current && current.balance) {
728
+ bodyKids.push(h('div', { className: 'dmu-big', key: 'b0', style: { fontSize: 22, marginTop: 8 } },
729
+ curSym(current.balance.currency) + current.balance.total))
730
+ } else {
731
+ bodyKids.push(h('div', { className: 'dmu-mutedline', key: 'b1', style: { marginTop: 8 } }, fallbackSum || '…'))
732
+ }
733
+ return h('div', { className: 'dmu-footrow' },
734
+ h('div', { ref: cardRef, className: 'dmu-footcard', role: 'button', tabIndex: 0, 'aria-label': tr('footName'), onClick: guardedOpen, onKeyDown: (e) => { if (e.key === 'Enter') openModal() } },
735
+ h('div', { className: 'dmu-row' },
736
+ h(Logo, { route: current ? current.route : 'generic', size: 18 }),
737
+ h('span', { className: 'name' }, current ? (shortName(current.route, tr) || tr('footName')) : tr('footName')),
738
+ h(P.StateDot, { state: dotState(current || provs.find((p) => p.windows && p.windows.length) || provs[0]), size: 8 }),
739
+ h('span', { className: 'metric' },
740
+ relTime((current && current.fetchedAt) || snap.generatedAt, tr),
741
+ refreshBtn)),
742
+ bodyKids),
743
+ modal && h(UsageModal, { api, snap, busy, refresh, onClose: () => setModal(false), tr, sessionId: currentRoute }),
744
+ )
745
+ }
746
+
747
+ // ---------------------------------------------------------------- 统计组件
748
+ function fmtTokens(n, tr) {
749
+ if (!Number.isFinite(n)) return '0'
750
+ const enMode = tr ? tr('lang') === 'en' : false
751
+ if (enMode) {
752
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
753
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
754
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
755
+ return String(Math.round(n))
756
+ }
757
+ if (n >= 1e8) return (n / 1e8).toFixed(2) + ' 亿'
758
+ if (n >= 1e4) return (n / 1e4).toFixed(1) + ' 万'
759
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
760
+ return String(Math.round(n))
761
+ }
762
+ function KpiCard({ label, value, unit }) {
763
+ return h('div', { className: 'dmu-kpi' },
764
+ h('div', { className: 'k' }, label),
765
+ h('div', { className: 'v' }, value, unit ? h('small', null, unit) : null))
766
+ }
767
+
768
+ function BarChart({ items, getLabel, formatTitle, tr }) {
769
+ const max = Math.max.apply(null, items.map((x) => x.v).concat([1]))
770
+ const total = items.reduce((s2, x) => s2 + x.v, 0)
771
+ if (total <= 0) return h('div', { className: 'dmu-empty' }, tr('emptyUsage'))
772
+ return h('div', { className: 'dmu-chart' },
773
+ h('div', { className: 'bars', role: 'img', 'aria-label': tr('trendTitle') },
774
+ items.map((x, i) => h('div', {
775
+ key: i, className: 'bar',
776
+ style: { height: Math.max(2, Math.round((x.v / max) * 116)) },
777
+ title: formatTitle(x),
778
+ }))),
779
+ h('div', { className: 'labels', 'aria-hidden': true },
780
+ items.map((x, i) => h('span', { key: i }, getLabel(x)))))
781
+ }
782
+
783
+ function DataTable({ tr, columns, rows, emptyText }) {
784
+ return h('table', { className: 'dmu-table' },
785
+ h('thead', null,
786
+ h('tr', null, columns.map((c, i) => h('th', { key: i, className: c.num ? 'num' : null }, c.label)))),
787
+ h('tbody', null,
788
+ rows.length
789
+ ? rows.map((r, ri) => h('tr', { key: ri },
790
+ r.map((cell, ci) => h('td', { key: ci, className: columns[ci] && columns[ci].num ? 'num' : null }, cell))))
791
+ : h('tr', null, h('td', { colSpan: columns.length }, h('div', { className: 'dmu-empty' }, emptyText)))))
792
+ }
793
+
794
+ function fmtMs(ms) {
795
+ if (!Number.isFinite(ms)) return '—'
796
+ if (ms >= 1000) return (ms / 1000).toFixed(1) + 's'
797
+ return Math.round(ms) + 'ms'
798
+ }
799
+ function fmtClock(t) {
800
+ const d = new Date(t)
801
+ const p2 = (n) => String(n).padStart(2, '0')
802
+ return p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds())
803
+ }
804
+
805
+ function exportCsv(stats, tr) {
806
+ const rows = []
807
+ rows.push(['模型用量统计', new Date(stats.generatedAt).toISOString()])
808
+ rows.push([])
809
+ rows.push(['指标', '值'])
810
+ rows.push([tr('kpiRequests'), stats.range.requests])
811
+ rows.push([tr('kpiTokens'), stats.range.tokens])
812
+ rows.push([tr('kpiCacheHit'), stats.range.cacheHitRate == null ? '' : stats.range.cacheHitRate + '%'])
813
+ rows.push([])
814
+ rows.push(['日期', 'Tokens', tr('kpiRequests')])
815
+ for (const d of stats.days) rows.push([d.date, d.tokens, d.requests])
816
+ rows.push([])
817
+ rows.push([tr('thProvider'), tr('thRequests'), tr('thTokens')])
818
+ for (const p of stats.providers) rows.push([p.route, p.requests, p.tokens])
819
+ rows.push([])
820
+ rows.push([tr('thModel'), tr('thRequests'), tr('thTokens')])
821
+ for (const m of stats.models) rows.push([m.provider + '/' + m.model, m.requests, m.tokens])
822
+ rows.push([])
823
+ rows.push([tr('thTime'), tr('thModel'), tr('thInput'), tr('thOutput'), tr('thCache'), tr('thDur'), tr('thTtft')])
824
+ for (const r of stats.recent || []) rows.push([new Date(r.t).toISOString(), r.p + '/' + r.m, r.i, r.o, r.cr, r.dur == null ? '' : r.dur, r.ttft == null ? '' : r.ttft])
825
+ const csv = rows.map((row) => row.map((cell) => {
826
+ const t = String(cell == null ? '' : cell)
827
+ return /[",\n]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t
828
+ }).join(',')).join('\n')
829
+ const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
830
+ const a = document.createElement('a')
831
+ a.href = URL.createObjectURL(blob)
832
+ const d = new Date()
833
+ const stamp = d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0')
834
+ a.download = 'model-usage-' + stamp + '.csv'
835
+ document.body.appendChild(a)
836
+ a.click()
837
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 1000)
838
+ }
839
+
840
+ function SpendSection({ spend, routes, tr }) {
841
+ const entries = Object.entries(spend || {}).filter(([route, v]) => {
842
+ if (!v) return false
843
+ if (v.kind === 'balance') return Number(v.spent) > 0 || Number(v.recharged) > 0
844
+ if (v.kind === 'windows') return v.windows && Object.keys(v.windows).some((k) => Number(v.windows[k]) > 0)
845
+ return false
846
+ })
847
+ if (!entries.length) return null
848
+ const nameOf = (route) => shortName(route, tr) || route
849
+ return h('div', { style: { marginTop: 14 } },
850
+ h('div', { className: 'dmu-kicker', style: { marginTop: 0 } }, tr('spendTitle')),
851
+ h('div', { className: 'dmu-kpis' },
852
+ entries.map(([route, v]) => {
853
+ let metric = null
854
+ let sub = null
855
+ if (v.kind === 'balance') {
856
+ const sym = curSym(v.currency)
857
+ metric = (v.spent > 0 ? '−' + sym + Number(v.spent).toFixed(2) : '—')
858
+ sub = v.recharged > 0 ? tr('rechargedWord') + ' +' + sym + Number(v.recharged).toFixed(2) : tr('spentWord')
859
+ } else {
860
+ const rolling = v.windows && v.windows['rolling']
861
+ const weekly = v.windows && v.windows['weekly']
862
+ metric = Number(rolling) > 0 ? '−' + Number(rolling).toFixed(1) + '%'
863
+ : Number(weekly) > 0 ? '−' + Number(weekly).toFixed(1) + '%' : '—'
864
+ const bits = []
865
+ if (Number(rolling) > 0) bits.push(tr('limitRolling'))
866
+ if (Number(weekly) > 0) bits.push(tr('limitWeekly') + ' −' + Number(weekly).toFixed(1) + '%')
867
+ sub = bits.join(' · ') || tr('windowSpentWord')
868
+ }
869
+ return h('div', { className: 'dmu-kpi', key: route },
870
+ h('div', { className: 'k' }, nameOf(route)),
871
+ h('div', { className: 'v', style: { fontSize: 20 } }, metric),
872
+ h('div', { className: 'dmu-cap', style: { marginTop: 4, fontSize: 10.5 } }, sub))
873
+ })))
874
+ }
875
+
876
+ function StatsOverview({ api, tr }) {
877
+ const [stats, setStats] = useState(null)
878
+ const [error, setError] = useState(false)
879
+ const [range, setRange] = useState('today')
880
+ const [metric, setMetric] = useState('tokens')
881
+ const [provider, setProvider] = useState('all')
882
+ const [filterOpen, setFilterOpen] = useState(false)
883
+ const rangeDays = range === 'today' ? 1 : range === '7d' ? 7 : 30
884
+ const load = useCallback(async (days) => {
885
+ try {
886
+ setStats(await api.stats({ days: days || 1 }))
887
+ setError(false)
888
+ } catch {
889
+ setError(true)
890
+ }
891
+ }, [])
892
+ useEffect(() => { load(rangeDays) }, [rangeDays])
893
+ useEffect(() => {
894
+ const iv = setInterval(() => { if (document.visibilityState === 'visible') load(rangeDays) }, 60000)
895
+ return () => clearInterval(iv)
896
+ }, [rangeDays])
897
+ if (!stats && error) {
898
+ return h('div', { className: 'dmu-row', style: { gap: 10 } },
899
+ h('span', { className: 'dmu-warnline', style: { margin: 0 } }, tr('statsError')),
900
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: () => load(rangeDays) }, tr('retry')))
901
+ }
902
+ if (!stats) return h('div', { className: 'dmu-mutedline' }, tr('waitingStats'))
903
+ const todayMode = range === 'today'
904
+ const provRows = stats.providers || []
905
+ const filteredProviders = provider === 'all' ? provRows : provRows.filter((r) => r.route === provider)
906
+ const filteredModels = provider === 'all' ? (stats.models || []) : (stats.models || []).filter((m) => m.provider === provider)
907
+ const kpiRow = todayMode
908
+ ? (provider === 'all' ? { requests: stats.today.requests, tokens: stats.today.tokens, cacheHitRate: stats.today.cacheHitRate }
909
+ : (stats.todayByProvider || []).find((r) => r.route === provider) || { requests: 0, tokens: 0, cacheHitRate: null })
910
+ : (provider === 'all' ? stats.range
911
+ : (provRows.find((r) => r.route === provider) || { requests: 0, tokens: 0, cacheHitRate: null }))
912
+ const pickVal = (x) => {
913
+ if (provider === 'all') {
914
+ return metric === 'tokens' ? x.tokens : x.requests
915
+ }
916
+ const bp = (x.byProvider && x.byProvider[provider]) || null
917
+ if (!bp) return 0
918
+ return metric === 'tokens' ? bp.tokens : bp.requests
919
+ }
920
+ const bars = (todayMode ? stats.hours : stats.days).map((x) => {
921
+ const v = pickVal(x)
922
+ const label = todayMode
923
+ ? (x.h % 4 === 0 ? String(x.h).padStart(2, '0') : '')
924
+ : x.date.slice(5)
925
+ const when = todayMode ? String(x.h).padStart(2, '0') + ':00' : x.date
926
+ return { label, v, title: when + ' · ' + fmtTokens(v, tr) + ' tokens · ' + (provider === 'all' ? x.requests : ((x.byProvider && x.byProvider[provider] && x.byProvider[provider].requests) || 0)) }
927
+ })
928
+ const rangeTab = (value, label) => h(P.Pill, {
929
+ active: range === value, onClick: () => setRange(value),
930
+ style: { padding: '4px 10px', fontSize: 11.5, whiteSpace: 'nowrap', flex: 'none' },
931
+ }, label)
932
+ const metricTab = (value, label) => h(P.Pill, {
933
+ active: metric === value, onClick: () => setMetric(value),
934
+ style: { padding: '4px 10px', fontSize: 11.5, whiteSpace: 'nowrap', flex: 'none' },
935
+ }, label)
936
+ return h('div', null,
937
+ h('div', { className: 'dmu-row', style: { gap: 5, marginBottom: 14, flexWrap: 'nowrap' } },
938
+ rangeTab('today', tr('tabToday')),
939
+ rangeTab('7d', tr('tab7d')),
940
+ rangeTab('30d', tr('tab30d')),
941
+ h('span', { style: { marginLeft: 'auto' } }),
942
+ h(P.Menu, {
943
+ open: filterOpen,
944
+ anchor: h(P.Button, {
945
+ variant: 'outline',
946
+ size: 'sm',
947
+ onClick: () => setFilterOpen((v) => !v),
948
+ 'aria-label': tr('filterProvider'),
949
+ style: { whiteSpace: 'nowrap', flex: 'none' },
950
+ }, (provider === 'all' ? tr('chipAll') : (shortName(provider, tr) || provider)) + ' ▾'),
951
+ items: [{ id: 'all', label: tr('chipAll') }].concat(provRows.map((r) => ({ id: r.route, label: shortName(r.route, tr) || r.route }))),
952
+ selectedId: provider,
953
+ onSelect: (id) => { setProvider(id); setFilterOpen(false) },
954
+ onClose: () => setFilterOpen(false),
955
+ portal: true,
956
+ align: 'end',
957
+ }),
958
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: () => exportCsv(stats, tr), style: { whiteSpace: 'nowrap', flex: 'none' } }, '↓ ' + tr('exportShort'))),
959
+
960
+ h('div', { className: 'dmu-kpis' },
961
+ h(KpiCard, { label: tr('kpiRequests'), value: String(kpiRow.requests) }),
962
+ h(KpiCard, { label: tr('kpiTokens'), value: fmtTokens(kpiRow.tokens, tr) }),
963
+ kpiRow.cacheHitRate != null
964
+ ? h(KpiCard, { label: tr('kpiCacheHit'), value: kpiRow.cacheHitRate + '%' })
965
+ : h(KpiCard, { label: tr('kpiCacheHit'), value: '—' })),
966
+ h(SpendSection, { spend: stats.spendToday, tr }),
967
+ h('div', { className: 'dmu-chart' },
968
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 } },
969
+ h('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary,#637182)' } },
970
+ tr('trendTitle') + ' · ' + (metric === 'tokens'
971
+ ? fmtTokens(bars.reduce((s2, x) => s2 + x.v, 0), tr) + ' tokens'
972
+ : bars.reduce((s2, x) => s2 + x.v, 0) + ' ' + tr('kpiRequests'))),
973
+ h('span', { style: { marginLeft: 'auto', display: 'flex', gap: 4 } },
974
+ metricTab('tokens', tr('metricTokens')),
975
+ metricTab('requests', tr('metricRequests')))),
976
+ bars.some((x) => x.v > 0)
977
+ ? h(BarChart, { items: bars, getLabel: (x) => x.label, formatTitle: (x) => x.title, tr })
978
+ : h('div', { className: 'dmu-empty' }, tr('emptyUsage'))),
979
+ h(DataTable, {
980
+ tr,
981
+ columns: [{ label: tr('thProvider') }, { label: tr('thRequests'), num: true }, { label: tr('thTokens'), num: true }],
982
+ rows: filteredProviders.map((r) => [shortName(r.route, tr) || r.route, String(r.requests), fmtTokens(r.tokens, tr)]),
983
+ emptyText: tr('emptyData'),
984
+ }),
985
+ h(DataTable, {
986
+ tr,
987
+ columns: [{ label: tr('thModel') }, { label: tr('thRequests'), num: true }, { label: tr('thTokens'), num: true }],
988
+ rows: filteredModels.slice(0, 30).map((r) => [
989
+ h('span', null,
990
+ h('span', { style: { fontFamily: 'var(--dsh-font-mono,monospace)', fontSize: 11.5 } }, r.model),
991
+ h('span', { className: 'route', style: { marginLeft: 6 } }, r.provider)),
992
+ String(r.requests),
993
+ fmtTokens(r.tokens, tr),
994
+ ]),
995
+ emptyText: tr('emptyData'),
996
+ }),
997
+ )
998
+ }
999
+
1000
+
1001
+ function LogTab({ api, tr }) {
1002
+ const [stats, setStats] = useState(null)
1003
+ const [error, setError] = useState(false)
1004
+ const load = useCallback(async () => {
1005
+ try { setStats(await api.stats({ days: 7 })); setError(false) } catch { setError(true) }
1006
+ }, [])
1007
+ useEffect(() => { load(); const iv = setInterval(() => { if (document.visibilityState === 'visible') load() }, 60000); return () => clearInterval(iv) }, [])
1008
+ if (!stats && error) {
1009
+ return h('div', { className: 'dmu-row', style: { gap: 10 } },
1010
+ h('span', { className: 'dmu-warnline', style: { margin: 0 } }, tr('statsError')),
1011
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: load }, tr('retry')))
1012
+ }
1013
+ if (!stats) return h('div', { className: 'dmu-mutedline' }, tr('waitingStats'))
1014
+ const rows = (stats.recent || []).map((r) => [
1015
+ h('span', { style: { fontVariantNumeric: 'tabular-nums' } }, fmtClock(r.t)),
1016
+ h('span', null,
1017
+ h('span', { style: { fontFamily: 'var(--dsh-font-mono,monospace)', fontSize: 11.5 } }, r.m),
1018
+ h('span', { className: 'route', style: { marginLeft: 6 } }, shortName(r.p, tr) || r.p)),
1019
+ fmtTokens(r.i, tr),
1020
+ fmtTokens(r.o, tr),
1021
+ fmtTokens(r.cr, tr),
1022
+ fmtMs(r.dur),
1023
+ fmtMs(r.ttft),
1024
+ ])
1025
+ return h('div', null,
1026
+ h('div', { className: 'dmu-row', style: { gap: 6, marginBottom: 14 } },
1027
+ h('span', { style: { marginLeft: 'auto' } },
1028
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: load }, '↻ ' + tr('refresh'))),
1029
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: () => exportCsv(stats, tr), style: { whiteSpace: 'nowrap', flex: 'none' } }, '↓ ' + tr('exportShort'))),
1030
+ h(DataTable, {
1031
+ tr,
1032
+ columns: [
1033
+ { label: tr('thTime'), num: true },
1034
+ { label: tr('thModel') },
1035
+ { label: tr('thInput'), num: true },
1036
+ { label: tr('thOutput'), num: true },
1037
+ { label: tr('thCache'), num: true },
1038
+ { label: tr('thDur'), num: true },
1039
+ { label: tr('thTtft'), num: true },
1040
+ ],
1041
+ rows,
1042
+ emptyText: tr('noLog'),
1043
+ }))
1044
+ }
1045
+
1046
+
1047
+ // ---------------------------------------------------------------- 入口 3:设置 → 模型(底部 supplier quota 区)
1048
+ function ModelsFooter(props) {
1049
+ const { api } = props
1050
+ const tr = trWith(props.t)
1051
+ const { snap } = useSnapshot(api)
1052
+ if (!snap || !hasDisplayable(snap)) return null
1053
+ const provs = ((snap && snap.providers) || []).filter((p) => p && (p.balance || (p.windows && p.windows.length)))
1054
+ return h('div', { className: 'dmu-settings', style: { marginTop: 8 } },
1055
+ h('div', { className: 'dmu-kicker', style: { marginTop: 0 } }, tr('modelsQuotaTitle')),
1056
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
1057
+ provs.map((p) => h('div', { key: p.route, className: 'dmu-row', style: { gap: 8 } },
1058
+ h(Logo, { route: p.route, size: 18 }),
1059
+ h('span', { style: { fontSize: 12.5, fontWeight: 600 } }, p.displayName || shortName(p.route, tr)),
1060
+ h(P.StateDot, { state: dotState(p), size: 8 }),
1061
+ h('span', { className: 'metric', style: { marginLeft: 'auto', fontSize: 11 } },
1062
+ summaryOf({ providers: [p] }, tr))))))
1063
+ }
1064
+
1065
+ // ---------------------------------------------------------------- 入口 2:设置页
1066
+ function SettingsEntry(props) {
1067
+ const { api } = props
1068
+ const tr = trWith(props.t)
1069
+ const [tab, setTab] = useState('overview')
1070
+ const tabs = [
1071
+ { value: 'overview', label: tr('tabOverview'), id: 'dmu-tab-overview', panelId: 'dmu-panel-overview' },
1072
+ { value: 'quota', label: tr('tabQuota'), id: 'dmu-tab-quota', panelId: 'dmu-panel-quota' },
1073
+ { value: 'log', label: tr('tabLog'), id: 'dmu-tab-log', panelId: 'dmu-panel-log' },
1074
+ ]
1075
+ return h('div', { className: 'dmu-settings' },
1076
+ h('h2', null, tr('nav')),
1077
+ h(P.SegmentedTabs, {
1078
+ items: tabs,
1079
+ value: tab,
1080
+ onChange: (v) => setTab(v),
1081
+ label: tr('tabsLabel'),
1082
+ className: 'dmu-main-tabs',
1083
+ }),
1084
+ h('div', { id: 'dmu-panel-' + tab, role: 'tabpanel', 'aria-labelledby': 'dmu-tab-' + tab, style: { marginTop: 14 } },
1085
+ tab === 'overview'
1086
+ ? h(StatsOverview, { api, tr })
1087
+ : tab === 'quota'
1088
+ ? h(QuotaTab, { api, tr })
1089
+ : h(LogTab, { api, tr })))
1090
+ }
1091
+
1092
+ function QuotaTab(props) {
1093
+ const { api } = props
1094
+ const tr = trWith(props.t)
1095
+ const { snap, busy, refresh } = useSnapshot(api)
1096
+ return h('div', null,
1097
+ h('div', { className: 'dmu-row', style: { marginBottom: 12 } },
1098
+ h(P.Button, { variant: 'primary', size: 'sm', onClick: refresh, disabled: busy }, (busy ? '… ' : '') + '↻ ' + tr('refreshAll'))),
1099
+ h('div', { className: 'list' },
1100
+ snap
1101
+ ? renderGroups(snap.providers || [], tr, null)
1102
+ : h('div', { className: 'dmu-mutedline' }, tr('waitingHost'))))
1103
+ }
1104
+
1105
+ // ---------------------------------------------------------------- apply
1106
+ return {
1107
+ inject: ['slots', 'locale', 'sessions'],
1108
+ apply(ctx) {
1109
+ const api = createApi(ctx)
1110
+
1111
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dmu: locale')
1112
+ ctx.effect(() => {
1113
+ const tag = document.createElement('style')
1114
+ tag.id = 'dsh-model-usage-style'
1115
+ tag.textContent = CSS
1116
+ document.head.appendChild(tag)
1117
+ return () => tag.remove()
1118
+ }, 'dmu: styles')
1119
+
1120
+ const selCtrl = createSelectionController(ctx, api)
1121
+
1122
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
1123
+ name: 'sidebar.footer.action',
1124
+ id: 'dsh-model-usage',
1125
+ order: -2,
1126
+ locale: NS,
1127
+ inject: () => ({ api, sel: selCtrl }),
1128
+ }, FooterEntry))
1129
+
1130
+ ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
1131
+ name: 'settings.models.footer',
1132
+ id: 'dsh-model-usage-models-footer',
1133
+ order: 10,
1134
+ locale: NS,
1135
+ inject: () => ({ api }),
1136
+ }, ModelsFooter))
1137
+
1138
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1139
+ name: 'settings.section',
1140
+ id: 'model-usage',
1141
+ order: 25,
1142
+ label: () => { try { return ctx.locale.bind(NS)('nav') } catch { return zh.nav } },
1143
+ locale: NS,
1144
+ inject: () => ({ api }),
1145
+ }, SettingsEntry))
1146
+ },
1147
+ }
1148
+ },
1149
+ })