dsh-connect-trae 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.0 (2026-10-12)
4
+
5
+ ### 新增
6
+
7
+ - **积分即将过期时给出提醒**(贡献者 [JeffTsuiCUG](https://github.com/JeffTsuiCUG),PR [#32](https://github.com/dingminhua/dsh-connect-trae/pull/32))。剩余额度只回答「还有多少」,回答不了「**哪一笔先到期**」,而后者才是「该先用哪个」的依据。此前一个 1 小时后过期的包和一个下个月才过期的包在界面上看起来完全一样,只盯总额的用户会毫无察觉地丢掉最先到期的那个。
8
+ - **数据在上一版(3.3.0,PR #31)已就绪**:`credits.accounts[].expiresAtMs` 已逐包携带到期时刻;本版在其之上加提醒 UI,**不新增任何上游读、卡片也不因此多一次请求**。原始起因见 [issue #26](https://github.com/dingminhua/dsh-connect-trae/issues/26)(DSH 主界面侧边栏显示积分状态的长期目标;`credits.expiry` 这份数据将来做 #26 可直接复用,同一个计费请求的结果)。
9
+ - **`toCredits` 按到期时刻把有余额的积分包分桶汇总为 `credits.expiry`**:`within24h` / `within3d` / `within7d` / `expired` 四桶,每桶带金额合计、套餐数量与最早到期时间。
10
+ - 只统计**正余额**的包:已用尽的包即使今天到期也不计入,否则长命账号会看起来永远「迫在眉睫」。
11
+ - 已过期的包**单独计桶**,不进入前瞻窗口——剩余毫秒数为负时会满足所有前瞻条件(`end - now < 24h`),混在一起会让一个早就死掉的包看起来永远临近。
12
+ - 抽出 `usableExpiry()` 谓词(`> 0` 且有限),与每包投影共用同一判断,两者不可能对同一批数据得出矛盾结论。`0` 与 `NaN`/`Infinity` 均被排除。
13
+ - **`TraeWebCreditExpiry` / `TraeWebExpiringCredits` 仅当有包携带可用到期时间时才存在**——「没拿到数据」不得渲染成「没有要过期的」,这与 #31 里 `expiresAtMs` 缺省即省略的设计一致。
14
+ - **两处 UI 不再一视同仁**:使用量标签页新增到期提示条,按 **7 天 → 24 小时 → 已过期**逐级加重,写明金额、套餐数和最早到期时间;输入框下面的积分面板(真正常驻看额度的地方)同样渲染提示条。仅国内版:国际版是订阅制(`payStatus`),没有逐包到期数据。
15
+ - 中英双语文案(`row.expiryWarningSoon` / `row.expiryWarningWeek` / `row.expiryWarningLapsed` / `row.expiryEarliest`)。
16
+ - 新增测试覆盖分桶边界、正余额排除、已过期/未到期分离、最早到期选取、非有限值与 `0` 的处理,以及「无数据」与「没有要过期的」这两个必须区分的状态。
17
+
18
+ ### 测试
19
+
20
+ - `tests/composer-styles.spec.tsx` 的 fixture 补上带 `expiry` 的数据,让输入框面板的到期提示条真正被渲染并接受「每个渲染出的 class 在样式表里有规则」的守卫检查;并做了负向验证——临时删掉一条样式规则,测试精确报出缺失的 class(`dsm-trae-expiry-detail`),确认守卫是真的在覆盖而不是假通过。
21
+ - `tests/web-status.spec.ts` 新增 `expiry summary` 一组,钉住分桶与两种状态的区分。
22
+
3
23
  ## 3.3.0 (2026-10-11)
4
24
 
5
25
  ### 新增
package/README.en.md CHANGED
@@ -42,6 +42,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
42
42
  - **Vision follows Trae's own declaration** — when Trae marks a model `multimodal`, the plugin advertises it as such with no manual tick per model (issue #16). The rule is "**models you tick ∪ models Trae marks multimodal**": models Trae marks `false`, or does not mark at all, stay text-only and can still be turned on by hand. A row enabled automatically is shown ticked and read-only, with the reason. See "Image input" below.
43
43
  - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings. **Your choice is remembered and stays in effect**: the plugin will never switch accounts on its own just because the bound one ran out of credit or another usable one was found (switching bills a different person). So when the bound account is exhausted the card **says so explicitly** and lists the other accounts on this machine that still have credit, with their balances, for you to switch to in one click — instead of showing an empty panel or quietly rebinding you. "Refresh accounts & tokens" now always reports what happened (how many accounts were found, who is still bound) instead of appearing to do nothing. The dropdown names the product (`Trae` / `TRAE SOLO` / international) so a credential left behind by an uninstalled app is recognisable.
44
44
  - **Read-only usage and model management** — Work/general credits on CN accounts, subscription/trial status on international ones; enable Trae models freely. Read-only queries consume nothing.
45
+ - **Credit-near-expiry notices** — the remaining balance only says "how much is left", not "**which pack lapses first**". The notice escalates by urgency (7 days → 24 hours → already lapsed) and names the amount, the pack count and the soonest expiry, so a user watching only the total never silently loses the pack that expires first. Shown in both the usage card and the composer credits panel on the CN side (the international side is subscription-based and has no per-pack expiry).
45
46
  - **One-click test of the enabled models** — sends each **enabled** model one minimal message (a single `ping`, no tools) and reports whether it actually answers: **works** / **needs a higher plan** (Trae `1005`; your plan does not cover it) / **not served** (`4001`/`4011`; no SOLO function serves it) / **throttled** / **sign-in rejected** / **not determined**. The last one is a first-class outcome: a timeout, a dropped connection, a proxy or a 5xx is **never** reported as unusable — only an explicit upstream refusal is a negative verdict. This is the plugin's **only** operation that spends quota (check-in changes account state but spends nothing), it runs only when you click, and its results live in the current session rather than in your settings.
46
47
  - **One-click test of all models** — the same probe applied to **every visible model** of the current region, not just the enabled ones. Use it when you want to know whether the models you have switched off could still answer — after a plan change, or after Trae reshuffles its lineup.
47
48
  - **Daily check-in claiming (per account)** — on the CN card **every account row carries its own check-in button**, with the day's reward written on it (`Check in +150`). A row reads **Claimed** and disables itself once *that account* has been paid, and reads **Device used** and disables itself when *this machine* has already spent today's slot through some other account — the two are reported honestly, and the second is never dressed up as "claimed". This is the plugin's **only** operation that changes account state, and it runs only when you click; one click targets that one row's account and sends exactly one request. There is deliberately **no "claim for every account"**: Trae allows **one check-in per device per day** (switching accounts does not reset the slot), so a sweep could only manufacture a row of false failures. The international side has no check-in activity, so no button is shown there.
package/README.md CHANGED
@@ -43,6 +43,7 @@
43
43
  - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。**你的选择会被记住并长期生效**:插件不会因为某个账号额度用尽、或检测到另一个可用账号就自作主张替你切换(换账号意味着换人计费)。所以当绑定的账号额度耗尽时,卡片会**明确告诉你**,并把本机其他还有额度的账号连同其可用积分数列出来,由你一键切换——而不是让面板一直空着、也不自动把你切走。「刷新账号与 Token」每次都会回报结果(找到几个账号、当前仍绑定谁),不会再「点了没反应」。下拉里显示的是产品名(`Trae` / `TRAE SOLO` / 国际版),便于分辨凭据来自哪个安装。
44
44
  - **跟随 Trae 当前登录的账号** —— 你在 Trae 桌面端**退出并换登录**后,卡片会检测到「插件绑定的账号」与「Trae 现在登录的账号」不一致,并给出一行提示与**一键切换**(复用账号下拉的同一次写入,不引入第二条切换路径)。想让插件**自动跟随**,可打开设置卡片里的「跟随 Trae 当前登录的账号」开关:此后每次配置生效都会把 `accounts.<region>` 重绑到 App 当前登录的账号。**该开关默认关闭**——已有配置保持原来的严格绑定,自动换绑意味着换人计费,必须由你主动勾选;未勾选时上面那行提示就是你的手动通道。(issue #29)
45
45
  - **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
46
+ - **积分临近到期会提醒** —— 剩余额度只回答「还有多少」,回答不了「**哪一笔先到期**」;提醒条按到期紧迫程度逐级加重(**7 天 → 24 小时 → 已过期**),写明金额、套餐数和最早到期时间,避免只盯总额时悄悄丢掉最先到期的额度。国内版在用量页与输入框积分面板两处都显示(国际版是订阅制,没有逐包到期数据)。
46
47
  - **一键测试启用模型** —— 给**已启用**的每个模型发一条最小消息(一句 `ping`、不带工具),逐个告诉你能不能真的应答:**可用** / **需要更高档位**(Trae `1005`,你的套餐不覆盖)/ **上游不提供**(`4001`/`4011`,SOLO 通道没有这个模型)/ **被限流** / **登录凭据被拒** / **本次未测出**。最后一项是一等公民:超时、断连、代理、5xx **一律不算「不可用」**——只有上游明确的拒绝才算负面结论。**这是插件里唯一消耗额度的操作**(签到改变的是账号状态,不消耗),只在你点击时运行,结果只留在当前会话里不写入设置。
47
48
  - **一键测试所有模型** —— 同一套探测,作用于当前 region 的**全部可见模型**(不限于已启用的)。想知道「没启用的那些到底还能不能用」时用它——比如套餐降级后,或 Trae 调整了模型阵容之后。
48
49
  - **每日签到领取(逐账号)** —— 国内版**每个账号行各有一个签到按钮**,当日奖励直接写在按钮上(`签到 +150`);该账号当天已领显示「已领取」并禁用,**本机今日名额已被别的账号用掉**时显示「本机已签」并禁用——如实区分「这个账号领了」和「这台设备领过了」,绝不把后者说成「已领取」。这是插件里**唯一会改变账号状态**的操作,由你主动点击触发;一次点击只针对该行账号、只发一次请求,**刻意没有「一键全签」**——Trae 的签到按「**每台设备每天一次**」计(换账号不会重置当天名额),批量只会酿成一排假失败。国际版没有签到活动,因此不显示该按钮。
package/lib/client.js CHANGED
@@ -243,7 +243,7 @@ window.__ModuleLoader__.load({
243
243
  .dsm-plugin-card-title{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-weight:600;line-height:1.4}
244
244
  .dsm-plugin-card-description{color:var(--dsw-alias-label-tertiary,#999);font-size:13px;line-height:1.5}
245
245
  /* Pure-CSS caret: the host primitives' chevron icon names are not part of any
246
- stable contract across DSH releases, so no static import is used — a border
246
+ stable contract across DSH releases, so no static import is used 鈥?a border
247
247
  caret in the plugin's own CSS is version-proof. */
248
248
  .dsm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary,#999);flex:none;width:16px;height:16px;position:relative;transition:transform .16s}
249
249
  .dsm-plugin-card-chevron::before{content:"";display:block;position:absolute;left:4px;top:5px;width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:rotate(45deg)}
@@ -280,15 +280,15 @@ window.__ModuleLoader__.load({
280
280
  .dsm-trae-usage-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
281
281
  .dsm-trae-usage-expiry{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
282
282
  .dsm-trae-account-picker{display:block}
283
- /* The 设置 block: label + hint on the left, control on the right, one save for
283
+ /* The 璁剧疆 block: label + hint on the left, control on the right, one save for
284
284
  the whole block. It replaces the bare checkbox rows that used to write on
285
- every click — a draft needs a visible way back out, so each row carries its
285
+ every click 鈥?a draft needs a visible way back out, so each row carries its
286
286
  own explanation and the footer carries the buttons. */
287
287
  /* The preference switches: BARE LINES, one per switch, each with its label
288
288
  wrapped around the box so the whole line is a click target.
289
289
  *
290
- * The 设置 block used to be a bordered box with a heading, two rows and a
291
- * 「保存」/「放弃」 footer. That is gone with the draft it existed to serve — this
290
+ * The 璁剧疆 block used to be a bordered box with a heading, two rows and a
291
+ * 銆屼繚瀛樸€?銆屾斁寮冦€?footer. That is gone with the draft it existed to serve 鈥?this
292
292
  * is the sibling dsh-connect-workbuddy card's shape, where a preference is a
293
293
  * checkbox on a line that commits on the click. */
294
294
  .dsm-trae-prefs{display:flex;flex-direction:column;gap:8px}
@@ -300,14 +300,14 @@ window.__ModuleLoader__.load({
300
300
  .dsm-trae-pref-hint{color:var(--dsw-alias-label-tertiary,#9aa0a8)}
301
301
  .dsm-trae-pref span{min-width:0}
302
302
  /* The card's checkboxes are the platform's own, styled only with
303
- accent-color — the SAME idiom .dsm-trae-mainui-switch-label above already
303
+ accent-color 鈥?the SAME idiom .dsm-trae-mainui-switch-label above already
304
304
  uses for the composer-line switch, and the one the sibling
305
305
  dsh-connect-workbuddy card uses for its tab and credits boxes.
306
306
  *
307
307
  * These were drawn by hand for one release: a 34x20 track with a 14px knob,
308
308
  * --dsw-alias-brand-primary when checked. That token is DSH's CONTRAST colour
309
309
  * (#0f1115 light / #f9fafb dark), not an accent, so a checked box came out as a
310
- * solid black pill — heavier than anything else in the card, and unrelated to
310
+ * solid black pill 鈥?heavier than anything else in the card, and unrelated to
311
311
  * the native box sitting two centimetres away in the composer toolbar. The
312
312
  * platform checkbox tracks the host's own accent and dark mode for free. */
313
313
  .dsm-trae-switch{display:inline-flex;align-items:center;cursor:pointer}
@@ -315,7 +315,7 @@ window.__ModuleLoader__.load({
315
315
  .dsm-trae-switch input:disabled{cursor:default}
316
316
  .dsm-trae-switch:has(input:disabled){opacity:.6;cursor:default}
317
317
  /* The account block is a real table: the question it answers is "which of these
318
- accounts, and what is the action on each one" — a dropdown can only ever show
318
+ accounts, and what is the action on each one" 鈥?a dropdown can only ever show
319
319
  the one already chosen. */
320
320
  .dsm-trae-accounts{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
321
321
  .dsm-trae-accounts-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
@@ -323,15 +323,15 @@ window.__ModuleLoader__.load({
323
323
  .dsm-trae-accounts-head>button{flex:none;white-space:nowrap}
324
324
  .dsm-trae-accounts-title{margin:0;font-size:13px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary,#e6e6e6)}
325
325
  .dsm-trae-accounts-hint{margin:3px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#9aa0a8)}
326
- /* The account rows: two lines each — who the account is, then what it is worth
327
- — with the one action that applies to THAT row on the right. A four-column
326
+ /* The account rows: two lines each 鈥?who the account is, then what it is worth
327
+ 鈥?with the one action that applies to THAT row on the right. A four-column
328
328
  table was the wrong instrument here: every field demanded its own header, the
329
329
  columns drifted with the content ("LaoDing" vs a full expiry stamp), and the
330
330
  bound row repeated its "in use" badge in two different columns. */
331
331
  /* The account table, shaped like the sibling WorkBuddy card's pool table: a
332
332
  bordered container, a tinted header row, and one grid row per account whose
333
333
  tracks are FRACTIONS of the container rather than sizes derived from the
334
- content. That is the whole point — the previous four-column table let each
334
+ content. That is the whole point 鈥?the previous four-column table let each
335
335
  column grow with its own text, so the columns did not line up from row to row
336
336
  and the table read as noise in a narrow panel. */
337
337
  .dsm-trae-account-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
@@ -375,7 +375,7 @@ window.__ModuleLoader__.load({
375
375
  .dsm-trae-account-checkin:hover:not(:disabled){background:rgba(86,134,254,.16)}
376
376
  .dsm-trae-account-checkin:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
377
377
  /* A disabled check-in still STATES something ("claimed", "device used"), so it
378
- stays readable — clearly inactive, not invisible. */
378
+ stays readable 鈥?clearly inactive, not invisible. */
379
379
  .dsm-trae-account-checkin:disabled{opacity:.55;cursor:default;border-color:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);background:transparent}
380
380
  /* The switch is the row's LIVE control, so it is brighter than the inert
381
381
  check-in labels beside it: same shape, different weight. */
@@ -422,7 +422,7 @@ window.__ModuleLoader__.load({
422
422
  the hint tooltip rather than looking like its read-only sibling. */
423
423
  /* The sidebar-credit switch. Two instances share this style: the card's own
424
424
  row at the top and the copy beside the model actions (2.9.3). Without it the
425
- checkbox rendered unstyled and read as absent — the whole reason the switch
425
+ checkbox rendered unstyled and read as absent 鈥?the whole reason the switch
426
426
  looked missing. */
427
427
  .dsm-trae-models-actions{display:flex;align-items:center;gap:8px;flex:none}
428
428
  .dsm-trae-usage-alternative{display:flex;align-items:flex-start;gap:10px;margin-top:10px;padding:10px 12px;border:1px solid var(--dsw-alias-state-warn-secondary,#fbbf24);border-radius:10px;background:var(--dsw-alias-state-warn-tertiary,#fef3c7)}
@@ -431,6 +431,14 @@ window.__ModuleLoader__.load({
431
431
  .dsm-trae-usage-switch-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}
432
432
  .dsm-trae-usage-switch-button:disabled{opacity:.5;cursor:not-allowed}
433
433
  .dsm-trae-usage-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
434
+ /* Credit-expiry notice, card copy. Three tones by urgency, so the 24-hour case
435
+ cannot be mistaken for the 7-day one at a glance. Reads as an advisory, not
436
+ an error: the account works fine, the credit is simply about to lapse. */
437
+ .dsm-trae-expiry{display:flex;flex-direction:column;gap:2px;margin-top:10px;padding:10px 12px;border:1px solid;border-radius:10px;font-size:12px;line-height:18px}
438
+ .dsm-trae-expiry-soon{border-color:var(--dsw-alias-state-warn-secondary,#fbbf24);background:var(--dsw-alias-state-warn-tertiary,#fef3c7);color:var(--dsw-alias-label-primary,#e6e6e6)}
439
+ .dsm-trae-expiry-week{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6)}
440
+ .dsm-trae-expiry-lapsed{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-tertiary,#999)}
441
+ .dsm-trae-expiry-detail{font-size:11px;line-height:16px;opacity:.8}
434
442
  .dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
435
443
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
436
444
  verdict stays neutral because an inconclusive probe is not evidence against
@@ -476,8 +484,8 @@ window.__ModuleLoader__.load({
476
484
  *
477
485
  * SEPARATE from `TRAE_CARD_CSS`, and that is not tidiness: the card's CSS is
478
486
  * only injected when the settings card module loads, so putting the composer's
479
- * rules in it means the readout renders UNSTYLED in the composer toolbar —
480
- * which is exactly what shipped in 2.13.0–2.13.4. The trigger fell back to the
487
+ * rules in it means the readout renders UNSTYLED in the composer toolbar 鈥?
488
+ * which is exactly what shipped in 2.13.0鈥?.13.4. The trigger fell back to the
481
489
  * browser's default button box, and the panel, without `position: fixed`, had
482
490
  * its inline left/top ignored and rendered nowhere useful.
483
491
  *
@@ -485,14 +493,14 @@ window.__ModuleLoader__.load({
485
493
  * renders has a rule here, so a rule cannot go missing without a test failing.
486
494
  */
487
495
  const COMPOSER_POINTS_CSS = `
488
- /* The readout is plain composer text — no border, no fill at rest. The padding
496
+ /* The readout is plain composer text 鈥?no border, no fill at rest. The padding
489
497
  is only the click target, invisible until the pointer arrives. */
490
498
  .dsm-trae-composer-points{display:inline-flex;align-items:center;min-width:0}
491
499
  .dsm-trae-composer-points-trigger{
492
500
  appearance:none;display:inline-flex;align-items:center;align-self:stretch;
493
501
  padding:2px 6px;border:0;border-radius:6px;background:transparent;
494
502
  /* EXPLICIT, not inherit. The composer row's own font and body colour are
495
- noticeably larger and darker than the neighbouring controls — a credit
503
+ noticeably larger and darker than the neighbouring controls 鈥?a credit
496
504
  readout is secondary information and must not shout. 12px matches this
497
505
  plugin's original line; label-tertiary matches the shell's own composer-row
498
506
  control (ContextMeter), which is the sibling this reads alongside. */
@@ -504,7 +512,7 @@ window.__ModuleLoader__.load({
504
512
  .dsm-trae-composer-points-trigger:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}
505
513
  .dsm-trae-composer-points-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
506
514
  /* The panel is portaled to document.body and positioned from the trigger, so it
507
- MUST be fixed — otherwise the left/top it measures are ignored and it lands in
515
+ MUST be fixed 鈥?otherwise the left/top it measures are ignored and it lands in
508
516
  document flow. All material comes from shell tokens so it follows the theme. */
509
517
  .dsm-trae-composer-panel{
510
518
  position:fixed;z-index:1100;box-sizing:border-box;
@@ -558,7 +566,7 @@ window.__ModuleLoader__.load({
558
566
  color:var(--dsw-alias-label-primary,CanvasText);
559
567
  }
560
568
  /* The current account reads as current without shouting: a filled dot and a
561
- slightly heavier weight. Disabled — pressing it would write a selection that
569
+ slightly heavier weight. Disabled 鈥?pressing it would write a selection that
562
570
  has not changed. */
563
571
  .dsm-trae-composer-panel-table tbody tr.dsm-trae-composer-panel-row-current td{font-weight:600}
564
572
  .dsm-trae-composer-panel-switch{
@@ -593,6 +601,30 @@ window.__ModuleLoader__.load({
593
601
  }
594
602
  .dsm-trae-composer-panel-empty{margin:0;color:var(--dsw-alias-label-tertiary,GrayText)}
595
603
  .dsm-trae-composer-panel-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d92d20);font-size:11px;line-height:16px}
604
+ /* Credit-expiry notice. Three tones by urgency, so the 24-hour case cannot be
605
+ mistaken for the 7-day one at a glance. Reads as an advisory, not an error:
606
+ the account works fine, the credit is simply about to lapse. */
607
+ .dsm-trae-expiry{
608
+ display:flex;flex-direction:column;gap:2px;
609
+ margin-top:9px;padding:8px 10px;border:1px solid;border-radius:8px;
610
+ font-size:11px;line-height:16px;
611
+ }
612
+ .dsm-trae-expiry-soon{
613
+ border-color:var(--dsw-alias-state-warn-secondary,#fbbf24);
614
+ background:var(--dsw-alias-state-warn-tertiary,#fef3c7);
615
+ color:var(--dsw-alias-label-primary,#1f2329);
616
+ }
617
+ .dsm-trae-expiry-week{
618
+ border-color:var(--dsw-alias-border-l2,rgba(127,127,127,.36));
619
+ background:transparent;
620
+ color:var(--dsw-alias-label-secondary,GrayText);
621
+ }
622
+ .dsm-trae-expiry-lapsed{
623
+ border-color:var(--dsw-alias-border-l2,rgba(127,127,127,.36));
624
+ background:transparent;
625
+ color:var(--dsw-alias-label-tertiary,GrayText);
626
+ }
627
+ .dsm-trae-expiry-detail{font-size:11px;line-height:16px;opacity:.85}
596
628
  `;
597
629
  //#endregion
598
630
  //#region src/client/plan-text.ts
@@ -737,6 +769,53 @@ window.__ModuleLoader__.load({
737
769
  function dotStyle(status) {
738
770
  return { background: status === "signed-in" ? "var(--dsw-alias-state-success-primary, #22a06b)" : status === "error" ? "var(--dsw-alias-state-error-primary, #d92d20)" : "var(--dsw-alias-label-dimmed, #9aa0a6)" };
739
771
  }
772
+ /**
773
+ * A warning that credit is about to lapse.
774
+ *
775
+ * The balance alone cannot express this: a pack lapsing in an hour and one
776
+ * lapsing next month both render as "150", so a user watching only the total
777
+ * loses the first one silently. The notice names the amount, the pack count and
778
+ * the soonest expiry, and escalates 7d → 24h → already-lapsed.
779
+ *
780
+ * Renders nothing when the upstream reported no end times at all — absence of
781
+ * expiry data is not evidence that nothing lapses, so the component stays
782
+ * silent rather than reassuring.
783
+ */
784
+ function ExpiryNotice({ expiry, t, formatNumber: fmt, formatDateTime: fmtTime }) {
785
+ const { expired, within24h, within7d } = expiry;
786
+ let message;
787
+ let soonest;
788
+ let tone;
789
+ if (within24h.packs > 0) {
790
+ message = t("row.expiryWarningSoon", {
791
+ total: fmt(within24h.total),
792
+ packs: String(within24h.packs)
793
+ });
794
+ soonest = within24h.earliestExpiresAtMs;
795
+ tone = "dsm-trae-expiry-soon";
796
+ } else if (within7d.packs > 0) {
797
+ message = t("row.expiryWarningWeek", {
798
+ total: fmt(within7d.total),
799
+ packs: String(within7d.packs)
800
+ });
801
+ soonest = within7d.earliestExpiresAtMs;
802
+ tone = "dsm-trae-expiry-week";
803
+ } else if (expired.packs > 0) {
804
+ message = t("row.expiryWarningLapsed", {
805
+ total: fmt(expired.total),
806
+ packs: String(expired.packs)
807
+ });
808
+ tone = "dsm-trae-expiry-lapsed";
809
+ } else return null;
810
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
811
+ className: `dsm-trae-expiry ${tone}`,
812
+ role: "status",
813
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: message }), soonest === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
814
+ className: "dsm-trae-expiry-detail",
815
+ children: t("row.expiryEarliest", { time: fmtTime(soonest) })
816
+ })]
817
+ });
818
+ }
740
819
  /** Render Trae sign-in state and the total usage summary as one expandable card. */
741
820
  function TraeUsageCard({ t, settingsScope, view }) {
742
821
  if (t === void 0) throw new Error("Trae usage card requires its translation function");
@@ -1905,6 +1984,12 @@ window.__ModuleLoader__.load({
1905
1984
  children: t("row.creditsAlternativeSwitch")
1906
1985
  })]
1907
1986
  }) : null,
1987
+ status.status === "signed-in" && status.credits?.expiry !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExpiryNotice, {
1988
+ expiry: status.credits.expiry,
1989
+ t,
1990
+ formatNumber,
1991
+ formatDateTime
1992
+ }) : null,
1908
1993
  status.status === "signed-in" && status.appLogin !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1909
1994
  className: "dsm-trae-usage-alternative",
1910
1995
  role: "status",
@@ -2365,6 +2450,36 @@ window.__ModuleLoader__.load({
2365
2450
  function formatCredits(value) {
2366
2451
  return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
2367
2452
  }
2453
+ /** The soonest expiry any bucket names, or undefined when none carries one. */
2454
+ function soonestExpiryOf(expiry) {
2455
+ return expiry.within24h.earliestExpiresAtMs ?? expiry.within7d.earliestExpiresAtMs ?? void 0;
2456
+ }
2457
+ /**
2458
+ * Which urgency tone the notice takes.
2459
+ *
2460
+ * Escalation order matters: a live expiry the user can still act on outranks a
2461
+ * lapsed one they cannot, even when the lapsed amount is larger.
2462
+ */
2463
+ function expiryToneOf(expiry) {
2464
+ if (expiry.within24h.packs > 0) return "dsm-trae-expiry-soon";
2465
+ if (expiry.within7d.packs > 0) return "dsm-trae-expiry-week";
2466
+ return "dsm-trae-expiry-lapsed";
2467
+ }
2468
+ /** The notice's headline sentence, naming the amount and the pack count. */
2469
+ function expiryMessageOf(expiry, t) {
2470
+ if (expiry.within24h.packs > 0) return t("row.expiryWarningSoon", {
2471
+ total: formatCredits(expiry.within24h.total),
2472
+ packs: String(expiry.within24h.packs)
2473
+ });
2474
+ if (expiry.within7d.packs > 0) return t("row.expiryWarningWeek", {
2475
+ total: formatCredits(expiry.within7d.total),
2476
+ packs: String(expiry.within7d.packs)
2477
+ });
2478
+ return t("row.expiryWarningLapsed", {
2479
+ total: formatCredits(expiry.expired.total),
2480
+ packs: String(expiry.expired.packs)
2481
+ });
2482
+ }
2368
2483
  function formatClock(value) {
2369
2484
  return new Date(value).toLocaleTimeString("zh-CN", {
2370
2485
  hour: "2-digit",
@@ -2690,6 +2805,14 @@ window.__ModuleLoader__.load({
2690
2805
  }, row.id);
2691
2806
  }) })]
2692
2807
  }),
2808
+ activeRegion === "cn" && signedIn?.credits?.expiry !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2809
+ className: `dsm-trae-expiry ${expiryToneOf(signedIn.credits.expiry)}`,
2810
+ role: "status",
2811
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: expiryMessageOf(signedIn.credits.expiry, t) }), soonestExpiryOf(signedIn.credits.expiry) === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2812
+ className: "dsm-trae-expiry-detail",
2813
+ children: t("row.expiryEarliest", { time: formatClock(soonestExpiryOf(signedIn.credits.expiry)) })
2814
+ })]
2815
+ }) : null,
2693
2816
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2694
2817
  className: "dsm-trae-composer-panel-foot",
2695
2818
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
@@ -2884,6 +3007,10 @@ window.__ModuleLoader__.load({
2884
3007
  "row.creditsExhausted": "The bound account ({account}) has run out of credit",
2885
3008
  "row.creditsAlternative": "Another account on this machine still has credit: {accounts}",
2886
3009
  "row.creditsAlternativeSwitch": "Switch to it",
3010
+ "row.expiryWarningSoon": "{total} credit lapses within 24 hours ({packs} packs)",
3011
+ "row.expiryWarningWeek": "{total} credit lapses within 7 days ({packs} packs)",
3012
+ "row.expiryWarningLapsed": "{total} credit has already lapsed but is still counted in the balance ({packs} packs)",
3013
+ "row.expiryEarliest": "Earliest: {time}",
2887
3014
  "row.appLoginMismatch": "Trae is signed in to {account}, but this plugin is bound to {bound}.",
2888
3015
  "row.appLoginFollow": "Use {account}",
2889
3016
  "row.followAppLogin": "Follow the account Trae is signed in to",
@@ -3021,6 +3148,10 @@ window.__ModuleLoader__.load({
3021
3148
  "row.creditsExhausted": "当前绑定账号({account})额度已用尽",
3022
3149
  "row.creditsAlternative": "本机还有可用账号:{accounts}",
3023
3150
  "row.creditsAlternativeSwitch": "切换到它",
3151
+ "row.expiryWarningSoon": "有 {total} 积分将在 24 小时内过期({packs} 个套餐)",
3152
+ "row.expiryWarningWeek": "有 {total} 积分将在 7 天内过期({packs} 个套餐)",
3153
+ "row.expiryWarningLapsed": "有 {total} 积分已过期但仍计入余额({packs} 个套餐)",
3154
+ "row.expiryEarliest": "最早到期:{time}",
3024
3155
  "row.appLoginMismatch": "Trae 已登录 {account},但本插件绑定的是 {bound}。",
3025
3156
  "row.appLoginFollow": "改用 {account}",
3026
3157
  "row.followAppLogin": "跟随 Trae 当前登录的账号",
package/lib/index.d.ts CHANGED
@@ -1657,6 +1657,29 @@ interface TraeWebCreditAccount {
1657
1657
  */
1658
1658
  expiresAtMs?: number;
1659
1659
  }
1660
+ /** Credit lapsing inside one horizon, as the card reports it. */
1661
+ interface TraeWebExpiringCredits {
1662
+ /** Sum of the contributing packs' remaining credit. */
1663
+ total: number;
1664
+ /** How many packs contribute. */
1665
+ packs: number;
1666
+ /** Soonest expiry among them, epoch milliseconds. */
1667
+ earliestExpiresAtMs?: number;
1668
+ }
1669
+ /**
1670
+ * Credit at risk, bucketed by how soon it lapses.
1671
+ *
1672
+ * Only present when at least one pack carried a usable `expiresAtMs` — a card
1673
+ * must never read "we were not told when anything expires" as "nothing is
1674
+ * about to lapse".
1675
+ */
1676
+ interface TraeWebCreditExpiry {
1677
+ within24h: TraeWebExpiringCredits;
1678
+ within3d: TraeWebExpiringCredits;
1679
+ within7d: TraeWebExpiringCredits;
1680
+ /** Packs already past their end time but still carrying credit. */
1681
+ expired: TraeWebExpiringCredits;
1682
+ }
1660
1683
  /** Aggregated usage/credit answer rendered by the plugin card. */
1661
1684
  interface TraeWebCredits {
1662
1685
  total: number;
@@ -1665,6 +1688,13 @@ interface TraeWebCredits {
1665
1688
  workAvailable: number;
1666
1689
  generalAvailable: number;
1667
1690
  accounts: readonly TraeWebCreditAccount[];
1691
+ /**
1692
+ * Credit lapsing soon, for the card's warning banner.
1693
+ *
1694
+ * Derived from the per-pack {@link TraeWebCreditAccount.expiresAtMs} the
1695
+ * `/usage` route already carries, so it costs no extra upstream read.
1696
+ */
1697
+ expiry?: TraeWebCreditExpiry;
1668
1698
  }
1669
1699
  /** Editable Trae model row rendered by the plugin-owned settings card. */
1670
1700
  interface TraeWebModel {
package/lib/index.js CHANGED
@@ -4456,6 +4456,17 @@ function loopbackOrigin(req) {
4456
4456
  }
4457
4457
  }
4458
4458
  /**
4459
+ * Whether a pack's expiry is one the card can actually use.
4460
+ *
4461
+ * One predicate, shared by the per-pack projection and the bucket summary, so
4462
+ * the two can never disagree about which packs count as dated. `0` and the
4463
+ * non-finite values are excluded: a `0` here would read as 1970 and sort ahead
4464
+ * of every real date, and `NaN`/`Infinity` are not dates at all.
4465
+ */
4466
+ function usableExpiry(endTimeMs) {
4467
+ return typeof endTimeMs === "number" && Number.isFinite(endTimeMs) && endTimeMs > 0;
4468
+ }
4469
+ /**
4459
4470
  * Map the credit snapshot to the card's compact credit document.
4460
4471
  *
4461
4472
  * Exported so the "is another account usable?" probe (issue #25) derives its
@@ -4463,14 +4474,14 @@ function loopbackOrigin(req) {
4463
4474
  * — a second implementation of the Work/general split would be free to disagree
4464
4475
  * with the number shown next to it.
4465
4476
  */
4466
- function toCredits(snapshot) {
4477
+ function toCredits(snapshot, nowMs = Date.now()) {
4467
4478
  const { totalAmount, consumedAmount } = snapshot.summary;
4468
4479
  const credit = (value) => Math.round(value * 1e4) / 1e4;
4469
4480
  const accounts = snapshot.packs.map((pack) => ({
4470
4481
  displayDesc: pack.displayDesc,
4471
4482
  remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
4472
4483
  size: pack.creditsLimit ?? 0,
4473
- ...typeof pack.endTimeMs === "number" && Number.isFinite(pack.endTimeMs) && pack.endTimeMs > 0 ? { expiresAtMs: pack.endTimeMs } : {}
4484
+ ...usableExpiry(pack.endTimeMs) ? { expiresAtMs: pack.endTimeMs } : {}
4474
4485
  }));
4475
4486
  const remaining = (endpoint) => snapshot.packs.filter((pack) => pack.availableEndpoint === endpoint).reduce((sum, pack) => credit(sum + Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))), 0);
4476
4487
  return {
@@ -4479,9 +4490,74 @@ function toCredits(snapshot) {
4479
4490
  available: totalAmount - consumedAmount,
4480
4491
  workAvailable: remaining(1),
4481
4492
  generalAvailable: remaining(0),
4482
- accounts
4493
+ accounts,
4494
+ ...summarisePackExpiry(accounts, nowMs)
4495
+ };
4496
+ }
4497
+ /** Empty bucket, used whenever no pack qualifies. */
4498
+ const NO_EXPIRING_CREDIT = {
4499
+ total: 0,
4500
+ packs: 0
4501
+ };
4502
+ /**
4503
+ * Sum the credit lapsing inside `horizonMs` of `nowMs`.
4504
+ *
4505
+ * Only positive balances count: a lapsed pack with nothing left is not a loss,
4506
+ * and including it would make every long-lived account look permanently at
4507
+ * risk. Only *future* expiries count too — see {@link expiredPacks}.
4508
+ */
4509
+ function expiringWithin(packs, nowMs, horizonMs) {
4510
+ return sumExpiring(packs, (expiresAt) => expiresAt > nowMs && expiresAt - nowMs <= horizonMs);
4511
+ }
4512
+ /**
4513
+ * Sum the credit whose pack has already lapsed but still carries a balance.
4514
+ *
4515
+ * Kept apart from {@link expiringWithin} because a lapsed pack satisfies every
4516
+ * forward-looking horizon at once (`expiresAt - now` is negative, hence "within
4517
+ * 24 hours"), which would make a long-dead pack look permanently imminent.
4518
+ */
4519
+ function expiredPacks(packs, nowMs) {
4520
+ return sumExpiring(packs, (expiresAt) => expiresAt <= nowMs);
4521
+ }
4522
+ /** Shared accumulator over packs whose expiry passes `matches`. */
4523
+ function sumExpiring(packs, matches) {
4524
+ let total = 0;
4525
+ let count = 0;
4526
+ let earliestExpiresAtMs;
4527
+ for (const pack of packs) {
4528
+ const expiresAt = pack.expiresAtMs;
4529
+ if (!usableExpiry(expiresAt) || pack.remain <= 0) continue;
4530
+ if (!matches(expiresAt)) continue;
4531
+ total += pack.remain;
4532
+ count += 1;
4533
+ if (earliestExpiresAtMs === void 0 || expiresAt < earliestExpiresAtMs) earliestExpiresAtMs = expiresAt;
4534
+ }
4535
+ if (count === 0) return NO_EXPIRING_CREDIT;
4536
+ return {
4537
+ total,
4538
+ packs: count,
4539
+ ...earliestExpiresAtMs === void 0 ? {} : { earliestExpiresAtMs }
4483
4540
  };
4484
4541
  }
4542
+ /**
4543
+ * Bucket the card's credit by how soon it lapses.
4544
+ *
4545
+ * Returns no `expiry` at all when not one pack carried a usable end time, so a
4546
+ * caller can tell "nothing is expiring" apart from "we were never told when
4547
+ * anything expires" — the second must not render as reassurance. Uses the same
4548
+ * {@link usableExpiry} predicate as the per-pack projection, so a summary is
4549
+ * never produced from a set of dates the card itself would not carry.
4550
+ */
4551
+ function summarisePackExpiry(accounts, nowMs) {
4552
+ if (!accounts.some((pack) => usableExpiry(pack.expiresAtMs))) return {};
4553
+ const DAY = 864e5;
4554
+ return { expiry: {
4555
+ within24h: expiringWithin(accounts, nowMs, DAY),
4556
+ within3d: expiringWithin(accounts, nowMs, 3 * DAY),
4557
+ within7d: expiringWithin(accounts, nowMs, 7 * DAY),
4558
+ expired: expiredPacks(accounts, nowMs)
4559
+ } };
4560
+ }
4485
4561
  /** Map the check-in answer to the card's compact document. */
4486
4562
  function toCheckin(status) {
4487
4563
  return {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "3.3.0",
5
+ "version": "3.4.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",