@young1lin/dsh-gpt-sub 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +27 -4
  3. package/client.js +383 -192
  4. package/package.json +5 -2
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  记录用户可感知的变化;格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
  版本号见 `package.json`。
5
5
 
6
+ ## [0.1.3] - 2026-10-08
7
+
8
+ ### 新增
9
+
10
+ - Codex 设置面板接入 DSH 中英文语言服务,按钮、提示、倒计时、文件浏览、确认与重置结果跟随语言切换;有效期按所选语言格式化。
11
+
12
+ ### 调整
13
+
14
+ - 设置导航统一命名为「Codex」,使用跟随主题的 Codex 图标替代默认齿轮,插件卸载时还原宿主图标。
15
+ - 改善重置条目的标题和状态/有效期排版,避免操作按钮挤压换行,并适配窄面板布局。
16
+ - 重置次数列表改为点击懒加载:打开/重开面板、切换语言、后台轮询与普通配额刷新不查询列表;首次点击「查询重置次数」后才读取,后续仅显式刷新或确认使用后更新。
17
+
18
+ ### 修复
19
+
20
+ - 保留使用重置次数后的结果提示,避免随列表刷新立即清空;已显示的提示可随语言更新。
21
+
6
22
  ## [0.1.2] - 2026-10-05
7
23
 
8
24
  ### 新增
package/README.md CHANGED
@@ -192,13 +192,29 @@ provider route never reads a stale token.
192
192
 
193
193
  ## Settings panel diagnostics
194
194
 
195
- The `Codex 配额` settings section does more than render the usage bars. It shows
195
+ The **Codex** section follows the language selected in Settings →
196
+ General (English or Chinese), using DSH's locale service like `dsh-mcp-adapter`.
197
+ Buttons, placeholders, countdowns, file browsing, confirmations, and existing
198
+ action feedback follow locale updates. While the panel remains mounted, a
199
+ locale update preserves an edited path or proxy URL; navigating to another
200
+ settings section unmounts the panel and discards unsaved edits.
201
+ Credit expiration dates use the selected language too; upstream diagnostic
202
+ messages, file paths, and account-provided titles remain unchanged.
203
+
204
+ The navigation row uses the [Codex mark from Lobe Icons](https://unpkg.com/@lobehub/icons-static-svg@1.95.1/icons/codex.svg)
205
+ (MIT, attribution included in [the client bundle](client.js)), not the shell's
206
+ default gear. Its monochrome SVG follows the theme and is restored on plugin
207
+ unload. Since the settings shell currently has no public icon slot, this uses
208
+ the same narrowly scoped, disposable DOM adaptation as `dsh-mcp-adapter`.
209
+ If a future shell changes its markup, the icon adaptation safely does nothing.
210
+
211
+ The section does more than render the usage bars. It shows
196
212
  one bar per rate-limit window the account reports — the 5-hour rolling window
197
213
  and the weekly window — each drawn Codex-style as the percent **remaining**: a
198
214
  full bar at 100% that drains toward 0% as the allowance is spent, with its own
199
- reset countdown. The countdown is exact, not rounded to one unit: `4h22m后重置`,
200
- `10m22s后重置` under an hour (seconds only appear there, where they still change
201
- fast enough to read), `2d5h后重置` past a day, and a zero component is dropped
215
+ reset countdown. The countdown is exact, not rounded to one unit: `Resets in 4h22m`
216
+ (or `4小时22分后重置` in Chinese), `10m22s` under an hour (seconds only appear there,
217
+ where they still change fast enough to read), `2d5h` past a day, and a zero component is dropped
202
218
  rather than padded — `4h`, never `4h0m`. An account with no 5-hour limit (pro
203
219
  accounts currently report only a weekly window) shows a note where that bar
204
220
  would be, so the absence reads as a fact about the plan rather than a broken
@@ -229,6 +245,13 @@ a capped window without waiting out its timer. Below the bars the panel shows:
229
245
  means direct. The line under the input notes whether the current value comes
230
246
  from cordis config or a page override.
231
247
 
248
+ Reset credit details are lazy-loaded: opening/reopening the panel, switching
249
+ languages, and background quota/status polling do not query the credit list.
250
+ Click **Check reset credits / 查询重置次数** for the first read; after that, only
251
+ the credit list's **Refresh / 刷新** button or a confirmed consumption refreshes
252
+ it. The header badge simply reuses the ordinary quota response, with no extra
253
+ credit-list request.
254
+
232
255
  Rate-limit reset credits (the codex CLI's `rate-limit-reset-credits` wham API): `GET /gpt-sub/reset-credits` lists the account's redeemable credits, `POST /gpt-sub/reset-credits/consume` (body `{ "creditId": "..." }`, optional) consumes one. The host mints a fresh `redeem_request_id` per click, and the panel asks for confirmation first because consuming is destructive.
233
256
 
234
257
  Host endpoints behind the panel: `GET /gpt-sub/quota` (the bars), `GET /gpt-sub/status`,
package/client.js CHANGED
@@ -41,7 +41,7 @@ window.__ModuleLoader__.load({
41
41
  const POLL_MS = 60_000
42
42
 
43
43
  const css = [
44
- '.dshGptSub{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
44
+ '.dshGptSub{box-sizing:border-box;container-type:inline-size;width:100%;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
45
45
  '.dshGptSubHead{display:flex;align-items:center;gap:8px;margin:0 0 10px}',
46
46
  '.dshGptSubPlan{font-weight:600;color:var(--dsw-alias-label-primary)}',
47
47
  '.dshGptSubTrack{position:relative;height:8px;border-radius:4px;background:var(--dsw-alias-interactive-bg-hover);overflow:hidden}',
@@ -54,13 +54,20 @@ window.__ModuleLoader__.load({
54
54
  '.dshGptSubWinLabel{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-bottom:4px}',
55
55
  '.dshGptSubNote{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
56
56
  '.dshGptSubBtn{margin-left:auto}',
57
- '.dshGptSubDiag{margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-interactive-bg-hover);display:grid;grid-template-columns:max-content 1fr;gap:8px 16px;align-items:center}',
58
- '.dshGptSubDiagLabel{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px}',
57
+ '.dshGptSubDiag{margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-interactive-bg-hover);display:grid;grid-template-columns:max-content minmax(0,1fr);gap:14px 16px;align-items:start}',
58
+ '.dshGptSubDiagLabel{padding-top:6px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px}',
59
59
  '.dshGptSubDiagValue{min-width:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary)}',
60
- '.dshGptSubProxyRow{display:flex;gap:8px;align-items:center;min-width:0}',
61
- '.dshGptSubProxyField{flex:1;min-width:0;display:flex}',
62
- '.dshGptSubProxyField>span{flex:1}',
63
- '.dshGptSubProxyMeta{display:flex;align-items:center;gap:6px;margin-top:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
60
+ '.dshGptSubProxyRow{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0}',
61
+ '.dshGptSubProxyField{flex:1 1 180px;min-width:0;display:flex}',
62
+ '.dshGptSubProxyField>*{flex:1;min-width:0}',
63
+ '.dshGptSubProxyMeta{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin-top:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
64
+ '.dshGptSubCreditList{display:flex;flex-direction:column;gap:12px;margin-top:12px}',
65
+ '.dshGptSubCreditRow{display:flex;align-items:center;justify-content:space-between;gap:16px;min-width:0}',
66
+ '.dshGptSubCreditText{flex:1;min-width:0;overflow-wrap:anywhere}',
67
+ '.dshGptSubCreditTitle{color:var(--dsw-alias-label-secondary);font-size:13px}',
68
+ '.dshGptSubCreditMeta{display:flex;flex-wrap:wrap;gap:2px 8px;margin-top:2px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
69
+ '@container (max-width:560px){.dshGptSubDiag{grid-template-columns:minmax(0,1fr);gap:6px}.dshGptSubDiagLabel{padding-top:8px}.dshGptSubHead{flex-wrap:wrap}}',
70
+ '@container (max-width:340px){.dshGptSubProxyField{flex-basis:100%}.dshGptSubCreditRow{flex-direction:column;align-items:flex-start;gap:6px}}',
64
71
  // The credential-file picker: a small host-backed directory browser.
65
72
  '.dshGptSubBrowseMask{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}',
66
73
  '.dshGptSubBrowse{width:min(560px,92vw);max-height:72vh;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-interactive-bg-hover);border-radius:12px;color:var(--dsw-alias-label-secondary);box-shadow:0 12px 40px rgba(0,0,0,.25)}',
@@ -93,14 +100,18 @@ window.__ModuleLoader__.load({
93
100
  * @param props - variant, children, and native button attributes.
94
101
  * @returns the button element.
95
102
  */
96
- const SmallButton = (props) =>
97
- Button !== undefined
98
- ? React.createElement(Button, { size: 'sm', ...props })
103
+ const SmallButton = (props) => {
104
+ // The primitive's small size does not prevent flex compression: keep
105
+ // action labels on one line, particularly "Use and reset".
106
+ const buttonProps = { ...props, style: { ...props.style, whiteSpace: 'nowrap', flexShrink: 0 } }
107
+ return Button !== undefined
108
+ ? React.createElement(Button, { size: 'sm', ...buttonProps })
99
109
  : React.createElement('button', {
100
110
  type: 'button',
101
111
  className: 'dshGptSubFallbackButton',
102
- ...props,
112
+ ...buttonProps,
103
113
  })
114
+ }
104
115
 
105
116
  /**
106
117
  * Render a pill badge through the primitives, or a styled native span.
@@ -128,57 +139,26 @@ window.__ModuleLoader__.load({
128
139
  return '#ef4444'
129
140
  }
130
141
 
131
- /**
132
- * Render a remaining duration in compact units, keeping two adjacent
133
- * components and dropping a trailing zero: days+hours past a day,
134
- * hours+minutes past an hour, minutes+seconds under one -- '4h22m',
135
- * '10m22s', '2d5h', '45s', never '4h0m'. Seconds only appear under an
136
- * hour, where they still change fast enough to read.
137
- * @param seconds - whole remaining seconds; negative clamps to zero.
138
- * @returns the compact duration.
139
- */
140
- const compactDuration = (seconds) => {
142
+ /** Compact durations, with units from the active language dictionary. */
143
+ const compactDuration = (seconds, t) => {
141
144
  const safe = Math.max(0, seconds)
142
145
  const days = Math.floor(safe / 86400)
143
146
  const hours = Math.floor((safe % 86400) / 3600)
144
147
  const minutes = Math.floor((safe % 3600) / 60)
145
148
  const rest = safe % 60
146
- if (days > 0) return days + 'd' + (hours > 0 ? hours + 'h' : '')
147
- if (hours > 0) return hours + 'h' + (minutes > 0 ? minutes + 'm' : '')
148
- if (minutes > 0) return minutes + 'm' + (rest > 0 ? rest + 's' : '')
149
- return rest + 's'
149
+ if (days > 0) return days + t('unitDay') + (hours > 0 ? hours + t('unitHour') : '')
150
+ if (hours > 0) return hours + t('unitHour') + (minutes > 0 ? minutes + t('unitMinute') : '')
151
+ if (minutes > 0) return minutes + t('unitMinute') + (rest > 0 ? rest + t('unitSecond') : '')
152
+ return rest + t('unitSecond')
150
153
  }
151
154
 
152
- /**
153
- * Render an epoch-ms timestamp as an exact countdown.
154
- * @param at - epoch milliseconds.
155
- * @param now - epoch milliseconds.
156
- * @returns '4h22m后'-style, '即将' when due, or '' when unknown.
157
- */
158
- const countdownFrom = (at, now) => {
155
+ /** Localize the entire countdown, rather than appending Chinese suffixes. */
156
+ const countdownFrom = (at, now, t, kind) => {
159
157
  if (!at) return ''
160
158
  const seconds = Math.max(0, Math.round((at - now) / 1000))
161
- if (seconds === 0) return '即将'
162
- return compactDuration(seconds) + '后'
159
+ return seconds === 0 ? t(kind + 'Soon') : t(kind + 'In', { duration: compactDuration(seconds, t) })
163
160
  }
164
-
165
- /**
166
- * Name what a countdown counts down to: '4h22m后' + '重置' reads
167
- * '4h22m后重置', and '即将' becomes '即将重置'.
168
- * @param base - a countdownFrom result.
169
- * @param noun - what happens when the countdown ends.
170
- * @returns the named string, or '' untouched.
171
- */
172
- const withNoun = (base, noun) =>
173
- base === '' ? '' : base === '即将' ? '即将' + noun : base.replace(/后$/, '后' + noun)
174
-
175
- /**
176
- * Render a reset time as an exact countdown.
177
- * @param resetAt - epoch seconds.
178
- * @param now - epoch milliseconds.
179
- * @returns '4h22m后重置', or an empty string when unknown.
180
- */
181
- const countdown = (resetAt, now) => withNoun(countdownFrom(resetAt * 1000, now), '重置')
161
+ const countdown = (resetAt, now, t) => countdownFrom(resetAt * 1000, now, t, 'reset')
182
162
 
183
163
  // The latest quota reading, plus the subscribers waiting on it.
184
164
  let state = { phase: 'loading' }
@@ -217,10 +197,10 @@ window.__ModuleLoader__.load({
217
197
  polling ??= (async () => {
218
198
  try {
219
199
  const response = await fetch(ENDPOINT + (force ? '?refresh=1' : ''), { cache: 'no-store' })
220
- if (!response.ok) throw new Error('endpoint answered ' + response.status)
200
+ if (!response.ok) throw endpointError(response.status)
221
201
  publish(await response.json())
222
202
  } catch (error) {
223
- publish({ phase: 'error', message: error instanceof Error ? error.message : String(error) })
203
+ publish({ phase: 'error', message: error })
224
204
  }
225
205
  })().finally(() => {
226
206
  polling = undefined
@@ -236,10 +216,10 @@ window.__ModuleLoader__.load({
236
216
  statusPolling ??= (async () => {
237
217
  try {
238
218
  const response = await fetch(STATUS_ENDPOINT, { cache: 'no-store' })
239
- if (!response.ok) throw new Error('endpoint answered ' + response.status)
219
+ if (!response.ok) throw endpointError(response.status)
240
220
  publishStatus(await response.json())
241
221
  } catch (error) {
242
- publishStatus({ error: error instanceof Error ? error.message : String(error) })
222
+ publishStatus({ error })
243
223
  }
244
224
  })().finally(() => {
245
225
  statusPolling = undefined
@@ -260,65 +240,195 @@ window.__ModuleLoader__.load({
260
240
  body: JSON.stringify(body),
261
241
  })
262
242
  const payload = await response.json().catch(() => ({}))
263
- if (!response.ok) throw new Error(payload.message || 'endpoint answered ' + response.status)
243
+ if (!response.ok) throw payload.message ? new Error(payload.message) : endpointError(response.status)
264
244
  return payload
265
245
  }
266
246
 
267
- /** Short fixed strings, kept out of the render body. */
268
- const STR = {
269
- authSource: 'Auth 来源',
270
- nextRefresh: '自动刷新',
271
- proxy: 'HTTP 代理',
272
- direct: '直连',
273
- test: '测试连接',
274
- save: '保存',
275
- saved: '已保存并生效,重启后仍使用该代理',
276
- testing: '测试中…',
277
- saving: '保存中…',
278
- marginNote: (margin, sync) => 'Token 剩余不足 ' + margin + ' 分钟时自动刷新;每 ' + sync + ' 分钟定期同步',
279
- unknown: '未知(Token 无有效 exp)',
280
- configOnly: '来自配置',
281
- overridden: '页面设置',
282
- ok: (ms) => '连接正常 · ' + ms + 'ms',
283
- fail: '连接失败',
284
- authFileLabel: 'Auth 文件',
285
- authPlaceholder: '~/.codex/auth.json',
286
- authSaved: '已保存并生效,重启后仍使用该路径',
287
- authResetHint: '清空并保存恢复配置默认',
288
- authUnpublished: '已切换,但 Token 发布失败,将在下个同步周期自动重试',
289
- browse: '浏览…',
290
- browseTitle: '选择 auth 文件',
291
- cancel: '取消',
292
- upLabel: '..',
293
- truncatedNote: '条目过多,仅显示部分',
294
- browseFail: '无法读取目录',
295
- noFiveHour: '当前账号无 5 小时限额,仅按周限额计',
296
- noWindows: '上游未报告任何限额窗口',
297
- resetsLeft: (count) => '还有 ' + count + ' 次重置',
298
- resetCredits: '重置次数',
299
- rcLoad: '查询重置次数',
300
- rcLoading: '查询中…',
301
- rcAvailable: (n) => '可用 ' + n + ' 次',
302
- rcEmpty: '当前没有可用的重置次数',
303
- rcUse: '使用并重置',
304
- rcUsing: '使用中…',
305
- rcConfirm: (title) => '确定使用这个重置次数吗?将立即重置配额窗口:' + (title || '未命名'),
306
- rcConsumeFail: '使用失败',
307
- rcOutcomes: {
308
- reset: (n) => '✓ 已重置(' + n + ' 个窗口)',
309
- nothing_to_reset: '✗ 当前没有可重置的窗口',
310
- no_credit: '✗ 没有可用的重置次数',
311
- already_redeemed: '✗ 该次数已被使用过',
312
- },
313
- rcStatus: { available: '可用', redeeming: '兑换中', redeemed: '已使用', unknown: '未知' },
314
- rcExpires: (text) => '(' + text + ' 前有效)',
247
+ // Same namespace / dictionary / slot-locale contract as dsh-mcp-adapter.
248
+ const NS = 'settings.gpt-sub-quota'
249
+ const en = {
250
+ nav: "Codex",
251
+ authSource: "Auth source",
252
+ nextRefresh: "Automatic refresh",
253
+ proxy: "HTTP proxy",
254
+ direct: "Direct connection",
255
+ test: "Test connection",
256
+ save: "Save",
257
+ saved: "Saved and applied; this proxy will still be used after restart",
258
+ testing: "Testing…",
259
+ saving: "Saving…",
260
+ marginNote: "Refresh the token when less than {margin} minutes remain; sync every {sync} minutes",
261
+ unknown: "Unknown (token has no valid exp)",
262
+ configOnly: "From configuration",
263
+ overridden: "Panel override",
264
+ ok: "Connected · {ms} ms",
265
+ fail: "Connection failed",
266
+ proxyPlaceholder: "http://127.0.0.1:7890; leave empty for a direct connection",
267
+ authFileLabel: "Auth file",
268
+ authPlaceholder: "~/.codex/auth.json",
269
+ authSaved: "Saved and applied; this path will still be used after restart",
270
+ authResetHint: "Clear and save to restore the configured default",
271
+ authUnpublished: "Switched, but token publishing failed; the next sync will retry automatically",
272
+ browse: "Browse…",
273
+ browseTitle: "Select an auth file",
274
+ cancel: "Cancel",
275
+ upLabel: "..",
276
+ truncatedNote: "Too many entries; only some are shown",
277
+ browseFail: "Cannot read this directory",
278
+ noFiveHour: "This account has no 5-hour limit; only the weekly limit applies",
279
+ noWindows: "The upstream service reported no rate-limit windows",
280
+ resetsLeft: "Reset credits remaining: {count}",
281
+ resetCredits: "Reset credits",
282
+ rcLoad: "Check reset credits",
283
+ rcLoading: "Checking…",
284
+ rcAvailable: "Available: {n}",
285
+ rcEmpty: "No reset credits are available",
286
+ rcUse: "Use and reset",
287
+ rcUsing: "Using…",
288
+ rcConfirm: "Use this reset credit? This will immediately reset the quota window: {title}",
289
+ unnamed: "Unnamed",
290
+ rcConsumeFail: "Use failed: {message}",
291
+ rcOutcomeReset: "✓ Reset complete (windows: {n})",
292
+ rcOutcomeNothing: "✗ No windows can be reset right now",
293
+ rcOutcomeNoCredit: "✗ No reset credits are available",
294
+ rcOutcomeRedeemed: "✗ This credit has already been used",
295
+ rcOutcomeUnknown: "Unexpected reset result: {code}",
296
+ rcStatusAvailable: "Available",
297
+ rcStatusRedeeming: "Redeeming",
298
+ rcStatusRedeemed: "Used",
299
+ rcStatusUnknown: "Unknown",
300
+ rcExpires: " (expires {text})",
301
+ refresh: "Refresh",
302
+ loading: "Loading…",
303
+ quotaError: "Cannot read quota: {message}",
304
+ unknownError: "Unknown error",
305
+ retry: "Retry",
306
+ windowDays: "{n}-day window",
307
+ windowHours: "{n}-hour window",
308
+ remaining: "{percent}% remaining",
309
+ stale: "Data may be stale: {message}",
310
+ accessToken: "Access token",
311
+ resetSoon: "Resets soon",
312
+ resetIn: "Resets in {duration}",
313
+ refreshSoon: "Refreshing soon",
314
+ refreshIn: "Refreshes in {duration}",
315
+ expireSoon: "Expires soon",
316
+ expireIn: "Expires in {duration}",
317
+ unitDay: "d",
318
+ unitHour: "h",
319
+ unitMinute: "m",
320
+ unitSecond: "s",
321
+ dateLocale: "en-US",
322
+ endpointError: "Endpoint returned HTTP {status}",
323
+ }
324
+ const zh = {
325
+ nav: "Codex",
326
+ authSource: "认证来源",
327
+ nextRefresh: "自动刷新",
328
+ proxy: "HTTP 代理",
329
+ direct: "直连",
330
+ test: "测试连接",
331
+ save: "保存",
332
+ saved: "已保存并生效,重启后仍使用该代理",
333
+ testing: "测试中…",
334
+ saving: "保存中…",
335
+ marginNote: "Token 剩余不足 {margin} 分钟时自动刷新;每 {sync} 分钟定期同步",
336
+ unknown: "未知(Token 无有效 exp)",
337
+ configOnly: "来自配置",
338
+ overridden: "页面设置",
339
+ ok: "连接正常 · {ms} 毫秒",
340
+ fail: "连接失败",
341
+ proxyPlaceholder: "http://127.0.0.1:7890,留空表示直连",
342
+ authFileLabel: "认证文件",
343
+ authPlaceholder: "~/.codex/auth.json",
344
+ authSaved: "已保存并生效,重启后仍使用该路径",
345
+ authResetHint: "清空并保存恢复配置默认",
346
+ authUnpublished: "已切换,但 Token 发布失败,将在下个同步周期自动重试",
347
+ browse: "浏览…",
348
+ browseTitle: "选择认证文件",
349
+ cancel: "取消",
350
+ upLabel: "..",
351
+ truncatedNote: "条目过多,仅显示部分",
352
+ browseFail: "无法读取目录",
353
+ noFiveHour: "当前账号无 5 小时限额,仅按周限额计",
354
+ noWindows: "上游未报告任何限额窗口",
355
+ resetsLeft: "还有 {count} 次重置",
356
+ resetCredits: "重置次数",
357
+ rcLoad: "查询重置次数",
358
+ rcLoading: "查询中…",
359
+ rcAvailable: "可用 {n} 次",
360
+ rcEmpty: "当前没有可用的重置次数",
361
+ rcUse: "使用并重置",
362
+ rcUsing: "使用中…",
363
+ rcConfirm: "确定使用这个重置次数吗?将立即重置配额窗口:{title}",
364
+ unnamed: "未命名",
365
+ rcConsumeFail: "使用失败:{message}",
366
+ rcOutcomeReset: "✓ 已重置({n} 个窗口)",
367
+ rcOutcomeNothing: "✗ 当前没有可重置的窗口",
368
+ rcOutcomeNoCredit: "✗ 没有可用的重置次数",
369
+ rcOutcomeRedeemed: "✗ 该次数已被使用过",
370
+ rcOutcomeUnknown: "未知重置结果:{code}",
371
+ rcStatusAvailable: "可用",
372
+ rcStatusRedeeming: "兑换中",
373
+ rcStatusRedeemed: "已使用",
374
+ rcStatusUnknown: "未知",
375
+ rcExpires: "({text} 前有效)",
376
+ refresh: "刷新",
377
+ loading: "读取中…",
378
+ quotaError: "无法读取配额:{message}",
379
+ unknownError: "未知错误",
380
+ retry: "重试",
381
+ windowDays: "{n} 天窗口",
382
+ windowHours: "{n} 小时窗口",
383
+ remaining: "剩余 {percent}%",
384
+ stale: "数据可能已过期:{message}",
385
+ accessToken: "访问令牌",
386
+ resetSoon: "即将重置",
387
+ resetIn: "{duration}后重置",
388
+ refreshSoon: "即将刷新",
389
+ refreshIn: "{duration}后刷新",
390
+ expireSoon: "即将过期",
391
+ expireIn: "{duration}后过期",
392
+ unitDay: "天",
393
+ unitHour: "小时",
394
+ unitMinute: "分",
395
+ unitSecond: "秒",
396
+ dateLocale: "zh-CN",
397
+ endpointError: "接口返回 HTTP {status}",
398
+ }
399
+
400
+ const interpolate = (template, params = {}) =>
401
+ template.replace(/\{(\w+)\}/g, (match, key) =>
402
+ Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : match)
403
+ const fallbackT = (key, params) => interpolate(en[key] ?? key, params)
404
+
405
+ // Keep HTTP failures and action notes as data, not translated snapshots.
406
+ // A language switch also updates a success/error note already on screen.
407
+ const endpointError = (status) => Object.assign(new Error(), { endpointStatus: status })
408
+ const errorText = (error, t) => {
409
+ if (error && error.endpointStatus !== undefined) return t('endpointError', { status: error.endpointStatus })
410
+ if (error && error.key) return t(error.key)
411
+ return (error && error.message) || (error ? String(error) : t('unknownError'))
412
+ }
413
+ const noteText = (note, t) => {
414
+ if (!note) return ''
415
+ if (note.key) return t(note.key, { ...note.params, ...(note.error ? { message: errorText(note.error, t) } : {}) })
416
+ return '✗ ' + errorText(note.error, t)
417
+ }
418
+ const resetOutcomeKeys = {
419
+ reset: 'rcOutcomeReset', nothing_to_reset: 'rcOutcomeNothing',
420
+ no_credit: 'rcOutcomeNoCredit', already_redeemed: 'rcOutcomeRedeemed',
421
+ }
422
+ const creditStatusKeys = {
423
+ available: 'rcStatusAvailable', redeeming: 'rcStatusRedeeming',
424
+ redeemed: 'rcStatusRedeemed', unknown: 'rcStatusUnknown',
315
425
  }
316
426
 
317
427
  /**
318
428
  * The proxy control block: current URL, editable, with test and save.
319
429
  * @returns the rendered block.
320
430
  */
321
- const ProxyControl = () => {
431
+ const ProxyControl = ({ t }) => {
322
432
  const current = status && status.proxyUrl !== undefined ? status.proxyUrl : ''
323
433
  const [draft, setDraft] = useState(current)
324
434
  const [dirty, setDirty] = useState(false)
@@ -342,9 +452,9 @@ window.__ModuleLoader__.load({
342
452
  try {
343
453
  const probed = await postJson(PROXY_TEST_ENDPOINT, { proxyUrl: draft.trim() })
344
454
  setResult(probed)
345
- if (!probed.ok) setNote('✗ ' + (probed.message || STR.fail))
455
+ if (!probed.ok) setNote({ error: probed.message || { key: 'fail' } })
346
456
  } catch (error) {
347
- setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
457
+ setNote({ error })
348
458
  } finally {
349
459
  setBusy('')
350
460
  }
@@ -361,10 +471,10 @@ window.__ModuleLoader__.load({
361
471
  try {
362
472
  await postJson(PROXY_ENDPOINT, { proxyUrl: draft.trim() })
363
473
  setDirty(false)
364
- setNote(STR.saved)
474
+ setNote({ key: 'saved' })
365
475
  await Promise.all([pollStatus(), poll(false)])
366
476
  } catch (error) {
367
- setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
477
+ setNote({ error })
368
478
  } finally {
369
479
  setBusy('')
370
480
  }
@@ -372,16 +482,17 @@ window.__ModuleLoader__.load({
372
482
 
373
483
  const okPill =
374
484
  result && result.ok
375
- ? React.createElement(Badge, null, '✓ ' + STR.ok(result.latencyMs))
485
+ ? React.createElement(Badge, null, '✓ ' + t('ok', { ms: result.latencyMs }))
376
486
  : null
377
487
  const failPill =
378
488
  result && !result.ok
379
- ? React.createElement(Badge, null, '✗ ' + STR.fail)
489
+ ? React.createElement(Badge, null, '✗ ' + t('fail'))
380
490
  : null
381
491
 
382
492
  const inputProps = {
383
493
  value: draft,
384
- placeholder: 'http://127.0.0.1:7890,留空表示直连',
494
+ placeholder: t('proxyPlaceholder'),
495
+ 'aria-label': t('proxy'),
385
496
  spellCheck: false,
386
497
  disabled: busy !== '',
387
498
  onChange: (event) => {
@@ -406,23 +517,23 @@ window.__ModuleLoader__.load({
406
517
  React.createElement(
407
518
  SmallButton,
408
519
  { variant: 'outline', disabled: busy !== '', onClick: onTest },
409
- busy === 'test' ? STR.testing : STR.test,
520
+ busy === 'test' ? t('testing') : t('test'),
410
521
  ),
411
522
  React.createElement(
412
523
  SmallButton,
413
524
  { variant: 'primary', disabled: busy !== '', onClick: onSave },
414
- busy === 'save' ? STR.saving : STR.save,
525
+ busy === 'save' ? t('saving') : t('save'),
415
526
  ),
416
527
  ),
417
528
  React.createElement(
418
529
  'div',
419
530
  { className: 'dshGptSubProxyMeta' },
420
- React.createElement(Badge, null, current === '' ? STR.direct : current),
421
- React.createElement(Badge, null, status && status.overridden ? STR.overridden : STR.configOnly),
531
+ React.createElement(Badge, null, current === '' ? t('direct') : current),
532
+ React.createElement(Badge, null, status && status.overridden ? t('overridden') : t('configOnly')),
422
533
  okPill,
423
534
  failPill,
424
535
  ),
425
- note ? React.createElement('div', { className: 'dshGptSubNote' }, note) : null,
536
+ note ? React.createElement('div', { className: 'dshGptSubNote' }, noteText(note, t)) : null,
426
537
  )
427
538
  }
428
539
 
@@ -435,7 +546,7 @@ window.__ModuleLoader__.load({
435
546
  * picker, which is why browsing runs through the host instead.
436
547
  * @returns the rendered block.
437
548
  */
438
- const AuthControl = () => {
549
+ const AuthControl = ({ t }) => {
439
550
  const current = status && status.authFile !== undefined ? status.authFile : ''
440
551
  const [draft, setDraft] = useState(current)
441
552
  const [dirty, setDirty] = useState(false)
@@ -458,10 +569,10 @@ window.__ModuleLoader__.load({
458
569
  try {
459
570
  const payload = await postJson(AUTH_ENDPOINT, { authFile: draft.trim() })
460
571
  setDirty(false)
461
- setNote(payload.published === false ? STR.authUnpublished : STR.authSaved)
572
+ setNote({ key: payload.published === false ? 'authUnpublished' : 'authSaved' })
462
573
  await pollStatus()
463
574
  } catch (error) {
464
- setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
575
+ setNote({ error })
465
576
  } finally {
466
577
  setBusy(false)
467
578
  }
@@ -478,10 +589,10 @@ window.__ModuleLoader__.load({
478
589
  const query = path ? '?path=' + encodeURIComponent(path) : ''
479
590
  const response = await fetch(AUTH_BROWSE_ENDPOINT + query, { cache: 'no-store' })
480
591
  const payload = await response.json().catch(() => ({}))
481
- if (!response.ok || !payload.ok) throw new Error(payload.message || STR.browseFail)
592
+ if (!response.ok || !payload.ok) throw payload.message ? new Error(payload.message) : { key: 'browseFail' }
482
593
  setBrowse(payload)
483
594
  } catch (error) {
484
- setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
595
+ setNote({ error })
485
596
  }
486
597
  }
487
598
 
@@ -498,7 +609,8 @@ window.__ModuleLoader__.load({
498
609
 
499
610
  const inputProps = {
500
611
  value: draft,
501
- placeholder: STR.authPlaceholder,
612
+ placeholder: t('authPlaceholder'),
613
+ 'aria-label': t('authFileLabel'),
502
614
  spellCheck: false,
503
615
  disabled: busy,
504
616
  onChange: (event) => {
@@ -526,21 +638,21 @@ window.__ModuleLoader__.load({
526
638
  React.createElement(
527
639
  SmallButton,
528
640
  { variant: 'outline', disabled: busy, onClick: () => void openBrowserAt('') },
529
- STR.browse,
641
+ t('browse'),
530
642
  ),
531
643
  React.createElement(
532
644
  SmallButton,
533
645
  { variant: 'primary', disabled: busy, onClick: onSave },
534
- busy ? STR.saving : STR.save,
646
+ busy ? t('saving') : t('save'),
535
647
  ),
536
648
  ),
537
649
  React.createElement(
538
650
  'div',
539
651
  { className: 'dshGptSubProxyMeta' },
540
- React.createElement(Badge, null, status && status.authOverridden ? STR.overridden : STR.configOnly),
541
- React.createElement('span', null, STR.authResetHint),
652
+ React.createElement(Badge, null, status && status.authOverridden ? t('overridden') : t('configOnly')),
653
+ React.createElement('span', null, t('authResetHint')),
542
654
  ),
543
- note ? React.createElement('div', { className: 'dshGptSubNote' }, note) : null,
655
+ note ? React.createElement('div', { className: 'dshGptSubNote' }, noteText(note, t)) : null,
544
656
  ),
545
657
  browse === undefined
546
658
  ? null
@@ -550,7 +662,7 @@ window.__ModuleLoader__.load({
550
662
  React.createElement(
551
663
  'div',
552
664
  { className: 'dshGptSubBrowse', onClick: (event) => event.stopPropagation() },
553
- React.createElement('div', { className: 'dshGptSubBrowseHead' }, STR.browseTitle),
665
+ React.createElement('div', { className: 'dshGptSubBrowseHead' }, t('browseTitle')),
554
666
  React.createElement('div', { className: 'dshGptSubBrowsePath' }, browse.dir),
555
667
  React.createElement(
556
668
  'ul',
@@ -564,7 +676,7 @@ window.__ModuleLoader__.load({
564
676
  onClick: () => void openBrowserAt(browse.parent),
565
677
  },
566
678
  React.createElement('span', { className: 'dshGptSubBrowseMark' }, '↰'),
567
- STR.upLabel,
679
+ t('upLabel'),
568
680
  )
569
681
  : null,
570
682
  browse.entries.map((entry) =>
@@ -585,12 +697,12 @@ window.__ModuleLoader__.load({
585
697
  'div',
586
698
  { className: 'dshGptSubBrowseFoot' },
587
699
  browse.truncated
588
- ? React.createElement('span', { className: 'dshGptSubNote' }, STR.truncatedNote)
700
+ ? React.createElement('span', { className: 'dshGptSubNote' }, t('truncatedNote'))
589
701
  : null,
590
702
  React.createElement(
591
703
  SmallButton,
592
704
  { variant: 'outline', onClick: () => setBrowse(undefined) },
593
- STR.cancel,
705
+ t('cancel'),
594
706
  ),
595
707
  ),
596
708
  ),
@@ -604,7 +716,7 @@ window.__ModuleLoader__.load({
604
716
  * Consuming is destructive, so nothing here fires without a click.
605
717
  * @returns the rendered block.
606
718
  */
607
- const ResetCreditsControl = () => {
719
+ const ResetCreditsControl = ({ t }) => {
608
720
  const [details, setDetails] = useState(undefined)
609
721
  const [busy, setBusy] = useState(false)
610
722
  const [consumingId, setConsumingId] = useState('')
@@ -614,25 +726,23 @@ window.__ModuleLoader__.load({
614
726
  * Fetch the credit list from the host.
615
727
  * @returns nothing; failures surface as the note.
616
728
  */
617
- const load = async () => {
729
+ const load = async (preserveNote = false) => {
618
730
  setBusy(true)
619
- setNote('')
731
+ if (!preserveNote) setNote('')
620
732
  try {
621
733
  const response = await fetch(RESET_CREDITS_ENDPOINT, { cache: 'no-store' })
622
734
  const payload = await response.json().catch(() => ({}))
623
- if (!response.ok || !payload.ok) throw new Error(payload.message || 'endpoint answered ' + response.status)
735
+ if (!response.ok || !payload.ok) throw payload.message ? new Error(payload.message) : endpointError(response.status)
624
736
  setDetails(payload)
625
737
  } catch (error) {
626
- setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
738
+ setNote({ error })
627
739
  } finally {
628
740
  setBusy(false)
629
741
  }
630
742
  }
631
743
 
632
- // Load once on mount; later reads happen through the visible button.
633
- useEffect(() => {
634
- void load()
635
- }, [])
744
+ // Never query on mount, locale changes, or quota polling. The first
745
+ // read is user-triggered; later reads come from Refresh or consumption.
636
746
 
637
747
  /**
638
748
  * Consume one credit after a confirmation dialog, then refresh both the
@@ -642,18 +752,18 @@ window.__ModuleLoader__.load({
642
752
  */
643
753
  const consume = async (credit) => {
644
754
  const title = credit ? credit.title || credit.id : ''
645
- if (!window.confirm(STR.rcConfirm(title))) return
755
+ if (!window.confirm(t('rcConfirm', { title: title || t('unnamed') }))) return
646
756
  setConsumingId(credit ? credit.id : '*')
647
757
  setNote('')
648
758
  try {
649
759
  const payload = await postJson(RESET_CREDITS_CONSUME_ENDPOINT, {
650
760
  ...(credit ? { creditId: credit.id } : {}),
651
761
  })
652
- const outcome = STR.rcOutcomes[payload.code]
653
- setNote(outcome ? outcome(payload.windows_reset || 0) : payload.code)
654
- await Promise.all([load(), poll(false)])
762
+ setNote({ key: resetOutcomeKeys[payload.code] || 'rcOutcomeUnknown',
763
+ params: { n: payload.windows_reset || 0, code: payload.code } })
764
+ await Promise.all([load(true), poll(false)])
655
765
  } catch (error) {
656
- setNote('✗ ' + STR.rcConsumeFail + ':' + (error instanceof Error ? error.message : String(error)))
766
+ setNote({ key: 'rcConsumeFail', error })
657
767
  } finally {
658
768
  setConsumingId('')
659
769
  }
@@ -666,9 +776,9 @@ window.__ModuleLoader__.load({
666
776
  React.createElement(
667
777
  SmallButton,
668
778
  { variant: 'outline', disabled: busy, onClick: () => void load() },
669
- busy ? STR.rcLoading : STR.rcLoad,
779
+ busy ? t('rcLoading') : t('rcLoad'),
670
780
  ),
671
- note ? React.createElement('span', null, note) : null,
781
+ note ? React.createElement('span', null, noteText(note, t)) : null,
672
782
  )
673
783
  }
674
784
 
@@ -679,31 +789,37 @@ window.__ModuleLoader__.load({
679
789
  React.createElement(
680
790
  'div',
681
791
  { className: 'dshGptSubProxyMeta' },
682
- React.createElement(Badge, null, STR.rcAvailable(details.available_count)),
792
+ React.createElement(Badge, null, t('rcAvailable', { n: details.available_count })),
683
793
  React.createElement(
684
794
  SmallButton,
685
795
  { variant: 'ghost', disabled: busy, onClick: () => void load() },
686
- busy ? STR.rcLoading : '刷新',
796
+ busy ? t('rcLoading') : t('refresh'),
687
797
  ),
688
798
  ),
689
799
  available.length === 0
690
- ? React.createElement('div', { className: 'dshGptSubNote' }, STR.rcEmpty)
800
+ ? React.createElement('div', { className: 'dshGptSubNote' }, t('rcEmpty'))
691
801
  : React.createElement(
692
802
  'div',
693
- null,
803
+ { className: 'dshGptSubCreditList' },
694
804
  available.map((credit) =>
695
805
  React.createElement(
696
806
  'div',
697
- { key: credit.id, className: 'dshGptSubProxyMeta' },
807
+ { key: credit.id, className: 'dshGptSubCreditRow' },
698
808
  React.createElement(
699
- 'span',
700
- null,
701
- (credit.title || credit.id) +
702
- ' · ' +
703
- (STR.rcStatus[credit.status] || credit.status) +
704
- (credit.expires_at
705
- ? STR.rcExpires(new Date(credit.expires_at).toLocaleString())
706
- : ''),
809
+ 'div',
810
+ { className: 'dshGptSubCreditText' },
811
+ React.createElement('div', { className: 'dshGptSubCreditTitle' }, credit.title || credit.id),
812
+ React.createElement(
813
+ 'div',
814
+ { className: 'dshGptSubCreditMeta' },
815
+ React.createElement('span', null,
816
+ creditStatusKeys[credit.status] ? t(creditStatusKeys[credit.status]) : credit.status),
817
+ credit.expires_at
818
+ ? React.createElement('span', null, t('rcExpires', {
819
+ text: new Date(credit.expires_at).toLocaleString(t('dateLocale')),
820
+ }))
821
+ : null,
822
+ ),
707
823
  ),
708
824
  React.createElement(
709
825
  SmallButton,
@@ -712,12 +828,12 @@ window.__ModuleLoader__.load({
712
828
  disabled: busy || consumingId !== '',
713
829
  onClick: () => void consume(credit),
714
830
  },
715
- consumingId === credit.id ? STR.rcUsing : STR.rcUse,
831
+ consumingId === credit.id ? t('rcUsing') : t('rcUse'),
716
832
  ),
717
833
  ),
718
834
  ),
719
835
  ),
720
- note ? React.createElement('div', { className: 'dshGptSubNote' }, note) : null,
836
+ note ? React.createElement('div', { className: 'dshGptSubNote' }, noteText(note, t)) : null,
721
837
  )
722
838
  }
723
839
 
@@ -725,7 +841,7 @@ window.__ModuleLoader__.load({
725
841
  * The Settings section body.
726
842
  * @returns the rendered panel.
727
843
  */
728
- const QuotaSection = () => {
844
+ const QuotaSection = ({ t = fallbackT } = {}) => {
729
845
  ensureStyle()
730
846
  const [reading, setReading] = useState(state)
731
847
  const [stat, setStat] = useState(status)
@@ -743,17 +859,17 @@ window.__ModuleLoader__.load({
743
859
  }, [])
744
860
 
745
861
  if (reading.phase === 'loading') {
746
- return React.createElement('div', { className: 'dshGptSub' }, '读取中…')
862
+ return React.createElement('div', { className: 'dshGptSub' }, t('loading'))
747
863
  }
748
864
  if (reading.phase === 'error') {
749
865
  return React.createElement(
750
866
  'div',
751
867
  { className: 'dshGptSub' },
752
- React.createElement('div', { className: 'dshGptSubNote' }, '无法读取配额:' + (reading.message || '未知错误')),
868
+ React.createElement('div', { className: 'dshGptSubNote' }, t('quotaError', { message: errorText(reading.message, t) })),
753
869
  React.createElement(
754
870
  'div',
755
871
  { className: 'dshGptSubBtn' },
756
- React.createElement(SmallButton, { variant: 'outline', onClick: () => poll(true) }, '重试'),
872
+ React.createElement(SmallButton, { variant: 'outline', onClick: () => poll(true) }, t('retry')),
757
873
  ),
758
874
  )
759
875
  }
@@ -763,7 +879,8 @@ window.__ModuleLoader__.load({
763
879
  * @param hours - the window's length in hours.
764
880
  * @returns the label.
765
881
  */
766
- const windowLabel = (hours) => (hours >= 24 ? Math.round(hours / 24) + ' 天窗口' : hours + ' 小时窗口')
882
+ const windowLabel = (hours) => t(hours >= 24 ? 'windowDays' : 'windowHours',
883
+ { n: hours >= 24 ? Math.round(hours / 24) : hours })
767
884
 
768
885
  /**
769
886
  * One window's block: label, a track of the percent REMAINING, and the
@@ -791,19 +908,19 @@ window.__ModuleLoader__.load({
791
908
  React.createElement(
792
909
  'div',
793
910
  { className: 'dshGptSubMeta' },
794
- React.createElement('span', null, '剩余 ' + remaining + '%'),
795
- React.createElement('span', null, countdown(window.resetAt, now)),
911
+ React.createElement('span', null, t('remaining', { percent: remaining })),
912
+ React.createElement('span', null, countdown(window.resetAt, now, t)),
796
913
  ),
797
914
  )
798
915
  }
799
916
 
800
917
  const refreshLabel = stat
801
918
  ? stat.refreshAt
802
- ? countdownFrom(stat.refreshAt, now) + '(' + STR.marginNote(stat.refreshMarginMinutes, stat.syncIntervalMinutes) + ')'
803
- : STR.unknown
919
+ ? countdownFrom(stat.refreshAt, now, t, 'refresh') + ' · ' + t('marginNote', { margin: stat.refreshMarginMinutes, sync: stat.syncIntervalMinutes })
920
+ : t('unknown')
804
921
  : ''
805
922
  const authLabel = stat ? stat.authFile || '' : ''
806
- const tokenExpiryLabel = stat && stat.tokenExpiresAt ? withNoun(countdownFrom(stat.tokenExpiresAt, now), '过期') : ''
923
+ const tokenExpiryLabel = stat && stat.tokenExpiresAt ? countdownFrom(stat.tokenExpiresAt, now, t, 'expire') : ''
807
924
 
808
925
  return React.createElement(
809
926
  'div',
@@ -815,12 +932,12 @@ window.__ModuleLoader__.load({
815
932
  // The on-demand usage resets the account can still spend to clear
816
933
  // a capped window without waiting out its timer.
817
934
  reading.resetsRemaining !== undefined
818
- ? React.createElement(Badge, null, STR.resetsLeft(reading.resetsRemaining))
935
+ ? React.createElement(Badge, null, t('resetsLeft', { count: reading.resetsRemaining }))
819
936
  : null,
820
937
  React.createElement(
821
938
  'span',
822
939
  { className: 'dshGptSubBtn' },
823
- React.createElement(SmallButton, { variant: 'ghost', onClick: () => poll(true) }, '刷新'),
940
+ React.createElement(SmallButton, { variant: 'ghost', onClick: () => poll(true) }, t('refresh')),
824
941
  ),
825
942
  ),
826
943
  // The 5-hour rolling window first -- it is the one that bites first --
@@ -829,42 +946,111 @@ window.__ModuleLoader__.load({
829
946
  reading.fiveHour !== undefined
830
947
  ? WindowBlock(windowLabel(reading.fiveHour.windowHours), reading.fiveHour)
831
948
  : reading.weekly !== undefined
832
- ? React.createElement('div', { className: 'dshGptSubNote' }, STR.noFiveHour)
949
+ ? React.createElement('div', { className: 'dshGptSubNote' }, t('noFiveHour'))
833
950
  : null,
834
951
  reading.weekly !== undefined ? WindowBlock(windowLabel(reading.weekly.windowHours), reading.weekly) : null,
835
952
  reading.fiveHour === undefined && reading.weekly === undefined
836
- ? React.createElement('div', { className: 'dshGptSubNote' }, STR.noWindows)
953
+ ? React.createElement('div', { className: 'dshGptSubNote' }, t('noWindows'))
837
954
  : null,
838
955
  reading.stale
839
- ? React.createElement('div', { className: 'dshGptSubNote' }, '数据可能已过期:' + (reading.message || ''))
956
+ ? React.createElement('div', { className: 'dshGptSubNote' }, t('stale', { message: reading.message ? errorText(reading.message, t) : '' }))
840
957
  : null,
841
958
  React.createElement(
842
959
  'div',
843
960
  { className: 'dshGptSubDiag' },
844
- React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.authSource),
961
+ React.createElement('span', { className: 'dshGptSubDiagLabel' }, t('authSource')),
845
962
  React.createElement(
846
963
  'span',
847
964
  { className: 'dshGptSubDiagValue' },
848
- authLabel + (tokenExpiryLabel ? ' · Access Token ' + tokenExpiryLabel : ''),
965
+ authLabel + (tokenExpiryLabel ? ' · ' + t('accessToken') + ' ' + tokenExpiryLabel : ''),
849
966
  ),
850
- React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.authFileLabel),
967
+ React.createElement('span', { className: 'dshGptSubDiagLabel' }, t('authFileLabel')),
851
968
  React.createElement(
852
969
  'span',
853
970
  { className: 'dshGptSubDiagValue' },
854
- React.createElement(AuthControl, null),
971
+ React.createElement(AuthControl, { t }),
855
972
  ),
856
- React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.nextRefresh),
973
+ React.createElement('span', { className: 'dshGptSubDiagLabel' }, t('nextRefresh')),
857
974
  React.createElement('span', { className: 'dshGptSubDiagValue' }, refreshLabel),
858
- React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.proxy),
859
- React.createElement('span', { className: 'dshGptSubDiagValue' }, React.createElement(ProxyControl, null)),
860
- React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.resetCredits),
861
- React.createElement('span', { className: 'dshGptSubDiagValue' }, React.createElement(ResetCreditsControl, null)),
975
+ React.createElement('span', { className: 'dshGptSubDiagLabel' }, t('proxy')),
976
+ React.createElement('span', { className: 'dshGptSubDiagValue' }, React.createElement(ProxyControl, { t })),
977
+ React.createElement('span', { className: 'dshGptSubDiagLabel' }, t('resetCredits')),
978
+ React.createElement('span', { className: 'dshGptSubDiagValue' }, React.createElement(ResetCreditsControl, { t })),
862
979
  ),
863
980
  )
864
981
  }
865
982
 
866
- /** Required services: the slot registry the panel registers into. */
867
- exports.inject = ['slots']
983
+ /* Codex mark from @lobehub/icons-static-svg 1.95.1 (MIT):
984
+ * https://unpkg.com/@lobehub/icons-static-svg@1.95.1/icons/codex.svg
985
+ * Copyright (c) 2023 LobeHub
986
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
987
+ * of this software and associated documentation files (the "Software"), to deal
988
+ * in the Software without restriction, including without limitation the rights
989
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
990
+ * copies of the Software, and to permit persons to whom the Software is
991
+ * furnished to do so, subject to the following conditions:
992
+ * The above copyright notice and this permission notice shall be included in
993
+ * all copies or substantial portions of the Software.
994
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
995
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
996
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
997
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
998
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
999
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1000
+ * THE SOFTWARE.
1001
+ */
1002
+ const CODEX_MARK = 'M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z'
1003
+
1004
+ // The settings shell currently hard-codes its nav icons and ignores a
1005
+ // slot's icon prop. Like dsh-mcp-adapter, replace only our row's SVG, and
1006
+ // restore it on unload. Unknown shell markup is deliberately a no-op.
1007
+ const paintNavIcon = (label) => {
1008
+ if (typeof document === 'undefined') return () => {}
1009
+ const painted = new Map()
1010
+ const marker = 'data-gpt-sub-nav-icon'
1011
+ const paint = () => {
1012
+ for (const cell of painted.keys()) {
1013
+ if (cell.isConnected === false) painted.delete(cell)
1014
+ }
1015
+ const want = label().trim()
1016
+ if (!want) return
1017
+ for (const cell of document.querySelectorAll('[role="dialog"] nav button')) {
1018
+ if ((cell.textContent || '').trim() !== want) continue
1019
+ const gear = cell.querySelector('svg')
1020
+ if (!gear || gear.getAttribute(marker) !== null) continue
1021
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1022
+ const className = gear.getAttribute('class')
1023
+ if (className) svg.setAttribute('class', className)
1024
+ svg.setAttribute('viewBox', '0 0 24 24')
1025
+ svg.setAttribute('width', '16')
1026
+ svg.setAttribute('height', '16')
1027
+ svg.setAttribute('fill', 'currentColor')
1028
+ svg.setAttribute('fill-rule', 'evenodd')
1029
+ svg.setAttribute('aria-hidden', 'true')
1030
+ svg.setAttribute('focusable', 'false')
1031
+ svg.setAttribute(marker, '1')
1032
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1033
+ path.setAttribute('d', CODEX_MARK)
1034
+ path.setAttribute('clip-rule', 'evenodd')
1035
+ svg.appendChild(path)
1036
+ // If the shell kept the row but redrew its gear, the newest original
1037
+ // is the one to restore on disposal.
1038
+ painted.set(cell, { gear, svg })
1039
+ gear.replaceWith(svg)
1040
+ }
1041
+ }
1042
+ paint()
1043
+ const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(paint)
1044
+ observer?.observe(document.body, { childList: true, subtree: true, characterData: true })
1045
+ return () => {
1046
+ observer?.disconnect()
1047
+ for (const { gear, svg } of painted.values()) svg.replaceWith(gear)
1048
+ painted.clear()
1049
+ }
1050
+ }
1051
+
1052
+ /** Required services: slot registration and the host locale runtime. */
1053
+ exports.inject = ['slots', 'locale']
868
1054
 
869
1055
  /**
870
1056
  * Client plugin body: poll while the page is visible, and register the
@@ -872,6 +1058,10 @@ window.__ModuleLoader__.load({
872
1058
  * @param ctx - client root context.
873
1059
  */
874
1060
  exports.apply = function apply(ctx) {
1061
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'gpt-sub: dictionaries')
1062
+ const t = ctx.locale.bind(NS)
1063
+ ctx.effect(() => paintNavIcon(() => t('nav')), 'gpt-sub: nav icon')
1064
+
875
1065
  ctx.effect(() => {
876
1066
  void poll(false)
877
1067
  void pollStatus()
@@ -898,8 +1088,9 @@ window.__ModuleLoader__.load({
898
1088
  name: 'settings.section',
899
1089
  id: 'gpt-sub-quota',
900
1090
  order: 20,
901
- label: () => 'Codex 配额',
902
- inject: () => ({}),
1091
+ label: () => t('nav'),
1092
+ locale: NS,
1093
+ inject: () => ({ t }),
903
1094
  }, QuotaSection))
904
1095
  }
905
1096
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-gpt-sub",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -102,7 +102,10 @@
102
102
  "patch": "./cordis.patch.yml"
103
103
  },
104
104
  "client": {
105
- "inject": [],
105
+ "inject": [
106
+ "@deepseek-ai/dsh-client-locale",
107
+ "@deepseek-ai/dsh-client-ui-settings"
108
+ ],
106
109
  "platform": "web"
107
110
  }
108
111
  }