dsh-workbuddy2api-hub 0.0.0-stage → 1.0.10
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/CHANGELOG.md +161 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/client/client.js +1354 -0
- package/cordis.patch.yml +10 -0
- package/lib/hub.js +869 -0
- package/lib/index.js +1671 -0
- package/package.json +91 -4
package/client/client.js
ADDED
|
@@ -0,0 +1,1354 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-workbuddy2api-hub',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} }
|
|
5
|
+
var exports = module.exports
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* dsh-workbuddy2api-hub 的前端部分。
|
|
10
|
+
*
|
|
11
|
+
* 两处挂载:
|
|
12
|
+
* - `settings.section`:完整面板(连接配置、模型勾选、策略、告警);
|
|
13
|
+
* - `sidebar.footer.action`:一个额度小徽标,随时能看到还剩多少。
|
|
14
|
+
*
|
|
15
|
+
* 两个插槽是两个独立的 React 根,所以它们共享本模块里的一个轻量 store:
|
|
16
|
+
* 谁先挂载谁启动轮询,都卸载了就停掉,避免后台空转。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const react = require('react')
|
|
20
|
+
const h = react.createElement
|
|
21
|
+
const useState = react.useState
|
|
22
|
+
const useEffect = react.useEffect
|
|
23
|
+
const useMemo = react.useMemo
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* UI 基础组件。DSH 并没有稳定的 primitives 包(不同发行版里可能不存在),
|
|
27
|
+
* 所以这里只在拿得到的时候借它的按钮/开关,拿不到就用原生元素——绝不能因为
|
|
28
|
+
* 一个 require 失败导致整块设置页白屏。
|
|
29
|
+
*/
|
|
30
|
+
let primitives = null
|
|
31
|
+
try {
|
|
32
|
+
primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
33
|
+
} catch {
|
|
34
|
+
primitives = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NS = 'workbuddy2api-hub'
|
|
38
|
+
const API_BASE = 'workbuddy2api-hub/api/'
|
|
39
|
+
const POLL_MS = 15000
|
|
40
|
+
const API_TIMEOUT_MS = 15000
|
|
41
|
+
|
|
42
|
+
/** 中英文案。key 尽量语义化,改文案不用改组件。 */
|
|
43
|
+
const zh = {
|
|
44
|
+
nav: 'WorkBuddy Hub',
|
|
45
|
+
footerLabel: 'WorkBuddy',
|
|
46
|
+
title: 'WorkBuddy Hub',
|
|
47
|
+
subtitle: '把本地 workbuddy2api-hub 的模型接到 DSH',
|
|
48
|
+
connection: '连接',
|
|
49
|
+
baseUrl: '服务地址',
|
|
50
|
+
baseUrlHint: '默认 http://127.0.0.1:8788',
|
|
51
|
+
pwd: '面板密码(pwd)',
|
|
52
|
+
pwdHint: '只用于换取面板会话,不会写进对话请求',
|
|
53
|
+
tab: '默认页签',
|
|
54
|
+
apiKey: 'API Key',
|
|
55
|
+
apiKeyHint: '对话请求用;留空则尝试从面板自动获取',
|
|
56
|
+
interval: '轮询间隔(秒)',
|
|
57
|
+
save: '保存并刷新',
|
|
58
|
+
test: '测试连接',
|
|
59
|
+
testing: '正在测试…',
|
|
60
|
+
openDashboard: '打开看板',
|
|
61
|
+
refresh: '刷新',
|
|
62
|
+
clear: '移除 DSH 路由',
|
|
63
|
+
clearConfirm: '会从 llm-pi-ai 里删掉本插件写入的路由,并停止自动同步。继续?',
|
|
64
|
+
saved: '已保存',
|
|
65
|
+
testOk: '连接正常',
|
|
66
|
+
loadFailed: '读取状态失败',
|
|
67
|
+
models: '模型',
|
|
68
|
+
model: '模型',
|
|
69
|
+
rate: '倍率',
|
|
70
|
+
context: '上下文',
|
|
71
|
+
output: '输出上限',
|
|
72
|
+
efforts: '思考档位',
|
|
73
|
+
enabled: '启用',
|
|
74
|
+
all: '全选',
|
|
75
|
+
none: '全不选',
|
|
76
|
+
enableNew: '启用新模型',
|
|
77
|
+
newModels: '新上架',
|
|
78
|
+
retired: '已下架',
|
|
79
|
+
removeNow: '立即移除',
|
|
80
|
+
policy: '策略',
|
|
81
|
+
newModelPolicy: '新上架模型',
|
|
82
|
+
policySelect: '自动勾选',
|
|
83
|
+
policyIgnore: '先不勾选',
|
|
84
|
+
retireHours: '下架宽限期(小时)',
|
|
85
|
+
lowCredits: '低额度阈值',
|
|
86
|
+
autoApiKey: '自动从面板取 API Key',
|
|
87
|
+
health: '服务',
|
|
88
|
+
accounts: '账号',
|
|
89
|
+
ready: '就绪',
|
|
90
|
+
needRelogin: '需重登',
|
|
91
|
+
expired: '已过期',
|
|
92
|
+
balance: '额度',
|
|
93
|
+
usageToday: '今日',
|
|
94
|
+
footerBalance: '积分余额',
|
|
95
|
+
footerApiCost: 'API 等价',
|
|
96
|
+
rangeToday: '今日',
|
|
97
|
+
rangeWeek: '本周',
|
|
98
|
+
rangeMonth: '本月',
|
|
99
|
+
rangeAll: '累计',
|
|
100
|
+
tokensUnit: 'tokens',
|
|
101
|
+
creditUnit: '积分',
|
|
102
|
+
requests: '请求',
|
|
103
|
+
errors: '错误',
|
|
104
|
+
tokens: 'Token',
|
|
105
|
+
cost: '花费',
|
|
106
|
+
synced: '已同步',
|
|
107
|
+
notSynced: '未同步',
|
|
108
|
+
noModels: 'hub 还没返回任何模型',
|
|
109
|
+
noApiKey: '没有可用 API Key(面板密码不能当 Bearer 用)',
|
|
110
|
+
loginState: '面板会话',
|
|
111
|
+
loggedIn: '已登录',
|
|
112
|
+
loggedOut: '未登录',
|
|
113
|
+
autoSynced: '自动同步到 DSH',
|
|
114
|
+
disabledSync: '自动同步已关闭',
|
|
115
|
+
dismiss: '知道了',
|
|
116
|
+
unsupported: '当前 hub 版本不支持该接口',
|
|
117
|
+
unknownError: '未知错误',
|
|
118
|
+
loading: '加载中…',
|
|
119
|
+
pwdDefault: 'hub 仍在使用默认密码',
|
|
120
|
+
pendingSync: '同步被宿主事务推迟,下一次刷新会补做',
|
|
121
|
+
pluginVersion: '插件',
|
|
122
|
+
routeKey: '路由键',
|
|
123
|
+
modelsEnabled: '已启用',
|
|
124
|
+
avgRate: '平均倍率',
|
|
125
|
+
cacheHit: '缓存命中',
|
|
126
|
+
reasons: '告警',
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const en = {
|
|
130
|
+
nav: 'WorkBuddy Hub',
|
|
131
|
+
footerLabel: 'WB credit',
|
|
132
|
+
title: 'WorkBuddy Hub',
|
|
133
|
+
subtitle: 'Bridge a local workbuddy2api-hub into DSH',
|
|
134
|
+
connection: 'Connection',
|
|
135
|
+
baseUrl: 'Service URL',
|
|
136
|
+
baseUrlHint: 'Default http://127.0.0.1:8788',
|
|
137
|
+
pwd: 'Panel password (pwd)',
|
|
138
|
+
pwdHint: 'Used only to open a panel session',
|
|
139
|
+
tab: 'Default tab',
|
|
140
|
+
apiKey: 'API key',
|
|
141
|
+
apiKeyHint: 'Used for chat requests; leave blank to reuse the panel key',
|
|
142
|
+
interval: 'Poll interval (s)',
|
|
143
|
+
save: 'Save and refresh',
|
|
144
|
+
test: 'Test connection',
|
|
145
|
+
testing: 'Testing…',
|
|
146
|
+
openDashboard: 'Open dashboard',
|
|
147
|
+
refresh: 'Refresh',
|
|
148
|
+
clear: 'Remove DSH route',
|
|
149
|
+
clearConfirm: 'Remove the route written by this plugin and stop syncing?',
|
|
150
|
+
saved: 'Saved',
|
|
151
|
+
testOk: 'Connection works',
|
|
152
|
+
loadFailed: 'Failed to read state',
|
|
153
|
+
models: 'Models',
|
|
154
|
+
model: 'Model',
|
|
155
|
+
rate: 'Rate',
|
|
156
|
+
context: 'Context',
|
|
157
|
+
output: 'Max output',
|
|
158
|
+
efforts: 'Reasoning',
|
|
159
|
+
enabled: 'Enabled',
|
|
160
|
+
all: 'Select all',
|
|
161
|
+
none: 'Select none',
|
|
162
|
+
enableNew: 'Enable new models',
|
|
163
|
+
newModels: 'New',
|
|
164
|
+
retired: 'Retired',
|
|
165
|
+
removeNow: 'Remove now',
|
|
166
|
+
policy: 'Policy',
|
|
167
|
+
newModelPolicy: 'New models',
|
|
168
|
+
policySelect: 'Select automatically',
|
|
169
|
+
policyIgnore: 'Leave unselected',
|
|
170
|
+
retireHours: 'Retire grace (hours)',
|
|
171
|
+
lowCredits: 'Low credit threshold',
|
|
172
|
+
autoApiKey: 'Fetch API key from panel',
|
|
173
|
+
health: 'Service',
|
|
174
|
+
accounts: 'Accounts',
|
|
175
|
+
ready: 'ready',
|
|
176
|
+
needRelogin: 'relogin',
|
|
177
|
+
expired: 'expired',
|
|
178
|
+
balance: 'Credit',
|
|
179
|
+
usageToday: 'Today',
|
|
180
|
+
footerBalance: 'Credits',
|
|
181
|
+
footerApiCost: 'API equiv.',
|
|
182
|
+
rangeToday: 'Today',
|
|
183
|
+
rangeWeek: 'This week',
|
|
184
|
+
rangeMonth: 'This month',
|
|
185
|
+
rangeAll: 'Total',
|
|
186
|
+
tokensUnit: 'tokens',
|
|
187
|
+
creditUnit: 'credits',
|
|
188
|
+
requests: 'Requests',
|
|
189
|
+
errors: 'Errors',
|
|
190
|
+
tokens: 'Tokens',
|
|
191
|
+
cost: 'Cost',
|
|
192
|
+
synced: 'synced',
|
|
193
|
+
notSynced: 'not synced',
|
|
194
|
+
noModels: 'hub returned no models',
|
|
195
|
+
noApiKey: 'No API key available (the panel token is not a bearer token)',
|
|
196
|
+
loginState: 'Panel session',
|
|
197
|
+
loggedIn: 'signed in',
|
|
198
|
+
loggedOut: 'signed out',
|
|
199
|
+
autoSynced: 'Syncing into DSH',
|
|
200
|
+
disabledSync: 'Sync disabled',
|
|
201
|
+
dismiss: 'Got it',
|
|
202
|
+
unsupported: 'This hub version does not expose that endpoint',
|
|
203
|
+
unknownError: 'Unknown error',
|
|
204
|
+
loading: 'Loading…',
|
|
205
|
+
pwdDefault: 'hub still uses the default password',
|
|
206
|
+
pendingSync: 'Sync was deferred by a host transaction; the next refresh retries',
|
|
207
|
+
pluginVersion: 'Plugin',
|
|
208
|
+
routeKey: 'Route key',
|
|
209
|
+
modelsEnabled: 'enabled',
|
|
210
|
+
avgRate: 'Average rate',
|
|
211
|
+
cacheHit: 'Cache hit',
|
|
212
|
+
reasons: 'Alerts',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ------------------------------- 配色 ------------------------------- */
|
|
216
|
+
|
|
217
|
+
// 主题令牌用 DSH 真正注册的那套 `--dsw-alias-*`(不是早期的 `--dsh-*`)。
|
|
218
|
+
// 名字写错时 var() 会静默走兜底值:兜底是给深色主题写的浅灰字,
|
|
219
|
+
// 一旦用户切到浅色主题,就变成「浅灰字 + 白底」——看着像没渲染出来。
|
|
220
|
+
// 所以这里必须用真名,兜底只是最后一道保险。
|
|
221
|
+
const palette = {
|
|
222
|
+
text: 'var(--dsw-alias-label-primary, #e8e8ea)',
|
|
223
|
+
muted: 'var(--dsw-alias-label-secondary, #9a9aa2)',
|
|
224
|
+
faint: 'var(--dsw-alias-state-idle-primary, #6f6f78)',
|
|
225
|
+
line: 'var(--dsw-alias-border-l1, rgba(255,255,255,0.12))',
|
|
226
|
+
lineStrong: 'var(--dsw-alias-border-l2, rgba(255,255,255,0.22))',
|
|
227
|
+
card: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,0.03))',
|
|
228
|
+
card2: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06))',
|
|
229
|
+
accent: 'var(--dsw-alias-brand-primary, #4d8dff)',
|
|
230
|
+
ok: 'var(--dsw-alias-state-success-primary, #4ec9a5)',
|
|
231
|
+
warn: 'var(--dsw-alias-state-warn-primary, #d9a441)',
|
|
232
|
+
err: 'var(--dsw-alias-state-error-primary, #e0605f)',
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const styles = {
|
|
236
|
+
wrap: { display: 'flex', flexDirection: 'column', gap: '14px', color: palette.text, fontSize: '13px' },
|
|
237
|
+
card: {
|
|
238
|
+
border: `1px solid ${palette.line}`,
|
|
239
|
+
borderRadius: '10px',
|
|
240
|
+
background: palette.card,
|
|
241
|
+
padding: '12px 14px',
|
|
242
|
+
display: 'flex',
|
|
243
|
+
flexDirection: 'column',
|
|
244
|
+
gap: '10px',
|
|
245
|
+
},
|
|
246
|
+
header: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' },
|
|
247
|
+
h1: { fontSize: '15px', fontWeight: 600, margin: 0 },
|
|
248
|
+
sub: { color: palette.muted, fontSize: '12px' },
|
|
249
|
+
row: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' },
|
|
250
|
+
field: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '180px', flex: '1 1 180px' },
|
|
251
|
+
label: { fontSize: '12px', color: palette.muted },
|
|
252
|
+
// 次要提示也用 label-secondary:state-idle 在浅色主题下对比度太低,
|
|
253
|
+
// 11px 的字再用它就看不清了(用户就是这么发现的)。
|
|
254
|
+
hint: { fontSize: '11px', color: palette.muted },
|
|
255
|
+
input: {
|
|
256
|
+
background: palette.card2,
|
|
257
|
+
border: `1px solid ${palette.line}`,
|
|
258
|
+
borderRadius: '7px',
|
|
259
|
+
color: palette.text,
|
|
260
|
+
padding: '7px 9px',
|
|
261
|
+
fontSize: '13px',
|
|
262
|
+
font: 'inherit',
|
|
263
|
+
width: '100%',
|
|
264
|
+
boxSizing: 'border-box',
|
|
265
|
+
},
|
|
266
|
+
button: {
|
|
267
|
+
background: palette.card2,
|
|
268
|
+
border: `1px solid ${palette.line}`,
|
|
269
|
+
borderRadius: '7px',
|
|
270
|
+
color: palette.text,
|
|
271
|
+
padding: '6px 11px',
|
|
272
|
+
fontSize: '12px',
|
|
273
|
+
cursor: 'pointer',
|
|
274
|
+
font: 'inherit',
|
|
275
|
+
},
|
|
276
|
+
buttonPrimary: {
|
|
277
|
+
background: palette.accent,
|
|
278
|
+
border: `1px solid ${palette.accent}`,
|
|
279
|
+
borderRadius: '7px',
|
|
280
|
+
color: '#fff',
|
|
281
|
+
padding: '6px 11px',
|
|
282
|
+
fontSize: '12px',
|
|
283
|
+
cursor: 'pointer',
|
|
284
|
+
font: 'inherit',
|
|
285
|
+
},
|
|
286
|
+
badge: {
|
|
287
|
+
display: 'inline-flex',
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
gap: '4px',
|
|
290
|
+
padding: '1px 7px',
|
|
291
|
+
borderRadius: '999px',
|
|
292
|
+
border: `1px solid ${palette.line}`,
|
|
293
|
+
fontSize: '11px',
|
|
294
|
+
color: palette.muted,
|
|
295
|
+
whiteSpace: 'nowrap',
|
|
296
|
+
},
|
|
297
|
+
table: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' },
|
|
298
|
+
th: {
|
|
299
|
+
textAlign: 'left',
|
|
300
|
+
padding: '6px 8px',
|
|
301
|
+
color: palette.muted,
|
|
302
|
+
fontWeight: 500,
|
|
303
|
+
borderBottom: `1px solid ${palette.line}`,
|
|
304
|
+
whiteSpace: 'nowrap',
|
|
305
|
+
},
|
|
306
|
+
td: { padding: '6px 8px', borderBottom: `1px solid ${palette.line}`, verticalAlign: 'middle' },
|
|
307
|
+
mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: '11px' },
|
|
308
|
+
banner: (tone) => ({
|
|
309
|
+
border: `1px solid ${tone === 'error' ? palette.err : tone === 'warning' ? palette.warn : palette.line}`,
|
|
310
|
+
background: tone === 'error' ? 'rgba(224,96,95,0.10)' : tone === 'warning' ? 'rgba(217,164,65,0.10)' : palette.card2,
|
|
311
|
+
borderRadius: '8px',
|
|
312
|
+
padding: '8px 10px',
|
|
313
|
+
fontSize: '12px',
|
|
314
|
+
color: palette.text,
|
|
315
|
+
display: 'flex',
|
|
316
|
+
gap: '8px',
|
|
317
|
+
alignItems: 'flex-start',
|
|
318
|
+
flexWrap: 'wrap',
|
|
319
|
+
}),
|
|
320
|
+
dot: (ok) => ({
|
|
321
|
+
width: '8px',
|
|
322
|
+
height: '8px',
|
|
323
|
+
borderRadius: '50%',
|
|
324
|
+
background: ok ? palette.ok : palette.err,
|
|
325
|
+
display: 'inline-block',
|
|
326
|
+
}),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ------------------------------ 网络层 ------------------------------ */
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 把插件内的相对路径解析成带 baseURI 前缀的路径。
|
|
333
|
+
*
|
|
334
|
+
* DSH 可能挂在子路径下(`/app`),所以不能写死 `/workbuddy2api-hub/api/...`;
|
|
335
|
+
* 必须经过 `document.baseURI`。同时要保留 `?hours=` 这类查询串——
|
|
336
|
+
* 只取 pathname 会把它丢掉。
|
|
337
|
+
* @param path - 接口相对路径。
|
|
338
|
+
* @returns 可直接 fetch 的路径。
|
|
339
|
+
*/
|
|
340
|
+
function apiPath(path) {
|
|
341
|
+
const cleaned = String(path).replace(/^\/+/, '')
|
|
342
|
+
const url = new URL(API_BASE + cleaned, document.baseURI)
|
|
343
|
+
return `${url.pathname}${url.search}`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 带超时的 fetch。
|
|
348
|
+
*
|
|
349
|
+
* 用 `AbortSignal.timeout`(现代浏览器/Electron 都有);拿不到就不带超时,
|
|
350
|
+
* 至少不会因为一个缺少的 API 直接把整块 UI 打死。
|
|
351
|
+
* @returns 可用于 fetch 的 signal。
|
|
352
|
+
*/
|
|
353
|
+
function timeoutSignal(ms) {
|
|
354
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
355
|
+
return AbortSignal.timeout(ms)
|
|
356
|
+
}
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 请求本插件的宿主接口。
|
|
362
|
+
*
|
|
363
|
+
* 返回值永远是一个对象:网络错误、超时、非 JSON 响应都折成
|
|
364
|
+
* `{ ok: false, error }`,调用处不需要 try/catch。
|
|
365
|
+
* @param path - 接口相对路径。
|
|
366
|
+
* @param options.method - HTTP 方法。
|
|
367
|
+
* @param options.body - 请求体(会 JSON 序列化)。
|
|
368
|
+
* @param options.headers - 额外请求头。
|
|
369
|
+
* @returns `{ ok, data, error, notModified }`。
|
|
370
|
+
*/
|
|
371
|
+
async function requestJson(path, options = {}) {
|
|
372
|
+
const method = options.method ?? 'GET'
|
|
373
|
+
const init = { method, headers: { ...(options.headers ?? {}) }, credentials: 'same-origin' }
|
|
374
|
+
const signal = timeoutSignal(API_TIMEOUT_MS)
|
|
375
|
+
if (signal !== undefined) init.signal = signal
|
|
376
|
+
if (options.body !== undefined) {
|
|
377
|
+
init.headers['content-type'] = 'application/json'
|
|
378
|
+
init.body = JSON.stringify(options.body)
|
|
379
|
+
}
|
|
380
|
+
let response
|
|
381
|
+
try {
|
|
382
|
+
response = await fetch(apiPath(path), init)
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const timeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
error: timeout
|
|
388
|
+
? `接口超时(${API_TIMEOUT_MS / 1000} 秒没有响应)`
|
|
389
|
+
: `无法连接 DSH 宿主:${error?.message ?? '网络错误'}`,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (response.status === 304) {
|
|
393
|
+
return { ok: true, notModified: true, data: options.previous, etag: response.headers.get('etag') ?? '' }
|
|
394
|
+
}
|
|
395
|
+
let payload
|
|
396
|
+
try {
|
|
397
|
+
payload = await response.json()
|
|
398
|
+
} catch {
|
|
399
|
+
return { ok: false, error: `宿主返回了非 JSON 内容(HTTP ${response.status})` }
|
|
400
|
+
}
|
|
401
|
+
if (!response.ok || payload?.ok === false) {
|
|
402
|
+
return { ok: false, error: payload?.error ?? `HTTP ${response.status}`, data: payload }
|
|
403
|
+
}
|
|
404
|
+
return { ok: true, data: payload, etag: response.headers.get('etag') ?? '' }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ------------------------------ 共享状态 ---------------------------- */
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 两个 React 根之间的最小发布订阅。
|
|
411
|
+
*
|
|
412
|
+
* 谁挂载谁订阅;订阅者归零就停掉轮询。`etag` 让「什么都没变」的那一轮
|
|
413
|
+
* 只花一个 304 的往返——快照本身有十几 KB,15 秒一轮全量拉太浪费。
|
|
414
|
+
*/
|
|
415
|
+
const store = (() => {
|
|
416
|
+
const listeners = new Set()
|
|
417
|
+
let snapshot = null
|
|
418
|
+
let error = ''
|
|
419
|
+
let loading = true
|
|
420
|
+
let etag = ''
|
|
421
|
+
let timer = 0
|
|
422
|
+
let inflight = null
|
|
423
|
+
|
|
424
|
+
const emit = () => {
|
|
425
|
+
for (const listener of listeners) {
|
|
426
|
+
try {
|
|
427
|
+
listener()
|
|
428
|
+
} catch {
|
|
429
|
+
// 单个订阅者渲染出错不该影响另一个根。
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const set = (next) => {
|
|
435
|
+
snapshot = next
|
|
436
|
+
error = ''
|
|
437
|
+
loading = false
|
|
438
|
+
emit()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const fail = (message) => {
|
|
442
|
+
error = message
|
|
443
|
+
loading = false
|
|
444
|
+
emit()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const load = async (force = false) => {
|
|
448
|
+
if (inflight !== null) return inflight
|
|
449
|
+
inflight = (async () => {
|
|
450
|
+
const result = await requestJson('state', {
|
|
451
|
+
headers: force || etag === '' ? {} : { 'if-none-match': etag },
|
|
452
|
+
previous: snapshot,
|
|
453
|
+
})
|
|
454
|
+
if (result.ok) {
|
|
455
|
+
if (result.notModified !== true && result.data !== undefined) {
|
|
456
|
+
set({ ...result.data })
|
|
457
|
+
// 宿主用「快照正文的 sha1 前 16 位」当 ETag,下一轮带上它就能拿到 304。
|
|
458
|
+
etag = typeof result.etag === 'string' ? result.etag : ''
|
|
459
|
+
} else {
|
|
460
|
+
loading = false
|
|
461
|
+
emit()
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
fail(result.error ?? '未知错误')
|
|
465
|
+
}
|
|
466
|
+
inflight = null
|
|
467
|
+
return result
|
|
468
|
+
})()
|
|
469
|
+
return inflight
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const start = () => {
|
|
473
|
+
if (timer !== 0) return
|
|
474
|
+
load(true)
|
|
475
|
+
timer = setInterval(() => {
|
|
476
|
+
if (typeof document !== 'undefined' && document.hidden === true) return
|
|
477
|
+
load(false)
|
|
478
|
+
}, POLL_MS)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const stop = () => {
|
|
482
|
+
if (listeners.size > 0) return
|
|
483
|
+
if (timer !== 0) {
|
|
484
|
+
clearInterval(timer)
|
|
485
|
+
timer = 0
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
get: () => ({ snapshot, error, loading }),
|
|
491
|
+
subscribe(listener) {
|
|
492
|
+
listeners.add(listener)
|
|
493
|
+
return () => {
|
|
494
|
+
listeners.delete(listener)
|
|
495
|
+
if (listeners.size === 0) stop()
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
start,
|
|
499
|
+
reload: () => load(true),
|
|
500
|
+
set,
|
|
501
|
+
fail,
|
|
502
|
+
}
|
|
503
|
+
})()
|
|
504
|
+
|
|
505
|
+
/** 订阅 store 的 hook。 */
|
|
506
|
+
function useStore() {
|
|
507
|
+
const [, force] = useState(0)
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
const unsubscribe = store.subscribe(() => force((value) => value + 1))
|
|
510
|
+
store.start()
|
|
511
|
+
return unsubscribe
|
|
512
|
+
}, [])
|
|
513
|
+
return store.get()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 按钮:优先用宿主的 primitives,拿不到就原生。 */
|
|
517
|
+
function Button(props) {
|
|
518
|
+
const Component = primitives?.Button
|
|
519
|
+
const { primary, children, ...rest } = props
|
|
520
|
+
if (typeof Component === 'function') {
|
|
521
|
+
return h(Component, { ...rest, variant: primary ? 'primary' : 'secondary' }, children)
|
|
522
|
+
}
|
|
523
|
+
return h('button', { ...rest, type: 'button', style: primary ? styles.buttonPrimary : styles.button }, children)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** 小徽标。 */
|
|
527
|
+
function Badge(props) {
|
|
528
|
+
return h('span', { style: { ...styles.badge, ...(props.style ?? {}) }, title: props.title }, props.children)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 一行「标签:值」。 */
|
|
532
|
+
function Stat(props) {
|
|
533
|
+
return h(
|
|
534
|
+
'div',
|
|
535
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '84px' } },
|
|
536
|
+
h('span', { style: styles.hint }, props.label),
|
|
537
|
+
h('span', { style: { fontSize: '13px', fontWeight: 600 } }, props.value),
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** 数字格式化:大数用 k/M,方便一屏放下。 */
|
|
542
|
+
function formatNumber(value) {
|
|
543
|
+
const number = Number(value) || 0
|
|
544
|
+
if (number >= 1e9) return `${Math.round(number / 1e8) / 10}B`
|
|
545
|
+
if (number >= 1e6) return `${Math.round(number / 1e5) / 10}M`
|
|
546
|
+
if (number >= 1e3) return `${Math.round(number / 1e2) / 10}k`
|
|
547
|
+
return String(number)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** 相对时间:刚刚 / N 分钟前 / N 小时前。 */
|
|
551
|
+
function timeAgo(timestamp, t) {
|
|
552
|
+
const value = Number(timestamp) || 0
|
|
553
|
+
if (value <= 0) return ''
|
|
554
|
+
const diff = Date.now() - value
|
|
555
|
+
if (diff < 60000) return '刚刚'
|
|
556
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
|
557
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
|
558
|
+
return new Date(value).toLocaleString()
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 模型行按名称排序,规则与宿主的 `sortModels` 一致:
|
|
563
|
+
* 忽略大小写、数字按数值比较(`GLM-5.2` 在 `GLM-5.10` 前),同名按 id 兜底。
|
|
564
|
+
*
|
|
565
|
+
* 宿主已经排过一次序,这里再排一次是为了「宿主还没热更新 / 老版本宿主」时
|
|
566
|
+
* 表格也不会按 hub 的原始顺序乱放。
|
|
567
|
+
* @param rows - 设置页的模型行。
|
|
568
|
+
* @returns 排好序的新数组。
|
|
569
|
+
*/
|
|
570
|
+
function sortRowsByName(rows) {
|
|
571
|
+
const key = (row) => String(row?.name ?? row?.id ?? '').toLocaleLowerCase()
|
|
572
|
+
return Array.isArray(rows)
|
|
573
|
+
? rows.slice().sort((left, right) =>
|
|
574
|
+
key(left).localeCompare(key(right), undefined, { numeric: true, sensitivity: 'base' }) ||
|
|
575
|
+
String(left?.id ?? '').localeCompare(String(right?.id ?? ''), undefined, { numeric: true }))
|
|
576
|
+
: []
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/* ------------------------------ 面板组件 ---------------------------- */
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 设置页里的主面板。
|
|
583
|
+
* @param props.t - 翻译函数(由插槽注入)。
|
|
584
|
+
*/
|
|
585
|
+
function HubPanel(props) {
|
|
586
|
+
const t = props.t ?? ((key) => zh[key] ?? key)
|
|
587
|
+
const { snapshot, error, loading } = useStore()
|
|
588
|
+
const [draft, setDraft] = useState(null)
|
|
589
|
+
const [busy, setBusy] = useState('')
|
|
590
|
+
const [notice, setNotice] = useState('')
|
|
591
|
+
const [probe, setProbe] = useState(null)
|
|
592
|
+
|
|
593
|
+
// 快照回来后把表单同步成服务端状态;用户正在输入时(draft 已被改过)不覆盖。
|
|
594
|
+
const serverKey = snapshot === null ? '' : `${snapshot.baseUrl}|${snapshot.tab}|${snapshot.intervalSeconds}|${snapshot.policy?.newModelPolicy}|${snapshot.policy?.retireHours}|${snapshot.policy?.thresholds?.lowCredits}`
|
|
595
|
+
|
|
596
|
+
const form = draft ?? {
|
|
597
|
+
baseUrl: snapshot?.baseUrl ?? '',
|
|
598
|
+
pwd: '',
|
|
599
|
+
tab: snapshot?.tab ?? 'gateway',
|
|
600
|
+
apiKey: '',
|
|
601
|
+
intervalSeconds: snapshot?.intervalSeconds ?? 120,
|
|
602
|
+
newModelPolicy: snapshot?.policy?.newModelPolicy ?? 'select',
|
|
603
|
+
retireHours: snapshot?.policy?.retireHours ?? 24,
|
|
604
|
+
lowCredits: snapshot?.policy?.thresholds?.lowCredits ?? 50,
|
|
605
|
+
autoApiKey: snapshot?.policy?.autoApiKey !== false,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const patch = (next) => setDraft({ ...form, ...next })
|
|
609
|
+
|
|
610
|
+
const post = async (path, body, label) => {
|
|
611
|
+
setBusy(label)
|
|
612
|
+
setNotice('')
|
|
613
|
+
const result = await requestJson(path, { method: 'POST', body })
|
|
614
|
+
setBusy('')
|
|
615
|
+
if (result.ok && result.data !== undefined) {
|
|
616
|
+
store.set({ ...result.data })
|
|
617
|
+
return true
|
|
618
|
+
}
|
|
619
|
+
setNotice(result.error ?? t('unknownError'))
|
|
620
|
+
return false
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const save = async () => {
|
|
624
|
+
const body = {
|
|
625
|
+
baseUrl: form.baseUrl,
|
|
626
|
+
tab: form.tab,
|
|
627
|
+
intervalSeconds: Number(form.intervalSeconds) || 120,
|
|
628
|
+
autoApiKey: form.autoApiKey,
|
|
629
|
+
policy: {
|
|
630
|
+
newModelPolicy: form.newModelPolicy,
|
|
631
|
+
retireHours: Number(form.retireHours) || 0,
|
|
632
|
+
thresholds: { lowCredits: Number(form.lowCredits) || 0 },
|
|
633
|
+
},
|
|
634
|
+
}
|
|
635
|
+
if (typeof form.pwd === 'string' && form.pwd !== '') body.pwd = form.pwd
|
|
636
|
+
if (typeof form.apiKey === 'string' && form.apiKey !== '') body.apiKey = form.apiKey
|
|
637
|
+
const ok = await post('config', body, 'save')
|
|
638
|
+
if (ok) {
|
|
639
|
+
setDraft(null)
|
|
640
|
+
setNotice(t('saved'))
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const test = async () => {
|
|
645
|
+
setBusy('test')
|
|
646
|
+
const body = { baseUrl: form.baseUrl }
|
|
647
|
+
if (typeof form.pwd === 'string' && form.pwd !== '') body.pwd = form.pwd
|
|
648
|
+
const result = await requestJson('test', { method: 'POST', body })
|
|
649
|
+
setBusy('')
|
|
650
|
+
if (!result.ok) {
|
|
651
|
+
setProbe({ ok: false, message: result.error ?? t('unknownError') })
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
setProbe(
|
|
655
|
+
result.data?.ok === true
|
|
656
|
+
? { ok: true, message: `${t('testOk')}:${result.data.baseUrl ?? form.baseUrl}` }
|
|
657
|
+
: { ok: false, message: result.data?.failure?.message ?? t('unknownError') },
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const openDashboard = async () => {
|
|
662
|
+
setBusy('dashboard')
|
|
663
|
+
const result = await requestJson('dashboard-url')
|
|
664
|
+
setBusy('')
|
|
665
|
+
if (!result.ok || typeof result.data?.url !== 'string') {
|
|
666
|
+
setNotice(result.error ?? t('unknownError'))
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
window.open(result.data.url, '_blank', 'noopener,noreferrer')
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const clearRoute = async () => {
|
|
673
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function' && !window.confirm(t('clearConfirm'))) {
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
await post('clear', { confirm: true }, 'clear')
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const toggleModel = async (row) => {
|
|
680
|
+
await post('selection', { id: row.id, enabled: row.enabled !== true }, `model:${row.id}`)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const toggleEffort = async (row, level) => {
|
|
684
|
+
const current = Array.isArray(row.selectedEfforts) ? row.selectedEfforts : row.efforts
|
|
685
|
+
const next = current.includes(level) ? current.filter((item) => item !== level) : [...current, level]
|
|
686
|
+
// 至少留一个正档:全裁掉的话 pi-ai 会拒绝这份档案,整条路由都注册不上。
|
|
687
|
+
if (next.filter((item) => item !== 'off').length === 0) return
|
|
688
|
+
await post('selection', { id: row.id, efforts: next }, `effort:${row.id}`)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const head = h(
|
|
692
|
+
'div',
|
|
693
|
+
{ style: styles.header },
|
|
694
|
+
h('h3', { style: styles.h1 }, t('title')),
|
|
695
|
+
h('span', { style: styles.sub }, t('subtitle')),
|
|
696
|
+
snapshot === null
|
|
697
|
+
? null
|
|
698
|
+
: h(
|
|
699
|
+
'span',
|
|
700
|
+
{ style: { ...styles.badge, marginLeft: 'auto' } },
|
|
701
|
+
h('span', { style: styles.dot(snapshot.health?.ok === true && snapshot.lastError === '') }),
|
|
702
|
+
snapshot.health?.ok === true ? t('health') : t('loadFailed'),
|
|
703
|
+
),
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
/* 出错时把最要紧的一句放最上面:连不上 / 密码错 / 限流都要能立刻看见。 */
|
|
707
|
+
const errorBanner =
|
|
708
|
+
error === '' ? null : h('div', { style: styles.banner('error') }, h('strong', null, t('loadFailed')), h('span', null, error))
|
|
709
|
+
const syncError =
|
|
710
|
+
snapshot !== null && typeof snapshot.lastSyncError === 'string' && snapshot.lastSyncError !== ''
|
|
711
|
+
? h('div', { style: styles.banner('warning') }, h('span', null, snapshot.lastSyncError))
|
|
712
|
+
: null
|
|
713
|
+
const pendingBanner =
|
|
714
|
+
snapshot !== null && snapshot.syncPending === true
|
|
715
|
+
? h('div', { style: styles.banner('info') }, h('span', null, t('pendingSync')))
|
|
716
|
+
: null
|
|
717
|
+
const noticeBanner = notice === '' ? null : h('div', { style: styles.banner('info') }, h('span', null, notice))
|
|
718
|
+
|
|
719
|
+
const alerts =
|
|
720
|
+
Array.isArray(snapshot?.alerts) && snapshot.alerts.length > 0
|
|
721
|
+
? h(
|
|
722
|
+
'div',
|
|
723
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
724
|
+
...snapshot.alerts.map((alert) =>
|
|
725
|
+
h(
|
|
726
|
+
'div',
|
|
727
|
+
{ key: alert.key, style: styles.banner(alert.level === 'error' ? 'error' : 'warning') },
|
|
728
|
+
h('strong', null, alert.title),
|
|
729
|
+
h('span', { style: { flex: '1 1 200px' } }, alert.detail),
|
|
730
|
+
h('button', {
|
|
731
|
+
type: 'button',
|
|
732
|
+
style: { ...styles.button, padding: '2px 8px' },
|
|
733
|
+
onClick: () => post('dismiss', { key: alert.key }, `dismiss:${alert.key}`),
|
|
734
|
+
}, t('dismiss')),
|
|
735
|
+
),
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
: null
|
|
739
|
+
|
|
740
|
+
/* ------------------------------ 连接配置 ------------------------------ */
|
|
741
|
+
const connectionCard = h(
|
|
742
|
+
'section',
|
|
743
|
+
{ style: styles.card },
|
|
744
|
+
h('div', { style: styles.row }, h('strong', null, t('connection'))),
|
|
745
|
+
h(
|
|
746
|
+
'div',
|
|
747
|
+
{ style: styles.row },
|
|
748
|
+
h(
|
|
749
|
+
'label',
|
|
750
|
+
{ style: styles.field },
|
|
751
|
+
h('span', { style: styles.label }, t('baseUrl')),
|
|
752
|
+
h('input', {
|
|
753
|
+
style: styles.input,
|
|
754
|
+
value: form.baseUrl ?? '',
|
|
755
|
+
placeholder: 'http://127.0.0.1:8788',
|
|
756
|
+
spellCheck: false,
|
|
757
|
+
onChange: (event) => patch({ baseUrl: event.target.value }),
|
|
758
|
+
}),
|
|
759
|
+
h('span', { style: styles.hint }, t('baseUrlHint')),
|
|
760
|
+
),
|
|
761
|
+
h(
|
|
762
|
+
'label',
|
|
763
|
+
{ style: styles.field },
|
|
764
|
+
h('span', { style: styles.label }, t('pwd')),
|
|
765
|
+
h('input', {
|
|
766
|
+
style: styles.input,
|
|
767
|
+
type: 'password',
|
|
768
|
+
value: form.pwd ?? '',
|
|
769
|
+
placeholder: snapshot?.pwdConfigured === true ? '••••••' : 'admin',
|
|
770
|
+
autoComplete: 'off',
|
|
771
|
+
onChange: (event) => patch({ pwd: event.target.value }),
|
|
772
|
+
}),
|
|
773
|
+
h('span', { style: styles.hint }, t('pwdHint')),
|
|
774
|
+
),
|
|
775
|
+
h(
|
|
776
|
+
'label',
|
|
777
|
+
{ style: styles.field },
|
|
778
|
+
h('span', { style: styles.label }, t('tab')),
|
|
779
|
+
h(
|
|
780
|
+
'select',
|
|
781
|
+
{
|
|
782
|
+
style: styles.input,
|
|
783
|
+
value: form.tab ?? 'gateway',
|
|
784
|
+
onChange: (event) => patch({ tab: event.target.value }),
|
|
785
|
+
},
|
|
786
|
+
...(Array.isArray(snapshot?.tabs) ? snapshot.tabs : ['gateway']).map((tab) =>
|
|
787
|
+
h('option', { key: tab, value: tab }, tab),
|
|
788
|
+
),
|
|
789
|
+
),
|
|
790
|
+
),
|
|
791
|
+
),
|
|
792
|
+
h(
|
|
793
|
+
'div',
|
|
794
|
+
{ style: styles.row },
|
|
795
|
+
h(
|
|
796
|
+
'label',
|
|
797
|
+
{ style: styles.field },
|
|
798
|
+
h('span', { style: styles.label }, t('apiKey')),
|
|
799
|
+
h('input', {
|
|
800
|
+
style: styles.input,
|
|
801
|
+
type: 'password',
|
|
802
|
+
value: form.apiKey ?? '',
|
|
803
|
+
placeholder: snapshot?.apiKeySource === '' ? 'REQUIRED' : snapshot?.apiKeyEnv ?? '',
|
|
804
|
+
autoComplete: 'off',
|
|
805
|
+
onChange: (event) => patch({ apiKey: event.target.value }),
|
|
806
|
+
}),
|
|
807
|
+
h('span', { style: styles.hint }, t('apiKeyHint')),
|
|
808
|
+
),
|
|
809
|
+
h(
|
|
810
|
+
'label',
|
|
811
|
+
{ style: styles.field },
|
|
812
|
+
h('span', { style: styles.label }, t('interval')),
|
|
813
|
+
h('input', {
|
|
814
|
+
style: styles.input,
|
|
815
|
+
type: 'number',
|
|
816
|
+
min: 30,
|
|
817
|
+
max: 3600,
|
|
818
|
+
value: form.intervalSeconds ?? 120,
|
|
819
|
+
onChange: (event) => patch({ intervalSeconds: event.target.value }),
|
|
820
|
+
}),
|
|
821
|
+
),
|
|
822
|
+
),
|
|
823
|
+
h(
|
|
824
|
+
'div',
|
|
825
|
+
{ style: styles.row },
|
|
826
|
+
h(Button, { primary: true, onClick: save, disabled: busy !== '' }, busy === 'save' ? '…' : t('save')),
|
|
827
|
+
h(Button, { onClick: test, disabled: busy !== '' }, busy === 'test' ? t('testing') : t('test')),
|
|
828
|
+
h(Button, { onClick: openDashboard, disabled: busy !== '' }, t('openDashboard')),
|
|
829
|
+
h(Button, { onClick: () => store.reload() }, t('refresh')),
|
|
830
|
+
h('span', { style: { flex: '1 1 auto' } }),
|
|
831
|
+
h(Button, { onClick: clearRoute, disabled: busy !== '' }, t('clear')),
|
|
832
|
+
),
|
|
833
|
+
probe === null
|
|
834
|
+
? null
|
|
835
|
+
: h('div', { style: styles.banner(probe.ok ? 'info' : 'error') }, h('span', null, probe.message)),
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
/* ------------------------------- 概览 -------------------------------- */
|
|
839
|
+
const overview =
|
|
840
|
+
snapshot === null
|
|
841
|
+
? null
|
|
842
|
+
: h(
|
|
843
|
+
'section',
|
|
844
|
+
{ style: styles.card },
|
|
845
|
+
h(
|
|
846
|
+
'div',
|
|
847
|
+
{ style: styles.row },
|
|
848
|
+
h(Stat, { label: t('balance'), value: snapshot.balance === null || snapshot.balance === undefined ? '—' : `${snapshot.balance.balance} ${snapshot.balance.unit}` }),
|
|
849
|
+
h(Stat, { label: t('modelsEnabled'), value: `${snapshot.counts?.enabled ?? 0}/${snapshot.counts?.total ?? 0}` }),
|
|
850
|
+
h(Stat, { label: t('avgRate'), value: snapshot.averageRate > 0 ? `×${snapshot.averageRate}` : '—' }),
|
|
851
|
+
h(Stat, { label: t('accounts'), value: `${snapshot.accounts?.ready ?? 0}/${snapshot.accounts?.total ?? 0}` }),
|
|
852
|
+
h(Stat, { label: t('loginState'), value: snapshot.loggedIn === true ? t('loggedIn') : t('loggedOut') }),
|
|
853
|
+
h('span', { style: { flex: '1 1 auto' } }),
|
|
854
|
+
h(Badge, { title: snapshot.baseUrl }, snapshot.health?.realm === '' || snapshot.health?.realm === undefined ? 'hub' : `realm ${snapshot.health.realm}`),
|
|
855
|
+
snapshot.apiKeySource === ''
|
|
856
|
+
? h(Badge, { style: { color: palette.warn } }, t('noApiKey'))
|
|
857
|
+
: h(Badge, null, `key: ${snapshot.apiKeySource}`),
|
|
858
|
+
snapshot.pwdIsDefault === true ? h(Badge, { style: { color: palette.warn } }, t('pwdDefault')) : null,
|
|
859
|
+
),
|
|
860
|
+
snapshot.usage === null || snapshot.usage === undefined
|
|
861
|
+
? null
|
|
862
|
+
: h(
|
|
863
|
+
'div',
|
|
864
|
+
{ style: styles.row },
|
|
865
|
+
h('span', { style: styles.hint }, t('usageToday')),
|
|
866
|
+
h(Badge, null, `${t('requests')} ${formatNumber(snapshot.usage.requests)}`),
|
|
867
|
+
h(Badge, null, `${t('errors')} ${formatNumber(snapshot.usage.errors)}${snapshot.usage.errorRate > 0 ? ` (${snapshot.usage.errorRate}%)` : ''}`),
|
|
868
|
+
h(Badge, null, `${t('tokens')} ${formatNumber(snapshot.usage.totalTokens)}`),
|
|
869
|
+
h(Badge, null, `${t('creditUnit')} ${snapshot.usage.credit}`),
|
|
870
|
+
h(Badge, null, `${t('cacheHit')} ${snapshot.usage.cacheHitRate}%`),
|
|
871
|
+
h(Badge, null, `${t('cost')} ¥${snapshot.usage.costCny}`),
|
|
872
|
+
),
|
|
873
|
+
snapshot.lastSyncAt > 0
|
|
874
|
+
? h(
|
|
875
|
+
'div',
|
|
876
|
+
{ style: styles.row },
|
|
877
|
+
h('span', { style: styles.hint }, `${t('synced')} ${timeAgo(snapshot.lastSyncAt, t)} · ${snapshot.lastSyncCount} ${t('models')}`),
|
|
878
|
+
h('span', { style: styles.hint }, `${t('routeKey')}: ${snapshot.routeKey}`),
|
|
879
|
+
h('span', { style: styles.hint }, `${t('pluginVersion')} ${snapshot.pluginVersion}`),
|
|
880
|
+
)
|
|
881
|
+
: null,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
/* ------------------------------- 模型表 ------------------------------ */
|
|
885
|
+
const rows = Array.isArray(snapshot?.models) ? sortRowsByName(snapshot.models) : []
|
|
886
|
+
const modelCard = h(
|
|
887
|
+
'section',
|
|
888
|
+
{ style: styles.card },
|
|
889
|
+
h(
|
|
890
|
+
'div',
|
|
891
|
+
{ style: styles.row },
|
|
892
|
+
h('strong', null, t('models')),
|
|
893
|
+
h('span', { style: styles.hint }, `${rows.length}`),
|
|
894
|
+
h('span', { style: { flex: '1 1 auto' } }),
|
|
895
|
+
h(Button, { onClick: () => post('bulk', { enabled: true }, 'bulk') }, t('all')),
|
|
896
|
+
h(Button, { onClick: () => post('bulk', { enabled: false }, 'bulk') }, t('none')),
|
|
897
|
+
Array.isArray(snapshot?.newModels) && snapshot.newModels.length > 0
|
|
898
|
+
? h(Button, { onClick: () => post('enable-new', {}, 'enable-new') }, `${t('enableNew')} (${snapshot.newModels.length})`)
|
|
899
|
+
: null,
|
|
900
|
+
),
|
|
901
|
+
rows.length === 0
|
|
902
|
+
? h('div', { style: styles.hint }, loading ? t('loading') : t('noModels'))
|
|
903
|
+
: h(
|
|
904
|
+
'table',
|
|
905
|
+
{ style: styles.table },
|
|
906
|
+
h(
|
|
907
|
+
'thead',
|
|
908
|
+
null,
|
|
909
|
+
h(
|
|
910
|
+
'tr',
|
|
911
|
+
null,
|
|
912
|
+
h('th', { style: styles.th }, ''),
|
|
913
|
+
h('th', { style: styles.th }, t('model')),
|
|
914
|
+
h('th', { style: styles.th }, t('rate')),
|
|
915
|
+
h('th', { style: styles.th }, t('context')),
|
|
916
|
+
h('th', { style: styles.th }, t('output')),
|
|
917
|
+
h('th', { style: styles.th }, t('efforts')),
|
|
918
|
+
),
|
|
919
|
+
),
|
|
920
|
+
h(
|
|
921
|
+
'tbody',
|
|
922
|
+
null,
|
|
923
|
+
...rows.map((row) =>
|
|
924
|
+
h(
|
|
925
|
+
'tr',
|
|
926
|
+
{ key: row.id, style: row.retired === true ? { opacity: 0.55 } : undefined },
|
|
927
|
+
h(
|
|
928
|
+
'td',
|
|
929
|
+
{ style: { ...styles.td, width: '26px' } },
|
|
930
|
+
h('input', {
|
|
931
|
+
type: 'checkbox',
|
|
932
|
+
checked: row.enabled === true,
|
|
933
|
+
disabled: busy !== '',
|
|
934
|
+
onChange: () => toggleModel(row),
|
|
935
|
+
'aria-label': row.id,
|
|
936
|
+
}),
|
|
937
|
+
),
|
|
938
|
+
h(
|
|
939
|
+
'td',
|
|
940
|
+
{ style: styles.td },
|
|
941
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } }, h('span', null, row.name),
|
|
942
|
+
h('span', { style: { ...styles.hint, ...styles.mono } }, row.id),
|
|
943
|
+
),
|
|
944
|
+
h(
|
|
945
|
+
'div',
|
|
946
|
+
{ style: { display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '3px' } },
|
|
947
|
+
row.vision === true ? h(Badge, null, 'vision') : null,
|
|
948
|
+
row.tools === true ? h(Badge, null, 'tools') : null,
|
|
949
|
+
row.retired === true
|
|
950
|
+
? h(
|
|
951
|
+
'span',
|
|
952
|
+
{ style: { ...styles.badge, color: palette.warn, gap: '6px' } },
|
|
953
|
+
t('retired'),
|
|
954
|
+
h('button', {
|
|
955
|
+
type: 'button',
|
|
956
|
+
style: { ...styles.button, padding: '0 6px', fontSize: '10px' },
|
|
957
|
+
onClick: () => post('retire', { id: row.id }, `retire:${row.id}`),
|
|
958
|
+
}, t('removeNow')),
|
|
959
|
+
)
|
|
960
|
+
: null,
|
|
961
|
+
Array.isArray(snapshot?.newModels) && snapshot.newModels.includes(row.id)
|
|
962
|
+
? h(Badge, { style: { color: palette.accent } }, t('newModels'))
|
|
963
|
+
: null,
|
|
964
|
+
),
|
|
965
|
+
),
|
|
966
|
+
h('td', { style: styles.td }, row.rateText === '' ? '—' : row.rateText),
|
|
967
|
+
h('td', { style: styles.td }, row.contextWindow > 0 ? formatNumber(row.contextWindow) : '—'),
|
|
968
|
+
h('td', { style: styles.td }, row.maxTokens > 0 ? formatNumber(row.maxTokens) : '—'),
|
|
969
|
+
h(
|
|
970
|
+
'td',
|
|
971
|
+
{ style: styles.td },
|
|
972
|
+
Array.isArray(row.efforts) && row.efforts.length > 0
|
|
973
|
+
? h(
|
|
974
|
+
'div',
|
|
975
|
+
{ style: { display: 'flex', gap: '4px', flexWrap: 'wrap' } },
|
|
976
|
+
...row.efforts.map((level) => {
|
|
977
|
+
const active =
|
|
978
|
+
Array.isArray(row.selectedEfforts) ? row.selectedEfforts.includes(level) : true
|
|
979
|
+
return h(
|
|
980
|
+
'button',
|
|
981
|
+
{
|
|
982
|
+
key: level,
|
|
983
|
+
type: 'button',
|
|
984
|
+
disabled: busy !== '',
|
|
985
|
+
onClick: () => toggleEffort(row, level),
|
|
986
|
+
style: {
|
|
987
|
+
...styles.badge,
|
|
988
|
+
cursor: 'pointer',
|
|
989
|
+
font: 'inherit',
|
|
990
|
+
// 用主题色自身的透明度混色:深浅两套主题下都不会出现「浅底浅字」。
|
|
991
|
+
background: active ? `color-mix(in srgb, ${palette.accent} 18%, transparent)` : 'transparent',
|
|
992
|
+
color: active ? palette.accent : palette.muted,
|
|
993
|
+
borderColor: active ? palette.accent : palette.line,
|
|
994
|
+
padding: '1px 7px',
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
level,
|
|
998
|
+
)
|
|
999
|
+
}),
|
|
1000
|
+
)
|
|
1001
|
+
: h('span', { style: styles.hint }, '—'),
|
|
1002
|
+
),
|
|
1003
|
+
),
|
|
1004
|
+
),
|
|
1005
|
+
),
|
|
1006
|
+
),
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
/* ------------------------------- 策略 -------------------------------- */
|
|
1010
|
+
const policyCard = h(
|
|
1011
|
+
'section',
|
|
1012
|
+
{ style: styles.card },
|
|
1013
|
+
h('div', { style: styles.row }, h('strong', null, t('policy'))),
|
|
1014
|
+
h(
|
|
1015
|
+
'div',
|
|
1016
|
+
{ style: styles.row },
|
|
1017
|
+
h(
|
|
1018
|
+
'label',
|
|
1019
|
+
{ style: styles.field },
|
|
1020
|
+
h('span', { style: styles.label }, t('newModelPolicy')),
|
|
1021
|
+
h(
|
|
1022
|
+
'select',
|
|
1023
|
+
{
|
|
1024
|
+
style: styles.input,
|
|
1025
|
+
value: form.newModelPolicy ?? 'select',
|
|
1026
|
+
onChange: (event) => patch({ newModelPolicy: event.target.value }),
|
|
1027
|
+
},
|
|
1028
|
+
h('option', { value: 'select' }, t('policySelect')),
|
|
1029
|
+
h('option', { value: 'ignore' }, t('policyIgnore')),
|
|
1030
|
+
),
|
|
1031
|
+
),
|
|
1032
|
+
h(
|
|
1033
|
+
'label',
|
|
1034
|
+
{ style: styles.field },
|
|
1035
|
+
h('span', { style: styles.label }, t('retireHours')),
|
|
1036
|
+
h('input', {
|
|
1037
|
+
style: styles.input,
|
|
1038
|
+
type: 'number',
|
|
1039
|
+
min: 0,
|
|
1040
|
+
max: 168,
|
|
1041
|
+
value: form.retireHours ?? 24,
|
|
1042
|
+
onChange: (event) => patch({ retireHours: event.target.value }),
|
|
1043
|
+
}),
|
|
1044
|
+
),
|
|
1045
|
+
h(
|
|
1046
|
+
'label',
|
|
1047
|
+
{ style: styles.field },
|
|
1048
|
+
h('span', { style: styles.label }, t('lowCredits')),
|
|
1049
|
+
h('input', {
|
|
1050
|
+
style: styles.input,
|
|
1051
|
+
type: 'number',
|
|
1052
|
+
min: 0,
|
|
1053
|
+
value: form.lowCredits ?? 50,
|
|
1054
|
+
onChange: (event) => patch({ lowCredits: event.target.value }),
|
|
1055
|
+
}),
|
|
1056
|
+
),
|
|
1057
|
+
h(
|
|
1058
|
+
'label',
|
|
1059
|
+
{ style: { ...styles.row, gap: '6px', alignSelf: 'flex-end' } },
|
|
1060
|
+
h('input', {
|
|
1061
|
+
type: 'checkbox',
|
|
1062
|
+
checked: form.autoApiKey !== false,
|
|
1063
|
+
onChange: (event) => patch({ autoApiKey: event.target.checked }),
|
|
1064
|
+
}),
|
|
1065
|
+
h('span', { style: styles.label }, t('autoApiKey')),
|
|
1066
|
+
),
|
|
1067
|
+
),
|
|
1068
|
+
h(
|
|
1069
|
+
'div',
|
|
1070
|
+
{ style: styles.row },
|
|
1071
|
+
h(Button, { primary: true, onClick: save, disabled: busy !== '' }, t('save')),
|
|
1072
|
+
h(
|
|
1073
|
+
'span',
|
|
1074
|
+
{ style: styles.hint },
|
|
1075
|
+
snapshot === null
|
|
1076
|
+
? ''
|
|
1077
|
+
: `${snapshot.sync === true ? t('autoSynced') : t('disabledSync')} · ${snapshot.providerEntry} / ${snapshot.routeKey}`,
|
|
1078
|
+
),
|
|
1079
|
+
),
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
return h(
|
|
1083
|
+
'div',
|
|
1084
|
+
{ style: styles.wrap, 'data-dsh-plugin': NS },
|
|
1085
|
+
head,
|
|
1086
|
+
errorBanner,
|
|
1087
|
+
alerts,
|
|
1088
|
+
noticeBanner,
|
|
1089
|
+
pendingBanner,
|
|
1090
|
+
syncError,
|
|
1091
|
+
connectionCard,
|
|
1092
|
+
overview,
|
|
1093
|
+
modelCard,
|
|
1094
|
+
policyCard,
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** 把窗口名收进 today/week/month/all 四档;缺省当全量(老宿主的行为)。 */
|
|
1099
|
+
function normalizeRangeLabel(value) {
|
|
1100
|
+
const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
|
|
1101
|
+
if (raw === 'week' || raw === 'w') return 'week'
|
|
1102
|
+
if (raw === 'month' || raw === 'm') return 'month'
|
|
1103
|
+
if (raw === 'today' || raw === 'day' || raw === '1d') return 'today'
|
|
1104
|
+
return 'all'
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** 积分一律两位小数:40.919999999999916 → 40.92。 */
|
|
1108
|
+
function formatCredit(value) {
|
|
1109
|
+
const number = Number(value)
|
|
1110
|
+
if (!Number.isFinite(number)) return '—'
|
|
1111
|
+
return (Math.round(number * 100) / 100).toFixed(2)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** 人民币:6.2451 → ¥6.25。 */
|
|
1115
|
+
function formatCny(value) {
|
|
1116
|
+
const number = Number(value)
|
|
1117
|
+
if (!Number.isFinite(number)) return '—'
|
|
1118
|
+
return `¥${(Math.round(number * 100) / 100).toFixed(2)}`
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* 徽标上的四个数字(纯函数,便于测试)。
|
|
1123
|
+
*
|
|
1124
|
+
* 语义按 hub 的口径来:`balance.balance` 是账号池剩余积分;
|
|
1125
|
+
* `usage.totalTokens` / `usage.credit` / `usage.costCny` 是
|
|
1126
|
+
* **`/usage?range=` 窗口内**的用量——`credit` 是上游逐行给的,不是估的;
|
|
1127
|
+
* `costCny` 才是按 OpenRouter 价折算的「API 等价花费」。
|
|
1128
|
+
*/
|
|
1129
|
+
/**
|
|
1130
|
+
* 把一份用量快照算成可读文本。
|
|
1131
|
+
*
|
|
1132
|
+
* @param snapshot - 宿主 `/state` 返回的快照。
|
|
1133
|
+
* @param window - `today` 取 `usage`(主窗口),`week` 取 `usageWeek`。
|
|
1134
|
+
*/
|
|
1135
|
+
function footerSummary(snapshot, window = 'today') {
|
|
1136
|
+
const usage = (window === 'week' ? snapshot?.usageWeek : snapshot?.usage) ?? null
|
|
1137
|
+
const hasBalance = snapshot?.balance !== null && snapshot?.balance !== undefined
|
|
1138
|
+
return {
|
|
1139
|
+
balance: hasBalance ? formatCredit(snapshot.balance.balance) : '—',
|
|
1140
|
+
tokens: usage === null ? '—' : formatNumber(usage.totalTokens),
|
|
1141
|
+
credit: usage === null ? '—' : formatCredit(usage.credit),
|
|
1142
|
+
cost: usage === null ? '—' : formatCny(usage.costCny),
|
|
1143
|
+
// 老宿主(< 1.0.3)没有 usageRange,那时拉的是不过滤的全量窗口,
|
|
1144
|
+
// 所以缺省标 'all' 而不是 'today'——数字是什么就说什么。
|
|
1145
|
+
range: normalizeRangeLabel(window === 'week' ? 'week' : snapshot?.usageRange),
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* 侧边栏底部的徽标:WorkBuddy · 积分余额 · 今日 tokens · 今日积分 · API 等价花费。
|
|
1151
|
+
*
|
|
1152
|
+
* 侧边栏展开时(`wide`)两行放全五项;收起时只留状态点 + 积分余额 +
|
|
1153
|
+
* 今日积分,其余进 `title`(鼠标悬停可读,也方便复制)。
|
|
1154
|
+
* 点一下打开 hub 看板——比让用户自己去找设置页再点按钮顺手。
|
|
1155
|
+
*/
|
|
1156
|
+
function HubFooter(props) {
|
|
1157
|
+
const t = props.t ?? ((key) => zh[key] ?? key)
|
|
1158
|
+
const { snapshot, error } = useStore()
|
|
1159
|
+
const [opening, setOpening] = useState(false)
|
|
1160
|
+
|
|
1161
|
+
const summary = footerSummary(snapshot)
|
|
1162
|
+
const week = footerSummary(snapshot, 'week')
|
|
1163
|
+
// 老宿主(< 1.0.5)没有 usageWeek:这一行干脆不画,别拿今日的数冒充本周。
|
|
1164
|
+
const hasWeek = snapshot?.usageWeek !== null && snapshot?.usageWeek !== undefined
|
|
1165
|
+
const rangeKey = summary.range === 'today' ? 'rangeToday' : summary.range === 'week' ? 'rangeWeek' : summary.range === 'month' ? 'rangeMonth' : 'rangeAll'
|
|
1166
|
+
const tone = error !== '' || snapshot === null ? palette.faint : snapshot.health?.ok === true ? palette.ok : palette.err
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* 一行用量:`今日 tokens 72.3M · 积分 61.41 · API 等价 ¥10.39`。
|
|
1170
|
+
*
|
|
1171
|
+
* 主窗口和本周共用同一个格式函数。早先主窗口拆成三行、本周挤成一行,
|
|
1172
|
+
* 悬停框里两块长得不一样,读起来像两份不同的数据;现在两行同构,
|
|
1173
|
+
* 只有开头的窗口名不同。
|
|
1174
|
+
*/
|
|
1175
|
+
const usageLine = (label, item) =>
|
|
1176
|
+
`${label} ${t('tokensUnit')} ${item.tokens} · ${t('creditUnit')} ${item.credit} · ${t('footerApiCost')} ${item.cost}`
|
|
1177
|
+
|
|
1178
|
+
const title = [
|
|
1179
|
+
`${t('footerLabel')} · ${t('footerBalance')} ${summary.balance}`,
|
|
1180
|
+
usageLine(t(rangeKey), summary),
|
|
1181
|
+
hasWeek ? usageLine(t('rangeWeek'), week) : '',
|
|
1182
|
+
error !== '' ? error : snapshot?.baseUrl ?? '',
|
|
1183
|
+
]
|
|
1184
|
+
.filter((line) => line !== '')
|
|
1185
|
+
.join('\n')
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* 一行「窗口名在左、三个数字在右」。
|
|
1189
|
+
*
|
|
1190
|
+
* 左标签固定宽度对齐(今日 / 本周都是两个字,靠 `minWidth` 兜住英文的
|
|
1191
|
+
* Today / This week),右侧 `marginLeft: auto` 把三个数字顶到最右,
|
|
1192
|
+
* 两行数字因此上下对齐、一眼能比出今日与本周的差距。
|
|
1193
|
+
*/
|
|
1194
|
+
const usageRow = (label, item) =>
|
|
1195
|
+
h(
|
|
1196
|
+
'span',
|
|
1197
|
+
{
|
|
1198
|
+
style: {
|
|
1199
|
+
display: 'flex',
|
|
1200
|
+
alignItems: 'baseline',
|
|
1201
|
+
gap: '8px',
|
|
1202
|
+
paddingLeft: '14px',
|
|
1203
|
+
fontSize: '11px',
|
|
1204
|
+
lineHeight: '15px',
|
|
1205
|
+
whiteSpace: 'nowrap',
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
h('span', { style: { color: palette.muted, minWidth: '44px' } }, label),
|
|
1209
|
+
h(
|
|
1210
|
+
'span',
|
|
1211
|
+
{ style: { marginLeft: 'auto', display: 'flex', alignItems: 'baseline', gap: '5px', ...styles.mono, color: palette.text } },
|
|
1212
|
+
h('span', null, item.tokens),
|
|
1213
|
+
h('span', { style: { color: palette.faint } }, '·'),
|
|
1214
|
+
h('span', null, item.credit),
|
|
1215
|
+
h('span', { style: { color: palette.faint } }, '·'),
|
|
1216
|
+
h('span', null, item.cost),
|
|
1217
|
+
),
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
const open = async () => {
|
|
1221
|
+
if (opening) return
|
|
1222
|
+
setOpening(true)
|
|
1223
|
+
const result = await requestJson('dashboard-url')
|
|
1224
|
+
setOpening(false)
|
|
1225
|
+
if (result.ok && typeof result.data?.url === 'string') {
|
|
1226
|
+
window.open(result.data.url, '_blank', 'noopener,noreferrer')
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (props.wide === true) {
|
|
1231
|
+
return h(
|
|
1232
|
+
'button',
|
|
1233
|
+
{
|
|
1234
|
+
type: 'button',
|
|
1235
|
+
onClick: open,
|
|
1236
|
+
title,
|
|
1237
|
+
style: {
|
|
1238
|
+
...styles.button,
|
|
1239
|
+
width: '100%',
|
|
1240
|
+
display: 'flex',
|
|
1241
|
+
flexDirection: 'column',
|
|
1242
|
+
alignItems: 'stretch',
|
|
1243
|
+
gap: '2px',
|
|
1244
|
+
padding: '6px 8px',
|
|
1245
|
+
textAlign: 'left',
|
|
1246
|
+
},
|
|
1247
|
+
},
|
|
1248
|
+
h(
|
|
1249
|
+
'span',
|
|
1250
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
1251
|
+
h('span', { style: styles.dot(tone === palette.ok) }),
|
|
1252
|
+
h('span', { style: { color: palette.text, fontWeight: 600 } }, t('footerLabel')),
|
|
1253
|
+
h('span', { style: { marginLeft: 'auto', ...styles.mono, color: palette.text } }, summary.balance),
|
|
1254
|
+
),
|
|
1255
|
+
// 第二、三行:窗口名贴左(两行左边缘对齐),三个数字贴右(右边缘也对齐)。
|
|
1256
|
+
usageRow(t(rangeKey), summary),
|
|
1257
|
+
hasWeek ? usageRow(t('rangeWeek'), week) : null,
|
|
1258
|
+
)
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return h(
|
|
1262
|
+
'button',
|
|
1263
|
+
{
|
|
1264
|
+
type: 'button',
|
|
1265
|
+
onClick: open,
|
|
1266
|
+
title,
|
|
1267
|
+
style: {
|
|
1268
|
+
...styles.button,
|
|
1269
|
+
padding: '4px 2px',
|
|
1270
|
+
display: 'flex',
|
|
1271
|
+
flexDirection: 'column',
|
|
1272
|
+
alignItems: 'center',
|
|
1273
|
+
gap: '1px',
|
|
1274
|
+
background: 'transparent',
|
|
1275
|
+
border: 'none',
|
|
1276
|
+
},
|
|
1277
|
+
},
|
|
1278
|
+
h('span', { style: styles.dot(tone === palette.ok) }),
|
|
1279
|
+
// 收起态只有 ~48px:余额 + 今日积分各一行,其余看 title。
|
|
1280
|
+
h('span', { style: { fontSize: '11px', color: palette.text, ...styles.mono } }, summary.balance),
|
|
1281
|
+
h('span', { style: { fontSize: '9px', color: palette.muted, ...styles.mono } }, summary.credit),
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/* ------------------------------- 插件入口 ---------------------------- */
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* 包一层 try/catch 的 effect:某一块挂不上(比如宿主版本没有这个插槽)
|
|
1289
|
+
* 只影响这一块,不要牵连另一块。
|
|
1290
|
+
*/
|
|
1291
|
+
function safeEffect(ctx, label, factory) {
|
|
1292
|
+
try {
|
|
1293
|
+
ctx.effect(factory, `${NS}: ${label}`)
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
// 记录到控制台即可:UI 里放一条「这块没加载」反而干扰用户。
|
|
1296
|
+
console.warn(`[${NS}] ${label} 挂载失败`, error)
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** 插件主体。 */
|
|
1301
|
+
function apply(ctx) {
|
|
1302
|
+
const t = ctx.locale.bind(NS)
|
|
1303
|
+
|
|
1304
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), `${NS}: dictionaries`)
|
|
1305
|
+
|
|
1306
|
+
safeEffect(ctx, 'settings.section', () =>
|
|
1307
|
+
ctx.slots.inject('settings.section', () =>
|
|
1308
|
+
ctx.slots.register(
|
|
1309
|
+
{
|
|
1310
|
+
name: 'settings.section',
|
|
1311
|
+
id: 'workbuddy-hub',
|
|
1312
|
+
order: 46,
|
|
1313
|
+
label: () => t('nav'),
|
|
1314
|
+
locale: NS,
|
|
1315
|
+
inject: () => ({ t }),
|
|
1316
|
+
},
|
|
1317
|
+
(props) => h(HubPanel, { ...props, t }),
|
|
1318
|
+
),
|
|
1319
|
+
),
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
safeEffect(ctx, 'sidebar.footer.action', () =>
|
|
1323
|
+
ctx.slots.inject('sidebar.footer.action', () =>
|
|
1324
|
+
ctx.slots.register(
|
|
1325
|
+
{
|
|
1326
|
+
name: 'sidebar.footer.action',
|
|
1327
|
+
id: 'workbuddy-hub-quota',
|
|
1328
|
+
order: 51,
|
|
1329
|
+
label: () => t('footerLabel'),
|
|
1330
|
+
locale: NS,
|
|
1331
|
+
inject: () => ({ t }),
|
|
1332
|
+
},
|
|
1333
|
+
(props) => h(HubFooter, { ...props, t }),
|
|
1334
|
+
),
|
|
1335
|
+
),
|
|
1336
|
+
)
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
exports.apply = apply
|
|
1340
|
+
exports.inject = ['slots', 'locale']
|
|
1341
|
+
exports.NS = NS
|
|
1342
|
+
exports.__store = store
|
|
1343
|
+
exports.__apiPath = apiPath
|
|
1344
|
+
exports.__requestJson = requestJson
|
|
1345
|
+
exports.__dicts = { zh, en }
|
|
1346
|
+
exports.__palette = palette
|
|
1347
|
+
exports.__styles = styles
|
|
1348
|
+
exports.__HubPanel = HubPanel
|
|
1349
|
+
exports.__HubFooter = HubFooter
|
|
1350
|
+
exports.__sortRowsByName = sortRowsByName
|
|
1351
|
+
exports.__footerSummary = footerSummary
|
|
1352
|
+
return module.exports
|
|
1353
|
+
},
|
|
1354
|
+
})
|