balance-dsh 2.0.0 → 2.0.2

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 (3) hide show
  1. package/lib/client.js +425 -39
  2. package/lib/index.js +264 -4
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  .balance-footer__din{animation:balance-roll-in .42s cubic-bezier(.33,.66,.24,1) forwards}
46
46
  @keyframes balance-roll-out{0%{transform:translateY(0);opacity:1}60%{opacity:0}100%{transform:translateY(-105%);opacity:0}}
47
47
  @keyframes balance-roll-in{0%{transform:translateY(105%);opacity:0}40%{opacity:0}100%{transform:translateY(0);opacity:1}}
48
- @media (prefers-reduced-motion:reduce){.balance-footer__dout,.balance-footer__din{animation:none}.balance-footer__dout{display:none}.balance-footer__din{position:static}}
48
+ /* 逐位滚动不受 prefers-reduced-motion 影响:它是"这轮花掉多少"的即时反馈,是插件特色 */
49
49
  .balance-footer__wave{position:absolute;left:0;right:0;top:0;bottom:0;overflow:hidden;border-radius:var(--dsw-radius-lg,14px);corner-shape:round;pointer-events:none;z-index:2}
50
50
  /* 前沿白条:纯白,不带品牌色辉光(2026-10-30 反馈「只保留白色光效 不要颜色」)。
51
51
  只扫一层白光,**不覆盖扫过区域**——曾经有一个 ::after 暗罩把白条右侧整体压暗,
@@ -54,7 +54,7 @@ window.__ModuleLoader__.load({
54
54
  .balance-footer__wave::before{content:"";position:absolute;top:1px;bottom:1px;left:0;width:2.5px;margin-left:-1.25px;border-radius:999px;corner-shape:round;background:#fff;box-shadow:0 0 3px 1px rgba(255,255,255,.95),0 0 8px 3px rgba(255,255,255,.42),0 0 14px 5px rgba(255,255,255,.16);animation:balance-wipe-head .58s cubic-bezier(.34,.7,.26,1) forwards}
55
55
  @keyframes balance-wipe-head{0%{left:0;opacity:0}5%{opacity:1}82%{opacity:1}100%{left:100%;opacity:0}}
56
56
  /* 尊重系统的「减少动态效果」:不播,只留 --stale 的静态弱化 */
57
- @media (prefers-reduced-motion:reduce){.balance-footer__wave::before{animation:none;opacity:0}}
57
+ /* 浪潮白条不受 prefers-reduced-motion 影响:它是右键刷新的签名动效,故意保留 */
58
58
  /* 旧版余额行用到的图标与「余额」二字(footerStyle === "classic" 时才渲染) */
59
59
  .balance-footer__icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-primary)}
60
60
  .balance-footer__label{flex:none;color:var(--dsw-alias-label-primary)}
@@ -105,6 +105,9 @@ window.__ModuleLoader__.load({
105
105
  .balance-panel-icon--unread{color:var(--dsw-alias-brand-primary,#4d6bfe)}
106
106
  /* 红点:贴在图标右上角,深色描边保证在任意底色上都看得清 */
107
107
  .balance-panel-icon__dot{position:absolute;top:-2px;right:-3px;box-sizing:border-box;width:7px;height:7px;border-radius:50%;corner-shape:round;background:var(--dsw-alias-state-error-primary,#e5484d);box-shadow:0 0 0 1.5px var(--dsw-alias-bg-module-platform,var(--dsw-alias-base,#1b1b1c)),0 0 5px 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 65%,transparent);animation:balance-dot-pulse 2.2s ease-in-out infinite}
108
+ /* 有新版本时的文字提示:品牌色实心小胶囊,绝对定位压在图标右上,
109
+ 视觉上落在「余额与用量」几个字的上方。不做脉冲 —— 那是红点的语言,且静止更醒目。 */
110
+ .balance-panel-icon__new{position:absolute;left:10px;top:-10px;box-sizing:border-box;padding:1px 4px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:8.5px;line-height:11px;font-weight:600;letter-spacing:.1px;white-space:nowrap;pointer-events:none;box-shadow:0 1px 4px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 55%,transparent)}
108
111
  @keyframes balance-dot-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.82);opacity:.72}}
109
112
  @media (prefers-reduced-motion:reduce){.balance-panel-icon__dot{animation:none}}
110
113
  /* ── 侧栏面板页(点左侧「余额与用量」后渲染在主区域) ──────────────────── */
@@ -294,6 +297,10 @@ window.__ModuleLoader__.load({
294
297
  .balance-settings__input{box-sizing:border-box;width:96px;height:34px;padding:0 10px;text-align:right;font-variant-numeric:tabular-nums;border:.5px solid var(--dsw-alias-border-l4,rgba(127,127,127,.35));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.1));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;font-variant-numeric:tabular-nums;outline:none}
295
298
  .balance-settings__input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}
296
299
  .balance-settings__input--wide{width:100%;max-width:340px;min-width:0;text-align:left}
300
+ /* 关掉侧边栏入口后的提醒:壳用 DSH 官方 Modal,这里只留正文排版。 */
301
+ .balance-tip-body{margin:0;font-size:13px;line-height:1.7;color:var(--dsw-alias-label-secondary)}
302
+ .balance-tip-ok{padding:5px 14px;border:.5px solid transparent;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
303
+ .balance-tip-ok:hover{opacity:.88}
297
304
  .balance-settings__toggle{position:relative;box-sizing:border-box;flex:none;width:36px;height:20px;padding:2px;border:none;border-radius:999px;corner-shape:round;background:var(--dsw-alias-border-l3,rgba(127,127,127,.35));cursor:pointer;transition:background .12s ease}
298
305
  .balance-settings__toggle[aria-checked="true"]{background:var(--dsw-alias-brand-primary,#4d6bfe)}
299
306
  .balance-settings__toggle::after{content:"";display:block;width:16px;height:16px;border-radius:50%;corner-shape:round;background:var(--dsw-alias-switch-thumb,#fff);transition:transform .12s ease}
@@ -303,14 +310,33 @@ window.__ModuleLoader__.load({
303
310
  .balance-settings__segment{box-sizing:border-box;padding:2px 12px;border:none;border-radius:999px;corner-shape:round;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .15s var(--ds-ease-in-out,.2s),color .15s var(--ds-ease-in-out,.2s)}
304
311
  .balance-settings__segment:hover{color:var(--dsw-alias-label-primary)}
305
312
  .balance-settings__segment--on{background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#2a2a2c));color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.2)}
313
+ /* 版本与更新区块:设置页第一块(紧跟计费范围告示条)。
314
+ 默认是中性底色,只在「有新版本」时提亮成品牌色、「刚更新完」时转成功色,
315
+ 失败不染色(网络波动不该看起来像错误告警)。 */
316
+ .balance-settings__update{margin:0 0 16px;padding:11px 13px;border-radius:var(--dsw-radius-md,8px);border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22));background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}
317
+ .balance-settings__update--newer{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 12%,transparent)}
318
+ .balance-settings__update--done{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#22c55e) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#22c55e) 11%,transparent)}
319
+ .balance-settings__update-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
320
+ .balance-settings__update-title{display:flex;align-items:center;gap:7px;min-width:0;font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-secondary)}
321
+ .balance-settings__update-ver{font-weight:600;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
322
+ /* 新版徽标:实心品牌色胶囊,形状必须是正圆角(DSH 全局把圆角改成 superellipse 了) */
323
+ .balance-settings__update-badge{flex:none;padding:1px 7px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-brand-primary,#4d6bfe);color:#fff;font-size:11px;font-weight:600;line-height:16px;white-space:nowrap}
324
+ .balance-settings__update-btns{display:flex;align-items:center;gap:6px;flex:none}
325
+ /* 查询中的进度文案:跟按钮同一行、排在左边,灰一点,不跟主文案抢注意力。 */
326
+ .balance-settings__update-busy{flex:none;font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
327
+ /* 标题行左侧的状态话(目前只有「已是最新版本。」)—— 成功绿,前面一个细分隔点,
328
+ 与「版本 2.0.2」拉开层次但不抢眼。 */
329
+ .balance-settings__update-inline{flex:none;font-size:11.5px;line-height:17px;color:var(--dsw-alias-state-success-primary,#22c55e);white-space:nowrap}
330
+ .balance-settings__update-inline::before{content:"·";margin:0 2px 0 0;color:var(--dsw-alias-label-tertiary)}
331
+ .balance-settings__update-note{margin:7px 0 0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
306
332
  .balance-settings__actions{display:flex;align-items:center;gap:8px;margin:14px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
307
333
  .balance-settings__status{flex:1;min-width:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
308
334
  .balance-settings__status--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
309
335
  .balance-settings__status--ok{color:var(--dsw-alias-state-success-primary,#22c55e)}
310
- .balance-settings__btn{box-sizing:border-box;flex:none;padding:5px 14px;border:1px solid transparent;border-radius:var(--dsw-radius-md,8px);background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:1.5;cursor:pointer;transition:opacity .12s ease,color .12s ease}
311
- .balance-settings__btn:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
336
+ .balance-settings__btn{box-sizing:border-box;flex:none;padding:5px 14px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:1.5;cursor:pointer;transition:background .12s ease,border-color .12s ease,opacity .12s ease}
337
+ .balance-settings__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-active,rgba(127,127,127,.18))}
312
338
  .balance-settings__btn:disabled{opacity:.4;cursor:default}
313
- .balance-settings__btn--primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
339
+ .balance-settings__btn--primary{border-color:transparent;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
314
340
  .balance-settings__btn--primary:hover:not(:disabled){opacity:.88;color:var(--dsw-alias-bg-layer-3)}
315
341
  .balance-settings__summary{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
316
342
  `;
@@ -900,6 +926,73 @@ window.__ModuleLoader__.load({
900
926
  return settingsCache.value;
901
927
  }
902
928
 
929
+ // ─── 检查更新 ─────────────────────────────────────────────────────────────
930
+ /** 查 npm 上有没有新版本(/balance-api?update=1)。
931
+ * 返回 { ok, current, latest, newer, error };有新版时 newer = true。
932
+ * 只是查版本号,不下载任何东西;失败时 ok = false 且 error 带原因。 */
933
+ async function fetchUpdateInfo() {
934
+ const res = await fetch("/balance-api?update=1", { headers: { accept: "application/json" }, cache: "no-store" });
935
+ if (!res || !res.ok) throw new Error("HTTP " + (res ? res.status : "?"));
936
+ const data = await res.json();
937
+ const current =
938
+ data && typeof data.current === "string" && data.current !== ""
939
+ ? data.current
940
+ : String(settingsCache.version || "");
941
+ if (!data || data.ok !== true) {
942
+ return {
943
+ ok: false,
944
+ current,
945
+ latest: "",
946
+ newer: false,
947
+ error: data && typeof data.error === "string" ? data.error : "未知原因",
948
+ };
949
+ }
950
+ const latest = typeof data.latest === "string" ? data.latest : "";
951
+ return { ok: true, current, latest, newer: compareVersion(latest, current) > 0, error: "" };
952
+ }
953
+
954
+ // ─── 启动检查更新 → 侧栏入口红点 ────────────────────────────────────────
955
+ // 每次打开 DeepSeek Harness 查一次 npm,有新版本就在左侧「余额与用量」
956
+ // 入口上亮一颗红点(用户要求)。只查版本号,不下载、不弹窗、不打扰。
957
+ let updateNotice = { latest: "", seen: false };
958
+ const updateNoticeListeners = new Set();
959
+ function emitUpdateNotice() {
960
+ for (const listener of updateNoticeListeners) {
961
+ // 单个订阅者抛错不能影响其它(也别冒泡到渲染阶段)
962
+ try {
963
+ listener();
964
+ } catch {
965
+ /* ignore */
966
+ }
967
+ }
968
+ }
969
+
970
+ /** 订阅「有没有新版本」。与 settingsListeners 分开,避免每次改设置都重查。 */
971
+ function useUpdateNotice() {
972
+ const [, force] = react.useState(0);
973
+ react.useEffect(() => {
974
+ const listener = () => force((n) => n + 1);
975
+ updateNoticeListeners.add(listener);
976
+ return () => updateNoticeListeners.delete(listener);
977
+ }, []);
978
+ return updateNotice;
979
+ }
980
+
981
+ /** 启动检查(每次页面加载跑一次,apply 里延迟调度)。失败静默:没有红点而已。 */
982
+ async function checkUpdateOnStartup() {
983
+ try {
984
+ const info = await fetchUpdateInfo();
985
+ if (!info || info.ok !== true || info.newer !== true) return;
986
+ // latest 变了才重置 seen —— 用户已经无视过 2.1.0,再出 2.2.0 仍然要提示
987
+ if (updateNotice.latest !== info.latest) {
988
+ updateNotice = { latest: info.latest, seen: false };
989
+ emitUpdateNotice();
990
+ }
991
+ } catch {
992
+ /* 网络/接口不可用:当作没新版,不写日志不打扰 */
993
+ }
994
+ }
995
+
903
996
  /** 订阅设置(首次挂载异步拉一次)。 */
904
997
  function useSettings() {
905
998
  const [, force] = react.useState(0);
@@ -1048,17 +1141,10 @@ window.__ModuleLoader__.load({
1048
1141
  // 也不出现"余额变了但动效没播/播了一半被打断"(2026-10-30 反馈)。
1049
1142
  let manualRefreshLockedUntil = 0;
1050
1143
 
1051
- function reducedMotion() {
1052
- try {
1053
- return typeof window !== "undefined" && typeof window.matchMedia === "function"
1054
- && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1055
- } catch {
1056
- return false;
1057
- }
1058
- }
1059
- // 系统开了"减少动态效果"时 CSS 那份动画本来就是 none,锁个 580ms 只会像卡住,所以放行。
1060
1144
  function waveLockDuration() {
1061
- return reducedMotion() ? 0 : WAVE_DURATION_MS;
1145
+ // 恒等于动画时长。刻意**不**跟随 prefers-reduced-motion —— 白条是本插件的
1146
+ // 签名动效,系统开了「减少动态效果」也照播(用户明确要求,别再加回判断)。
1147
+ return WAVE_DURATION_MS;
1062
1148
  }
1063
1149
 
1064
1150
  function onBalanceContextMenu(event) {
@@ -2297,7 +2383,14 @@ function UsageCostLine({ useProjection, sessionId }) {
2297
2383
  // · 用户从 1.3.0 直接跳到 1.3.5(跳过中间版本)也会看到红点
2298
2384
  // · 点过之后写回 entrySeen: true,红点永久消失,重启也不回来
2299
2385
  // · 判定用 !== true 而不是 === false,老设置文件里没有这个键也照样算未读
2300
- const unread = settings.entrySeen !== true && !active;
2386
+ const notice = useUpdateNotice();
2387
+ // 有新版本、且还没点开过面板 → 在图标右上角挂一个「新版本!」胶囊。
2388
+ // 用户要求:别再让它跟「有新内容」的未读红点用同一个提示(重复且不显眼)。
2389
+ const updateHint = notice.latest !== "" && notice.seen !== true && !active;
2390
+ const unread = settings.entrySeen !== true && !active && !updateHint;
2391
+ // 关掉「侧边栏显示本插件」→ 这一格直接不渲染(图标消失)。
2392
+ // 位置必须在所有 hook 之后,否则不同渲染间 hook 数量不一致,React 会直接报错。
2393
+ if (settings.showSidebarEntry === false) return null;
2301
2394
  const svg = jsx.jsx("svg", {
2302
2395
  width: p,
2303
2396
  height: p,
@@ -2316,26 +2409,44 @@ function UsageCostLine({ useProjection, sessionId }) {
2316
2409
  jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
2317
2410
  ],
2318
2411
  });
2319
- if (!unread) return svg;
2412
+ if (!unread && !updateHint) return svg;
2320
2413
  // 点一下图标(红点就在图标右上角,点哪都算)就立刻熄灯:
2321
2414
  // 本地先乐观置 true 并广播,红点在同一帧消失,不等 HTTP 往返;
2322
2415
  // 持久化走 fire-and-forget,失败也只是下次进来再写一次。
2323
2416
  // 用 pointerdown 而不是 click —— panellist 那一行的 click 归 DSH 的 selectPanel 处理,
2324
2417
  // pointerdown 只负责"标记已读",不抢它的点击行为(事件照常冒泡)。
2325
2418
  const markSeen = () => {
2326
- if (settingsCache.value.entrySeen === true) return;
2327
- settingsCache = {
2328
- ...settingsCache,
2329
- value: { ...settingsCache.value, entrySeen: true },
2330
- };
2331
- emitSettings();
2332
- void saveSettings({ entrySeen: true }).catch(() => {});
2419
+ // 两个红点原因各自熄各自的:不能因为 entrySeen 已是 true 就整段 early return,
2420
+ // 否则「有新版本」那颗点永远清不掉。
2421
+ if (updateHint) {
2422
+ updateNotice = { ...updateNotice, seen: true };
2423
+ emitUpdateNotice();
2424
+ }
2425
+ if (settingsCache.value.entrySeen !== true) {
2426
+ settingsCache = {
2427
+ ...settingsCache,
2428
+ value: { ...settingsCache.value, entrySeen: true },
2429
+ };
2430
+ emitSettings();
2431
+ void saveSettings({ entrySeen: true }).catch(() => {});
2432
+ }
2333
2433
  };
2334
2434
  // 外层 span 只为了挂定位;DSH 的 panellist 容器不裁子元素
2435
+ // 有新版 → 「新版本!」胶囊(品牌色实心,压在图标右上、落在入口文字上方);
2436
+ // 否则若没点开过 → 原来那颗未读红点。两者不同时出现。
2335
2437
  return jsx.jsxs("span", {
2336
- className: "balance-panel-icon balance-panel-icon--unread",
2438
+ className:
2439
+ "balance-panel-icon" + (unread ? " balance-panel-icon--unread" : ""),
2337
2440
  onPointerDown: markSeen,
2338
- children: [svg, jsx.jsx("span", { className: "balance-panel-icon__dot", "aria-hidden": "true" })],
2441
+ children: [
2442
+ svg,
2443
+ unread
2444
+ ? jsx.jsx("span", { className: "balance-panel-icon__dot", "aria-hidden": "true" })
2445
+ : null,
2446
+ updateHint
2447
+ ? jsx.jsx("span", { className: "balance-panel-icon__new", children: "新版本!" })
2448
+ : null,
2449
+ ],
2339
2450
  });
2340
2451
  }
2341
2452
 
@@ -2350,6 +2461,12 @@ function UsageCostLine({ useProjection, sessionId }) {
2350
2461
  if (settings.entrySeen !== true) {
2351
2462
  void saveSettings({ entrySeen: true }).catch(() => {});
2352
2463
  }
2464
+ // 打开面板就能看到顶部的「版本与更新」区块,所以新版本红点到此为止。
2465
+ // seen 只存在内存里(不写盘):下次启动重新检查,真有新版会再亮。
2466
+ if (updateNotice.latest !== "" && updateNotice.seen !== true) {
2467
+ updateNotice = { ...updateNotice, seen: true };
2468
+ emitUpdateNotice();
2469
+ }
2353
2470
  }, [settings.entrySeen]);
2354
2471
  return jsx.jsxs("div", {
2355
2472
  className: "balance-panel-page",
@@ -2384,6 +2501,14 @@ function UsageCostLine({ useProjection, sessionId }) {
2384
2501
  const [draft, setDraft] = react.useState(null);
2385
2502
  const [saving, setSaving] = react.useState(false);
2386
2503
  const [status, setStatus] = react.useState({ kind: "", text: "" });
2504
+ // 版本与更新的状态机:
2505
+ // idle — 没查过
2506
+ // checking — 正在问 npm
2507
+ // ok — 查到了;newer 表示有没有新版
2508
+ // installing — 正在下载并覆盖本机安装
2509
+ // installed — 覆盖完成,等重启
2510
+ // error — 查询或更新失败
2511
+ const [updateCheck, setUpdateCheck] = react.useState({ kind: "idle" });
2387
2512
 
2388
2513
  // 拿到设置后灌一次草稿;draft 已有内容就不再覆盖,避免打断正在输入的用户
2389
2514
  react.useEffect(() => {
@@ -2423,6 +2548,23 @@ function UsageCostLine({ useProjection, sessionId }) {
2423
2548
  if (payload) commit(payload);
2424
2549
  }, 350);
2425
2550
  };
2551
+ // 关掉侧边栏入口时的提醒弹窗。这一项一关,本设置页就没有常驻入口了,
2552
+ // 所以必须当场告诉用户以后怎么回来(侧边栏 -> 插件 -> balance-dsh)。
2553
+ const [sidebarTip, setSidebarTip] = react.useState(false);
2554
+ // 弹窗用 DSH 官方的 Modal(primitives.Modal)—— Esc / 点遮罩 / 焦点管理都由它负责,
2555
+ // 我们只给标题、正文和底部按钮。取不到时退化成纯文字按钮,绝不自己画遮罩。
2556
+ const tipModal = primitives && typeof primitives.Modal === "function" ? primitives.Modal : null;
2557
+ const tipButton = primitives && typeof primitives.Button === "function" ? primitives.Button : null;
2558
+ const tipClose = () => setSidebarTip(false);
2559
+ const tipFooter = tipButton
2560
+ ? jsx.jsx(tipButton, { variant: "primary", onClick: tipClose, children: "知道了" })
2561
+ : jsx.jsx("button", {
2562
+ type: "button",
2563
+ className: "balance-tip-ok",
2564
+ onClick: tipClose,
2565
+ children: "知道了",
2566
+ });
2567
+
2426
2568
  const patch = (key, next) => {
2427
2569
  const merged = { ...valueRef.current, [key]: next };
2428
2570
  setDraft(merged);
@@ -2433,6 +2575,95 @@ function UsageCostLine({ useProjection, sessionId }) {
2433
2575
  setDraft(next);
2434
2576
  queueSave(next);
2435
2577
  };
2578
+ // 检查更新:只查版本号,不动任何文件。
2579
+ const onCheckUpdate = () => {
2580
+ if (updateCheck.kind === "checking" || updateCheck.kind === "installing") return;
2581
+ setUpdateCheck({ kind: "checking" });
2582
+ void fetchUpdateInfo()
2583
+ .then((info) => {
2584
+ if (info.ok !== true) {
2585
+ setUpdateCheck({ kind: "error", error: info.error });
2586
+ return;
2587
+ }
2588
+ setUpdateCheck({ kind: "ok", newer: info.newer, latest: info.latest, current: info.current });
2589
+ })
2590
+ .catch((error) => {
2591
+ setUpdateCheck({ kind: "error", error: error instanceof Error ? error.message : String(error) });
2592
+ });
2593
+ };
2594
+
2595
+ // 更新入口(用户要求:「检查到新版本时不要直接下载,要提醒用户是否下载」
2596
+ // + 「不是弹窗,是在检查更新那个位置变成『检测到新版本,是否更新』(更新)」)。
2597
+ // 所以只有一层:查到新版后同一块区域直接问「是否更新?」,旁边的按钮就叫「更新」,
2598
+ // 点了才下载。不弹窗、不再多点一次确认。
2599
+ const onDoInstall = () => {
2600
+ if (updateCheck.kind === "checking" || updateCheck.kind === "installing") return;
2601
+ setUpdateCheck({ kind: "installing", latest: updateCheck.latest });
2602
+ void fetch("/balance-api?update=1", {
2603
+ method: "POST",
2604
+ headers: { "Content-Type": "application/json" },
2605
+ body: "{}",
2606
+ })
2607
+ .then((res) => {
2608
+ if (!res || !res.ok) throw new Error("HTTP " + (res ? res.status : "?"));
2609
+ return res.json();
2610
+ })
2611
+ .then((data) => {
2612
+ if (!data || data.ok !== true) {
2613
+ setUpdateCheck({ kind: "error", error: (data && data.error) || "更新失败" });
2614
+ return;
2615
+ }
2616
+ // registry 上刚好被撤掉 / 本地已经是这个版本:服务端会回 installed:false
2617
+ if (data.installed === false) {
2618
+ setUpdateCheck({ kind: "ok", newer: false, latest: data.to || "", current: data.from || "" });
2619
+ return;
2620
+ }
2621
+ setUpdateCheck({
2622
+ kind: "installed",
2623
+ to: data.to || "",
2624
+ failed: Array.isArray(data.failed) ? data.failed : [],
2625
+ });
2626
+ })
2627
+ .catch((error) => {
2628
+ setUpdateCheck({ kind: "error", error: error instanceof Error ? error.message : String(error) });
2629
+ });
2630
+ };
2631
+
2632
+ const updateBusy = updateCheck.kind === "checking" || updateCheck.kind === "installing";
2633
+ // 一行说明文字:四种状态各说一句,idle 也给一句提示(区块默认就可见,不留空白)
2634
+ // 标题行左侧的一句话状态。「已是最新版本」这种短状态放这里(不再单独占一行),
2635
+ // 长文案(有新版本时的覆盖/备份/重启说明)仍走下面的 updateNote。
2636
+ const updateInline = (() => {
2637
+ if (updateCheck.kind === "ok" && !updateCheck.newer) return "已是最新版本。";
2638
+ return "";
2639
+ })();
2640
+
2641
+ // 查询中的进度文案走右侧(按钮左边),不占下面那一行 ——
2642
+ // 用户要求「这个查询也放在右边」。它短,跟按钮排一行正好。
2643
+ const updateBusyNote =
2644
+ updateCheck.kind === "checking" ? "正在查询 npm registry…" : "";
2645
+
2646
+ const updateNote = (() => {
2647
+ if (updateCheck.kind === "installing") return "正在下载并替换本机安装文件,请稍候…";
2648
+ if (updateCheck.kind === "installed") {
2649
+ const failed = updateCheck.failed || [];
2650
+ if (failed.length > 0) {
2651
+ return "已更新到 " + updateCheck.to + ",但有 " + failed.length + " 个文件没覆盖成功(" + failed.join(";") + ")。重启后如仍异常,请先从插件市场重装。";
2652
+ }
2653
+ return "已更新到 " + updateCheck.to + ",重启 DeepSeek Harness 后生效。";
2654
+ }
2655
+ if (updateCheck.kind === "error") return "检查失败:" + updateCheck.error + "(可稍后重试)";
2656
+ if (updateCheck.kind === "ok") {
2657
+ // 「已是最新版本」不在这里出 —— 它走 updateInline,显示在标题行左侧。
2658
+ if (!updateCheck.newer) return "";
2659
+ return "检测到新版本 " + updateCheck.latest
2660
+ + "(当前 " + (updateCheck.current || settingsCache.version || "—") + "),是否更新?"
2661
+ + "更新会覆盖本机插件文件(旧文件先备份到 ~/.dsh/balance-update-backup/),"
2662
+ + "完成后需重启 DeepSeek Harness 生效。";
2663
+ }
2664
+ // 初始态不写任何引导文案(用户要求:这行小字删掉)。
2665
+ return "";
2666
+ })();
2436
2667
 
2437
2668
  // 插件列表里的单行摘要
2438
2669
  if (view === "summary") {
@@ -2536,7 +2767,14 @@ function UsageCostLine({ useProjection, sessionId }) {
2536
2767
  jsx.jsx("span", { className: "balance-settings__unit", children: unit, key: key + "-unit" }),
2537
2768
  ]);
2538
2769
 
2539
- const toggleRow = (key, label, hint) => row(key, label, hint, toggle(key, label));
2770
+ // onToggle(可选)拿到的是「点完之后的新值」,用来在关掉某项时追加副作用
2771
+ const toggleRow = (key, label, hint, onToggle) =>
2772
+ row(
2773
+ key,
2774
+ label,
2775
+ hint,
2776
+ toggle(key, label, onToggle ? () => onToggle(value[key] !== true) : undefined),
2777
+ );
2540
2778
 
2541
2779
  // 二选一分段控件:用于「余额行外观」这类互斥选项
2542
2780
  const choiceRow = (key, label, hint, options) => row(key, label, hint, jsx.jsx("div", {
@@ -2670,6 +2908,86 @@ function UsageCostLine({ useProjection, sessionId }) {
2670
2908
  }),
2671
2909
  ],
2672
2910
  }),
2911
+ // 版本与更新:放在最前面(用户要"往上放一点")。
2912
+ // 检查、更新、结果说明都在这一块,底部只留保存状态与「恢复默认」。
2913
+ jsx.jsxs("div", {
2914
+ className:
2915
+ "balance-settings__update" +
2916
+ (updateCheck.kind === "error"
2917
+ ? " balance-settings__update--error"
2918
+ : updateCheck.kind === "installed"
2919
+ ? " balance-settings__update--done"
2920
+ : updateCheck.kind === "ok" && updateCheck.newer
2921
+ ? " balance-settings__update--newer"
2922
+ : ""),
2923
+ children: [
2924
+ jsx.jsxs("div", {
2925
+ className: "balance-settings__update-head",
2926
+ children: [
2927
+ jsx.jsxs("div", {
2928
+ className: "balance-settings__update-title",
2929
+ children: [
2930
+ "版本 ",
2931
+ jsx.jsx("strong", {
2932
+ className: "balance-settings__update-ver",
2933
+ children: settingsCache.version || "—",
2934
+ }),
2935
+ updateCheck.kind === "ok" && updateCheck.newer
2936
+ ? jsx.jsx("span", {
2937
+ className: "balance-settings__update-badge",
2938
+ children: "新版 " + updateCheck.latest,
2939
+ })
2940
+ : null,
2941
+ updateInline
2942
+ ? jsx.jsx("span", {
2943
+ className: "balance-settings__update-inline",
2944
+ children: updateInline,
2945
+ })
2946
+ : null,
2947
+ ],
2948
+ }),
2949
+ jsx.jsxs("div", {
2950
+ className: "balance-settings__update-btns",
2951
+ children: [
2952
+ // 查询中的文案排在按钮左边(用户要求放在右边,别占下面那行)
2953
+ updateBusyNote
2954
+ ? jsx.jsx("span", {
2955
+ className: "balance-settings__update-busy",
2956
+ children: updateBusyNote,
2957
+ })
2958
+ : null,
2959
+ // 检查更新:进行中禁用并显示进度(checking / installing 共用这个位置)
2960
+ jsx.jsx("button", {
2961
+ type: "button",
2962
+ className: "balance-settings__btn",
2963
+ onClick: onCheckUpdate,
2964
+ disabled: updateBusy,
2965
+ children: updateCheck.kind === "checking"
2966
+ ? "检查中…"
2967
+ : updateCheck.kind === "installing"
2968
+ ? "更新中…"
2969
+ : "检查更新",
2970
+ }),
2971
+ // 「更新」只在检测到新版时出现,点它才真的下载 ——
2972
+ // 上面的说明文字已经把"是否更新"问出来了,这里就是那个「是」。
2973
+ updateCheck.kind === "ok" && updateCheck.newer
2974
+ ? jsx.jsx("button", {
2975
+ type: "button",
2976
+ className: "balance-settings__btn balance-settings__btn--primary",
2977
+ onClick: onDoInstall,
2978
+ children: "更新",
2979
+ })
2980
+ : null,
2981
+ ],
2982
+ }),
2983
+ ],
2984
+ }),
2985
+ updateNote
2986
+ ? jsx.jsx("div", { className: "balance-settings__update-note", children: updateNote })
2987
+ : null,
2988
+ ],
2989
+ }),
2990
+
2673
2991
  group("balance", "余额", [
2674
2992
  numberRow("refreshIntervalSec", "余额刷新间隔", "轮询间隔;对话产生新消耗时也会顺带刷新。", 5, 3600, "秒"),
2675
2993
  numberRow("lowBalanceThreshold", "低余额警示阈值", "余额低于该值时金额转为琥珀色。", 0, 100000, "元"),
@@ -2691,7 +3009,15 @@ function UsageCostLine({ useProjection, sessionId }) {
2691
3009
  { value: "timeline", label: "新版" },
2692
3010
  { value: "classic", label: "旧版" },
2693
3011
  ]),
2694
- toggleRow("showSidebarEntry", "左侧导航栏入口", "在左侧导航栏底部(插件 / 自动化任务 那一组)加一个「余额与用量」入口,点开是本页。改这一项需刷新页面。"),
3012
+ toggleRow(
3013
+ "showSidebarEntry",
3014
+ "侧边栏显示本插件",
3015
+ "在左侧导航栏底部(插件 / 自动化任务 那一组)加一个「余额与用量」入口,点开是本页。关掉之后要再打开,走侧边栏「插件」→ balance-dsh。改这一项需刷新页面。",
3016
+ (next) => {
3017
+ patch("showSidebarEntry", next);
3018
+ if (next === false) setSidebarTip(true);
3019
+ },
3020
+ ),
2695
3021
  // 这两项各只属于一种样式,按当前选择出现:
2696
3022
  // 浪潮 = 新版独有(旧版刷新走「刷新中…」文字,没有白条可关)
2697
3023
  // 时段标签 = 新版不可关闭(刻度条是新版的结构主体),只有旧版才允许收成纯金额
@@ -2738,6 +3064,23 @@ function UsageCostLine({ useProjection, sessionId }) {
2738
3064
  }),
2739
3065
  ],
2740
3066
  }) : null,
3067
+
3068
+ // 关掉侧边栏入口后的提醒:直接用 DSH 官方 Modal 渲染。
3069
+ tipModal && sidebarTip
3070
+ ? jsx.jsx(tipModal, {
3071
+ open: true,
3072
+ title: "已关闭侧边栏入口",
3073
+ closeLabel: "关闭",
3074
+ onClose: tipClose,
3075
+ footer: tipFooter,
3076
+ children: jsx.jsx("p", {
3077
+ className: "balance-tip-body",
3078
+ children:
3079
+ "之后想再打开,去侧边栏「插件」→ balance-dsh,就能回到这个设置页,"
3080
+ + "把「侧边栏显示本插件」重新打开。刷新页面后生效。",
3081
+ }),
3082
+ })
3083
+ : null,
2741
3084
  ],
2742
3085
  });
2743
3086
  }
@@ -2778,19 +3121,62 @@ function UsageCostLine({ useProjection, sessionId }) {
2778
3121
  // slot 本体只负责画图标(wide 时尺寸 16,收起态 18)。
2779
3122
  // 点击后 DSH 调 layout.selectPanel(id),主区域用 keyed slot "main" 按 activePanelId 取内容,
2780
3123
  // 所以再注册一个 key 同名的 main 条目就是点开后的页面。
2781
- // 关掉「左侧导航栏入口」就不注册;slot 注册无法在运行时撤销,所以这一项改动需刷新页面。
3124
+ // 开关「侧边栏显示本插件」要**立刻**生效,不能等刷新:
3125
+ // 拿到 disposer 就把它注销掉(整行消失),拿不到就退回组件内 return null(图标消失)。
3126
+ // 注意别在 inject 回调里按当时的缓存值提前 return —— 那时 fetchSettingsOnce 还没回来,
3127
+ // settingsCache 永远是默认值 true,开关会看起来完全没反应。
2782
3128
  ctx.slots.inject("sidebar.panellist", () => {
2783
- if (settingsCache.value.showSidebarEntry === false) return;
2784
- return ctx.slots.register(
2785
- {
2786
- name: "sidebar.panellist",
2787
- id: "balance",
2788
- order: 30,
2789
- label: "余额与用量",
2790
- },
2791
- BalancePanelIcon,
2792
- );
3129
+ let dispose = null;
3130
+ let registered = false;
3131
+ const syncEntry = () => {
3132
+ const want = settingsCache.value.showSidebarEntry !== false;
3133
+ if (want && !registered) {
3134
+ const result = ctx.slots.register(
3135
+ {
3136
+ name: "sidebar.panellist",
3137
+ id: "balance",
3138
+ order: 30,
3139
+ label: "余额与用量",
3140
+ },
3141
+ BalancePanelIcon,
3142
+ );
3143
+ registered = true;
3144
+ dispose = typeof result === "function" ? result : null;
3145
+ return;
3146
+ }
3147
+ if (!want && registered && dispose) {
3148
+ try {
3149
+ dispose();
3150
+ } catch {
3151
+ return;
3152
+ }
3153
+ registered = false;
3154
+ dispose = null;
3155
+ }
3156
+ };
3157
+ syncEntry();
3158
+ settingsListeners.add(syncEntry);
3159
+ return () => {
3160
+ settingsListeners.delete(syncEntry);
3161
+ if (dispose) {
3162
+ try {
3163
+ dispose();
3164
+ } catch {
3165
+ /* 释放失败不影响卸载 */
3166
+ }
3167
+ dispose = null;
3168
+ registered = false;
3169
+ }
3170
+ };
2793
3171
  });
3172
+ // 启动检查更新:等 UI 先跑起来再查(2.5s),别跟首屏抢网络。
3173
+ // 每次页面加载 = 每次打开 DeepSeek Harness,符合「每次打开都检查一次」。
3174
+ if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
3175
+ window.setTimeout(() => {
3176
+ void checkUpdateOnStartup();
3177
+ }, 2500);
3178
+ }
3179
+
2794
3180
  ctx.slots.inject("main", () =>
2795
3181
  ctx.slots.register(
2796
3182
  {
package/lib/index.js CHANGED
@@ -345,7 +345,223 @@ function formatBalance(balance) {
345
345
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
346
346
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
347
347
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
348
- const PLUGIN_VERSION = "2.0.0";
348
+ const PLUGIN_VERSION = "2.0.2";
349
+
350
+ // ─── 检查更新 ───────────────────────────────────────────────────────────────
351
+ // 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
352
+ // 结果缓存 10 分钟:设置页的按钮连着点也不会把 registry 打爆。
353
+ const UPDATE_CACHE_MS = 10 * 60 * 1000;
354
+ const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/balance-dsh/latest";
355
+ let updateCache = { at: 0, value: null };
356
+
357
+ /** 语义化版本比较:a > b 返回正、相等返回 0、a < b 返回负。
358
+ * 只比三段数字(忽略预发布后缀),够用来判断「npm 上的是不是比本地新」。 */
359
+ function compareVersion(a, b) {
360
+ const parse = (v) =>
361
+ String(v ?? "")
362
+ .trim()
363
+ .replace(/^v/i, "")
364
+ .split("-")[0]
365
+ .split(".")
366
+ .map((x) => parseInt(x, 10) || 0);
367
+ const x = parse(a);
368
+ const y = parse(b);
369
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
370
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
371
+ if (d !== 0) return d;
372
+ }
373
+ return 0;
374
+ }
375
+
376
+ /** 本插件的安装根目录(.../node_modules/balance-dsh),由 import.meta.url 反推。
377
+ * 定位不到返回空串(调用方报错,不动任何文件)。 */
378
+ async function installedPluginDir() {
379
+ try {
380
+ const { fileURLToPath } = await import("node:url");
381
+ const { dirname } = await import("node:path");
382
+ // .../node_modules/balance-dsh/lib/index.js → 上两级就是包根
383
+ return dirname(dirname(fileURLToPath(import.meta.url)));
384
+ } catch {
385
+ return "";
386
+ }
387
+ }
388
+
389
+ /** 极简 tar.gz 解包:只取普通文件、去掉 npm 固定的 package/ 前缀,返回 [{ rel, data }]。
390
+ * 不处理符号链接(npm 包是禁掉 symlink 的)与 ustar 长文件名(本包路径都很短)。 */
391
+ async function extractPackageFiles(gz) {
392
+ const { gunzipSync } = await import("node:zlib");
393
+ const tar = gunzipSync(gz);
394
+ const files = [];
395
+ let off = 0;
396
+ while (off + 512 <= tar.length) {
397
+ const header = tar.subarray(off, off + 512);
398
+ // 尾部是全零块,遇到就结束
399
+ let allZero = true;
400
+ for (let i = 0; i < 512; i += 1) {
401
+ if (header[i] !== 0) {
402
+ allZero = false;
403
+ break;
404
+ }
405
+ }
406
+ if (allZero) break;
407
+ const name = header.toString("utf8", 0, 100).replace(/\0.*$/, "");
408
+ const size = parseInt(header.toString("utf8", 124, 136).replace(/\0.*$/, "").trim(), 8) || 0;
409
+ const typeFlag = header.toString("utf8", 156, 157);
410
+ // "0" / "" / NUL = 普通文件;"5" = 目录,"2" = 符号链接,都跳过
411
+ const isFile = typeFlag === "0" || typeFlag === "" || typeFlag === "\0";
412
+ if (isFile && size > 0 && name !== "") {
413
+ const rel = name.startsWith("package/") ? name.slice("package/".length) : name;
414
+ if (rel !== "" && !rel.endsWith("/") && !rel.includes("..")) {
415
+ files.push({ rel, data: tar.subarray(off + 512, off + 512 + size) });
416
+ }
417
+ }
418
+ off += 512 + Math.ceil(size / 512) * 512;
419
+ }
420
+ return files;
421
+ }
422
+
423
+ /** tarball 地址(npm 的固定布局)。 */
424
+ function updateTarballUrl(version) {
425
+ return "https://registry.npmjs.org/balance-dsh/-/balance-dsh-" + version + ".tgz";
426
+ }
427
+
428
+ /** 就地更新本插件:下载 npm 上的最新 tarball → 解包 → 逐文件覆盖自己的安装目录。
429
+ *
430
+ * 几个刻意的取舍:
431
+ * - 只覆盖 tarball 里有的文件,不删任何东西。上游删掉的文件会残留,但残留无害,
432
+ * 比误删用户目录里的东西安全得多。
433
+ * - 覆盖前把旧文件备份到 <dshHome>/balance-update-backup/<from>-<时间戳>/,可手工回滚。
434
+ * - 逐个文件写、单个失败只记进 failed 不中断 —— 最典型的是 Windows 上 package.json
435
+ * 被 DSH 占着(之前同步时就踩过),但 lib/*.js 能写进去,功能不受影响。
436
+ * - 覆盖后 lib/index.js 的改动**必须重启 DSH** 才生效:Node 不会热替换已加载的模块。
437
+ * 返回值里带 restart:true,让界面明确提示。
438
+ */
439
+ async function installLatestVersion(ctx) {
440
+ const found = await fetchLatestVersion();
441
+ if (found.error) return { ok: false, error: found.error };
442
+ const latest = found.latest;
443
+ if (compareVersion(latest, PLUGIN_VERSION) <= 0) {
444
+ return { ok: true, installed: false, from: PLUGIN_VERSION, to: latest, note: "已是最新版本" };
445
+ }
446
+
447
+ const root = await installedPluginDir();
448
+ if (root === "") return { ok: false, error: "定位不到插件安装目录" };
449
+
450
+ let gz;
451
+ try {
452
+ const signal =
453
+ typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
454
+ ? AbortSignal.timeout(20000)
455
+ : undefined;
456
+ const res = await fetch(updateTarballUrl(latest), { signal });
457
+ if (!res || !res.ok) throw new Error("HTTP " + (res ? res.status : "?"));
458
+ gz = Buffer.from(await res.arrayBuffer());
459
+ } catch (error) {
460
+ return { ok: false, error: "下载 " + latest + " 失败:" + (error instanceof Error ? error.message : String(error)) };
461
+ }
462
+
463
+ let files;
464
+ try {
465
+ files = await extractPackageFiles(gz);
466
+ } catch (error) {
467
+ return { ok: false, error: "解包失败:" + (error instanceof Error ? error.message : String(error)) };
468
+ }
469
+ if (files.length === 0) return { ok: false, error: "下载到的包里没有文件" };
470
+
471
+ const { mkdir, writeFile, readFile, rename, unlink } = await import("node:fs/promises");
472
+ const { dirname, join, resolve, sep } = await import("node:path");
473
+
474
+ // 备份目录:拿不到 dshHome 就跳过备份(仍继续更新,失败项会体现在 failed 里)
475
+ const home = dshHomeDir(ctx);
476
+ const backupRoot =
477
+ home === "" ? "" : join(home, "balance-update-backup", PLUGIN_VERSION + "-" + Date.now());
478
+
479
+ const written = [];
480
+ const failed = [];
481
+ const rootAbs = resolve(root);
482
+ const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep;
483
+
484
+ for (const file of files) {
485
+ const dst = resolve(rootAbs, file.rel);
486
+ // 防路径穿越:解析后必须仍在包目录里
487
+ if (!dst.startsWith(rootPrefix)) {
488
+ failed.push(file.rel + "(路径越界,已跳过)");
489
+ continue;
490
+ }
491
+ try {
492
+ if (backupRoot !== "") {
493
+ try {
494
+ const old = await readFile(dst);
495
+ const bak = join(backupRoot, file.rel);
496
+ await mkdir(dirname(bak), { recursive: true });
497
+ await writeFile(bak, old);
498
+ } catch {
499
+ // 旧文件不存在(新版本新增的文件)或不可读:没有可备份的,继续
500
+ }
501
+ }
502
+ await mkdir(dirname(dst), { recursive: true });
503
+ // 先写同目录临时文件再 rename:原子替换 + 断开硬链接(profile 里有些文件是
504
+ // pnpm 硬链接,直接 writeFile 会写进同一个 inode,可能污染 store / 其它 profile)。
505
+ const staging = dst + ".new-" + Date.now() + "-" + written.length;
506
+ try {
507
+ await writeFile(staging, file.data);
508
+ await rename(staging, dst);
509
+ } catch (error) {
510
+ try {
511
+ await unlink(staging);
512
+ } catch {
513
+ // 临时文件没建起来或已清理,忽略
514
+ }
515
+ throw error;
516
+ }
517
+ written.push(file.rel);
518
+ } catch (error) {
519
+ failed.push(file.rel + ":" + (error instanceof Error ? error.message : String(error)));
520
+ }
521
+ }
522
+
523
+ if (written.length === 0) {
524
+ return { ok: false, error: "没有文件写入成功:" + failed.join(";") };
525
+ }
526
+
527
+ // 缓存里的 latest 可能已经过时,更新完作废,下次检查重新问 registry
528
+ updateCache = { at: 0, value: null };
529
+
530
+ return {
531
+ ok: true,
532
+ installed: true,
533
+ from: PLUGIN_VERSION,
534
+ to: latest,
535
+ files: written.length,
536
+ failed,
537
+ backup: backupRoot,
538
+ restart: true,
539
+ };
540
+ }
541
+
542
+ /** 查 npm 上 balance-dsh 的最新版本。成功返回 { latest },失败返回 { error }(失败不缓存,下次可重试)。 */
543
+ async function fetchLatestVersion(force) {
544
+ const now = Date.now();
545
+ if (!force && updateCache.value && now - updateCache.at < UPDATE_CACHE_MS) return updateCache.value;
546
+ if (typeof fetch !== "function") return { error: "当前运行环境没有 fetch" };
547
+ try {
548
+ let signal;
549
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
550
+ signal = AbortSignal.timeout(8000);
551
+ }
552
+ const res = await fetch(UPDATE_REGISTRY_URL, { headers: { accept: "application/json" }, signal });
553
+ if (!res || !res.ok) throw new Error("registry 返回 HTTP " + (res ? res.status : "?"));
554
+ const data = await res.json();
555
+ const latest = data && typeof data.version === "string" ? data.version.trim() : "";
556
+ if (latest === "") throw new Error("registry 返回里没有 version 字段");
557
+ updateCache = { at: now, value: { latest } };
558
+ return updateCache.value;
559
+ } catch (error) {
560
+ // 超时 / 断网 / 被墙都走这里,只影响这个按钮,不影响余额与设置
561
+ return { error: error instanceof Error ? error.message : String(error) };
562
+ }
563
+ }
564
+
349
565
 
350
566
  /**
351
567
  * 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
@@ -760,9 +976,9 @@ function withSettings(config, settings) {
760
976
  };
761
977
  }
762
978
 
763
- /** 设置文件路径:优先 DSH 自己的 home(dshHomePath 服务),再环境变量,最后 ~/.dsh。
764
- * 返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
765
- function settingsFilePath(ctx) {
979
+ /** DSH 的 home 目录:优先 dshHomePath 服务,再环境变量,最后 ~/.dsh。
980
+ * 返回空串表示定位不到,此时设置读写与更新备份一律静默降级。 */
981
+ function dshHomeDir(ctx) {
766
982
  let home = "";
767
983
  try {
768
984
  const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
@@ -777,6 +993,12 @@ function settingsFilePath(ctx) {
777
993
  const base = process.env.USERPROFILE || process.env.HOME || "";
778
994
  if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
779
995
  }
996
+ return home;
997
+ }
998
+
999
+ /** 设置文件路径。返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
1000
+ function settingsFilePath(ctx) {
1001
+ const home = dshHomeDir(ctx);
780
1002
  if (home === "") return "";
781
1003
  const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
782
1004
  return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
@@ -1471,6 +1693,44 @@ export function apply(ctx, config = {}) {
1471
1693
  }
1472
1694
  return;
1473
1695
  }
1696
+ // 检查更新 / 就地更新:同一个参数,GET 查询、POST 安装。
1697
+ // 它排在下面那道 405 之前 —— 这是除了设置写入之外唯一放开 POST 的入口,
1698
+ // 且只认 ?update=1(其余非 GET 仍然 405)。
1699
+ let wantUpdate = false;
1700
+ try {
1701
+ wantUpdate = new URL(req.url, "http://localhost").searchParams.get("update") === "1";
1702
+ } catch {
1703
+ wantUpdate = false;
1704
+ }
1705
+ if (wantUpdate) {
1706
+ if (req.method === "POST") {
1707
+ // POST → 下载 npm 上最新 tarball 覆盖本机安装(见 installLatestVersion)。
1708
+ // 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
1709
+ try {
1710
+ const result = await installLatestVersion(httpCtx);
1711
+ writeJson(res, 200, { current: PLUGIN_VERSION, ...result });
1712
+ } catch (error) {
1713
+ writeJson(res, 200, {
1714
+ ok: false,
1715
+ current: PLUGIN_VERSION,
1716
+ error: error instanceof Error ? error.message : String(error),
1717
+ });
1718
+ }
1719
+ return;
1720
+ }
1721
+ if (req.method !== "GET") {
1722
+ writeJson(res, 405, { ok: false, error: "method not allowed" });
1723
+ return;
1724
+ }
1725
+ // force:用户手动点「检查更新」时不走 10 分钟缓存,否则刚更新过会显示旧结果
1726
+ const found = await fetchLatestVersion(true);
1727
+ if (found && found.error) {
1728
+ writeJson(res, 200, { ok: false, current: PLUGIN_VERSION, error: found.error });
1729
+ } else {
1730
+ writeJson(res, 200, { ok: true, current: PLUGIN_VERSION, latest: found.latest });
1731
+ }
1732
+ return;
1733
+ }
1474
1734
  if (req.method !== "GET") {
1475
1735
  writeJson(res, 405, { ok: false, error: "method not allowed" });
1476
1736
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",