@iasiv5/dsh-quota-watch 0.1.25 → 0.1.27

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/README.md CHANGED
@@ -58,7 +58,7 @@ Web 与 Desktop profile 均已实测适配。按 DSH 插件管理器提示完成
58
58
 
59
59
  > **重置卡是可消耗、不可撤销的订阅权益。** 使用前请确认适用范围、卡种与额度窗口。
60
60
 
61
- 仅支持配置为 `zai-coding-cn` 的**国内站个人版**。国际站 `zai` / `zai-coding` 不提供此入口;团队版未验证、不在支持范围内。重置卡列表按卡种显示可用张数与最早到期日;「刷新」只查询卡数,不会消费卡。手动使用前,宿主会重新查询当前卡列表,面板也会先显示二次确认。5 小时卡重置 5 小时窗口;每周卡同时重置每周与 5 小时窗口。
61
+ 仅支持配置为 `zai-coding-cn` 的**国内站个人版**。国际站 `zai` / `zai-coding` 不提供此入口;团队版未验证、不在支持范围内。宿主启动时查询重置卡,此后最多每 10 分钟周期查询一次;卡种、可用张数与最早到期日会随结果更新。两种卡都无可用且自动用卡均关闭、无待展示事件时,卡区收成保留分隔线的一行,仍可展开自动用卡设置。概览底部或详情标题栏的「立即刷新」会同时更新额度和重置卡,不会消费卡;手动使用前宿主仍会重新查询当前卡列表,面板也会先显示二次确认。5 小时卡重置 5 小时窗口;每周卡同时重置每周与 5 小时窗口。
62
62
 
63
63
  ![GLM 详情:额度窗口、重置卡与展开的自动用卡设置;两个窗口的消费授权均关闭(示例数据)](docs/assets/panel-glm-detail.webp)
64
64
 
package/lib/client.js CHANGED
@@ -249,9 +249,8 @@ var COPY = {
249
249
  count: "{count} \u5F20",
250
250
  none: "\u65E0\u53EF\u7528",
251
251
  use: "\u91CD\u7F6E",
252
- reloadLink: "\uFF08\u5237\u65B0\uFF09",
253
252
  autoToggle: "\u81EA\u52A8\u7528\u5361",
254
- autoActive: "\u81EA\u52A8\u7528\u5361\xB7\u5DF2\u542F\u7528",
253
+ autoActive: "\u81EA\u52A8\u7528\u5361 \xB7 \u5DF2\u542F\u7528",
255
254
  expiry: "{date} \u524D\u6709\u6548",
256
255
  confirmTitle: "\u786E\u8BA4\u4F7F\u7528\u91CD\u7F6E\u5361\uFF1F",
257
256
  confirmWeek: "\u5C06\u6D88\u8017 1 \u5F20\u6BCF\u5468\u91CD\u7F6E\u5361\uFF0C\u7ACB\u5373\u91CD\u7F6E\u6BCF\u5468\u989D\u5EA6\u4E0E 5 \u5C0F\u65F6\u989D\u5EA6\u3002",
@@ -263,8 +262,11 @@ var COPY = {
263
262
  succeeded: "\u5DF2\u91CD\u7F6E",
264
263
  failed: "\u91CD\u7F6E\u5931\u8D25",
265
264
  loadFailed: "\u8BFB\u53D6\u91CD\u7F6E\u5361\u5931\u8D25",
266
- autoFiveHour: "\u81EA\u52A8\u7528\u5361\uFF085 \u5C0F\u65F6\uFF09\xB7 \u5DF2\u7528 {threshold}% \u89E6\u53D1",
267
- autoWeek: "\u81EA\u52A8\u7528\u5361\uFF08\u6BCF\u5468\uFF09\xB7 \u5DF2\u7528 {threshold}% \u89E6\u53D1",
265
+ // Auto rows render as three grid cells — window label, a centered ·
266
+ // separator, and the used note — so the · column lines up across rows.
267
+ autoFiveHour: "\u81EA\u52A8\u7528\u5361\uFF085 \u5C0F\u65F6\uFF09",
268
+ autoWeek: "\u81EA\u52A8\u7528\u5361\uFF08\u6BCF\u5468\uFF09",
269
+ autoUsed: "\u5DF2\u7528 {threshold}% \u89E6\u53D1",
268
270
  autoImmediate: "\u5F53\u524D\u5DF2\u7528 {used}\uFF0C\u5DF2\u8D85\u9608\u503C\uFF1A\u5F00\u542F\u540E\u4F1A\u7ACB\u5373\u6D88\u8017\u4E00\u5F20\u5361",
269
271
  autoSaveFailed: "\u5F00\u5173\u672A\u80FD\u4FDD\u5B58\uFF0C\u8BF7\u91CD\u8BD5",
270
272
  autoPersistFailed: "\u5F00\u5173\u5DF2\u751F\u6548\uFF0C\u4F46\u672A\u80FD\u5199\u5165\u78C1\u76D8\uFF08\u91CD\u542F\u540E\u4F1A\u4E22\u5931\uFF09",
@@ -318,7 +320,6 @@ var COPY = {
318
320
  count: "{count}",
319
321
  none: "none",
320
322
  use: "Reset",
321
- reloadLink: "(Refresh)",
322
323
  autoToggle: "Auto-use",
323
324
  autoActive: "Auto-use \xB7 on",
324
325
  expiry: "expires {date}",
@@ -332,8 +333,10 @@ var COPY = {
332
333
  succeeded: "Reset done",
333
334
  failed: "Reset failed",
334
335
  loadFailed: "Could not read reset cards",
335
- autoFiveHour: "Auto-use (5h) \xB7 trigger at {threshold}% used",
336
- autoWeek: "Auto-use (weekly) \xB7 trigger at {threshold}% used",
336
+ // Same three-cell grid as zh: label · used note.
337
+ autoFiveHour: "Auto-use (5h)",
338
+ autoWeek: "Auto-use (weekly)",
339
+ autoUsed: "trigger at {threshold}% used",
337
340
  autoImmediate: "Currently {used} used \u2014 already past the threshold: turning this on spends a card immediately",
338
341
  autoSaveFailed: "Could not save the switch \u2014 retry",
339
342
  autoPersistFailed: "Switch applied, but it could not be written to disk (lost on restart)",
@@ -405,6 +408,8 @@ var FLOAT_STYLE_TEXT = `
405
408
  .dqw-panel-back { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; margin: 0; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer; }
406
409
  .dqw-panel-back svg { display: block; width: 16px; height: 16px; }
407
410
  .dqw-panel-back:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
411
+ .dqw-panel-refresh { flex: none; margin: 0; padding: 4px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer; font: inherit; font-size: 10px; line-height: 14px; opacity: .72; }
412
+ .dqw-panel-refresh:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); opacity: 1; }
408
413
  .dqw-panel-close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 24px; height: 24px; margin: 0; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer; font: inherit; font-size: 12px; line-height: 1; }
409
414
  .dqw-panel-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
410
415
  .dqw-overview-list { display: flex; flex-direction: column; gap: 2px; }
@@ -444,16 +449,11 @@ var FLOAT_STYLE_TEXT = `
444
449
  .dqw-win-mid { opacity: .65; white-space: nowrap; }
445
450
  .dqw-win-used { opacity: .65; white-space: nowrap; }
446
451
  .dqw-win-val { min-width: 0; font-weight: 600; }
447
- /* Reset cards: a bordered block below the window grid. Every control is a real
448
- <button> so keyboard focus and the disabled state come from the platform. */
452
+ /* Reset cards: keep the dashed divider even when an empty wallet collapses to
453
+ one row. The panel's \u7ACB\u5373\u5237\u65B0 is the only manual re-check control. */
449
454
  .dqw-reset { border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 4px; }
450
- .dqw-reset-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 10px; line-height: 14px; }
455
+ .dqw-reset-head, .dqw-reset-collapsed { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 10px; line-height: 14px; }
451
456
  .dqw-reset-title { opacity: .65; }
452
- .dqw-reset-reload { border: 0; background: none; padding: 0; font: inherit; font-size: 10px; color: var(--dsw-alias-label-secondary, inherit); opacity: .65; cursor: pointer; text-decoration: underline; }
453
- .dqw-reset-reload:hover { opacity: 1; }
454
- .dqw-reset-reload:disabled { cursor: default; opacity: .35; text-decoration: none; }
455
- .dqw-reset-head-left { display: flex; align-items: baseline; min-width: 0; }
456
- .dqw-reset-head-left.is-en { gap: 4px; }
457
457
  .dqw-auto-master { display: flex; align-items: center; gap: 4px; cursor: pointer; font-size: 10px; line-height: 14px; }
458
458
  .dqw-auto-master .dqw-auto-box { accent-color: var(--dsw-alias-button-primary-fill, #5b8def); }
459
459
  .dqw-reset-lines { display: grid; grid-template-columns: max-content max-content minmax(0, 1fr) max-content max-content; column-gap: 6px; row-gap: 4px; align-items: center; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
@@ -482,13 +482,21 @@ var FLOAT_STYLE_TEXT = `
482
482
  .dqw-reset-notice.is-error { color: var(--dsw-alias-label-danger, #c93c3c); }
483
483
  .dqw-reset-auto { font-size: 10px; line-height: 14px; opacity: .6; }
484
484
  /* Auto-use switches: a real checkbox per window, backed by a host-side file so
485
- automatic spending keeps working while no browser is open. */
486
- .dqw-auto { display: flex; flex-direction: column; gap: 1px; }
487
- .dqw-auto-row { display: flex; align-items: center; gap: 6px; font-size: 10.5px; line-height: 16px; cursor: pointer; }
485
+ automatic spending keeps working while no browser is open. Both rows share one
486
+ grid (same recipe as .dqw-reset-lines): the window label stays left-aligned,
487
+ the \xB7 sits in its own narrow centered column so the two rows line up, and the
488
+ used note follows after the column gap. display:contents keeps the <label>
489
+ wrapping its checkbox for click-to-toggle while the cells join the grid. */
490
+ .dqw-auto-grid { display: grid; grid-template-columns: max-content max-content 12px minmax(0, 1fr); column-gap: 6px; row-gap: 2px; align-items: center; font-size: 10.5px; line-height: 16px; }
491
+ .dqw-auto { display: contents; }
492
+ .dqw-auto-row { display: contents; }
493
+ .dqw-auto-grid label > :is(input, span) { cursor: pointer; }
488
494
  .dqw-auto-box { width: 12px; height: 12px; margin: 0; flex: none; cursor: pointer; accent-color: var(--dsw-alias-button-primary-fill, #5b8def); }
489
495
  .dqw-auto-box:disabled { cursor: default; }
490
- .dqw-auto-label { opacity: .75; }
491
- .dqw-auto-hint { font-size: 10px; line-height: 14px; padding-left: 18px; color: var(--dsw-alias-label-warning, #d29922); }
496
+ .dqw-auto-label { opacity: .75; white-space: nowrap; }
497
+ .dqw-auto-dot { justify-self: center; opacity: .75; }
498
+ .dqw-auto-used { opacity: .75; min-width: 0; }
499
+ .dqw-auto-hint { grid-column: 1 / -1; font-size: 10px; line-height: 14px; padding-left: 18px; color: var(--dsw-alias-label-warning, #d29922); }
492
500
  .dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
493
501
  `;
494
502
  function localeFor(win) {
@@ -856,6 +864,7 @@ function mountQuotaCard({
856
864
  let queuedPlace = false;
857
865
  let openKey;
858
866
  let panelNav = false;
867
+ let knownResetCards;
859
868
  let resetInfo;
860
869
  let resetLoading = false;
861
870
  let resetPending;
@@ -917,7 +926,7 @@ function mountQuotaCard({
917
926
  row.append(text(doc, "span", "dqw-chev", openKey === key ? "\u25BE" : "\u25B8"));
918
927
  return [row];
919
928
  };
920
- const panelHeader = ({ title, showBack = false }) => {
929
+ const panelHeader = ({ title, showBack = false, showRefresh = false }) => {
921
930
  const header = doc.createElement("div");
922
931
  header.className = "dqw-panel-header";
923
932
  if (showBack) {
@@ -931,6 +940,15 @@ function mountQuotaCard({
931
940
  header.append(back);
932
941
  }
933
942
  header.append(text(doc, "p", "dqw-panel-title", title));
943
+ if (showRefresh) {
944
+ const refresh = doc.createElement("button");
945
+ refresh.type = "button";
946
+ refresh.className = "dqw-panel-refresh";
947
+ refresh.dataset.action = "panel-refresh";
948
+ refresh.textContent = copy.actions.refreshNow;
949
+ refresh.setAttribute("aria-label", copy.actions.refreshNow);
950
+ header.append(refresh);
951
+ }
934
952
  const close = doc.createElement("button");
935
953
  close.type = "button";
936
954
  close.className = "dqw-panel-close";
@@ -992,6 +1010,7 @@ function mountQuotaCard({
992
1010
  try {
993
1011
  const payload = await fetchResetEnvelope(fetchImpl, CLIENT_ROUTES.resetCards);
994
1012
  resetInfo = payload;
1013
+ if (payload.ok === true && payload.cards !== void 0) knownResetCards = payload.cards;
995
1014
  } catch {
996
1015
  resetInfo = { ok: false, error: "request-failed" };
997
1016
  } finally {
@@ -1010,6 +1029,7 @@ function mountQuotaCard({
1010
1029
  if (payload.ok === true) {
1011
1030
  resetNotice = { kind: "ok", message: copy.resetCards.succeeded };
1012
1031
  resetInfo = payload.cards !== void 0 ? { ok: true, cards: payload.cards } : resetInfo;
1032
+ if (payload.cards !== void 0) knownResetCards = payload.cards;
1013
1033
  } else {
1014
1034
  resetNotice = { kind: "error", message: payload.message ?? copy.resetCards.failed };
1015
1035
  }
@@ -1101,12 +1121,13 @@ function mountQuotaCard({
1101
1121
  void saveAutoReset(configKey, box.checked);
1102
1122
  });
1103
1123
  const threshold = typeof entry?.threshold === "number" ? entry.threshold : AUTO_RESET_THRESHOLD_FALLBACK;
1104
- row.append(box, text(
1105
- doc,
1106
- "span",
1107
- "dqw-auto-label",
1108
- (windowKey === "5h" ? copy.resetCards.autoFiveHour : copy.resetCards.autoWeek).replace("{threshold}", formatNumber(threshold, locale))
1109
- ));
1124
+ const cards = copy.resetCards;
1125
+ row.append(
1126
+ box,
1127
+ text(doc, "span", "dqw-auto-label", windowKey === "5h" ? cards.autoFiveHour : cards.autoWeek),
1128
+ text(doc, "span", "dqw-auto-dot", "\xB7"),
1129
+ text(doc, "span", "dqw-auto-used", cards.autoUsed.replace("{threshold}", formatNumber(threshold, locale)))
1130
+ );
1110
1131
  wrap.append(row);
1111
1132
  const provider = (snapshot?.providers ?? []).find((item) => item?.key === "glm");
1112
1133
  const used = (provider?.plan?.windows ?? []).find((window) => window?.key === windowKey)?.percent;
@@ -1120,51 +1141,60 @@ function mountQuotaCard({
1120
1141
  }
1121
1142
  return wrap;
1122
1143
  }
1144
+ function resetCardsKnownEmpty() {
1145
+ if (knownResetCards === void 0) return false;
1146
+ return Array.isArray(knownResetCards.fiveHour) && Array.isArray(knownResetCards.week) && knownResetCards.fiveHour.length === 0 && knownResetCards.week.length === 0;
1147
+ }
1148
+ function resetCollapsed() {
1149
+ if (!resetCardsKnownEmpty() || autoOpen || resetPending !== void 0 || resetBusy !== void 0 || resetNotice !== void 0 || autoSaveError !== void 0) return false;
1150
+ const auto = snapshot?.autoReset;
1151
+ if (auto?.fiveHour?.enabled === true || auto?.week?.enabled === true || auto?.persistError !== void 0 || auto?.lastEvent !== void 0) return false;
1152
+ return true;
1153
+ }
1154
+ function buildAutoMaster(auto) {
1155
+ if (auto === void 0 || typeof auto !== "object") return void 0;
1156
+ const master = doc.createElement("label");
1157
+ master.className = "dqw-auto-master";
1158
+ const masterBox = doc.createElement("input");
1159
+ masterBox.type = "checkbox";
1160
+ masterBox.className = "dqw-auto-box";
1161
+ masterBox.checked = autoOpen === true;
1162
+ masterBox.disabled = autoSaving === true;
1163
+ masterBox.addEventListener("change", () => {
1164
+ autoOpen = masterBox.checked;
1165
+ saveAutoOpen(win.localStorage, autoOpen);
1166
+ syncPop();
1167
+ });
1168
+ const anyEnabled = auto.fiveHour?.enabled === true || auto.week?.enabled === true;
1169
+ master.append(text(
1170
+ doc,
1171
+ "span",
1172
+ "dqw-reset-title",
1173
+ anyEnabled ? copy.resetCards.autoActive : copy.resetCards.autoToggle
1174
+ ), masterBox);
1175
+ return master;
1176
+ }
1123
1177
  function renderResetSection() {
1124
1178
  const section = doc.createElement("div");
1125
1179
  section.className = "dqw-reset";
1126
- const head = doc.createElement("div");
1127
- head.className = "dqw-reset-head";
1128
- const headLeft = doc.createElement("div");
1129
- headLeft.className = `dqw-reset-head-left${locale === "en" ? " is-en" : ""}`;
1130
- headLeft.append(text(doc, "span", "dqw-reset-title", copy.resetCards.section));
1131
- const reload = doc.createElement("button");
1132
- reload.type = "button";
1133
- reload.className = "dqw-reset-reload";
1134
- reload.textContent = resetLoading ? copy.loading : copy.resetCards.reloadLink;
1135
- reload.disabled = resetLoading || resetBusy !== void 0;
1136
- reload.addEventListener("click", () => {
1137
- loadResetCards();
1138
- });
1139
- headLeft.append(reload);
1140
- head.append(headLeft);
1141
1180
  const autoHeader = snapshot?.autoReset;
1142
- if (autoHeader !== void 0 && typeof autoHeader === "object") {
1143
- const master = doc.createElement("label");
1144
- master.className = "dqw-auto-master";
1145
- const masterBox = doc.createElement("input");
1146
- masterBox.type = "checkbox";
1147
- masterBox.className = "dqw-auto-box";
1148
- masterBox.checked = autoOpen === true;
1149
- masterBox.disabled = autoSaving === true;
1150
- masterBox.addEventListener("change", () => {
1151
- autoOpen = masterBox.checked;
1152
- saveAutoOpen(win.localStorage, autoOpen);
1153
- syncPop();
1154
- });
1155
- const anyEnabled = autoHeader.fiveHour?.enabled === true || autoHeader.week?.enabled === true;
1156
- master.append(text(
1157
- doc,
1158
- "span",
1159
- "dqw-reset-title",
1160
- anyEnabled ? copy.resetCards.autoActive : copy.resetCards.autoToggle
1161
- ), masterBox);
1162
- head.append(master);
1181
+ const master = buildAutoMaster(autoHeader);
1182
+ if (resetCollapsed()) {
1183
+ const row = doc.createElement("div");
1184
+ row.className = "dqw-reset-collapsed";
1185
+ row.append(text(doc, "span", "dqw-reset-title", `${copy.resetCards.section} \xB7 ${copy.resetCards.none}`));
1186
+ if (master !== void 0) row.append(master);
1187
+ section.append(row);
1188
+ return section;
1163
1189
  }
1190
+ const head = doc.createElement("div");
1191
+ head.className = "dqw-reset-head";
1192
+ head.append(text(doc, "span", "dqw-reset-title", copy.resetCards.section));
1193
+ if (master !== void 0) head.append(master);
1164
1194
  section.append(head);
1165
- const cards = resetInfo?.ok === true && resetInfo.cards !== void 0 ? resetInfo.cards : void 0;
1195
+ const cards = knownResetCards;
1166
1196
  if (cards === void 0) {
1167
- const message = resetLoading ? copy.loading : resetInfo?.message ?? (resetInfo === void 0 ? copy.loading : copy.resetCards.loadFailed);
1197
+ const message = resetLoading ? copy.loading : resetInfo?.message ?? snapshot?.reset?.error ?? (resetInfo === void 0 ? copy.loading : copy.resetCards.loadFailed);
1168
1198
  section.append(text(doc, "p", "dqw-muted", message));
1169
1199
  return section;
1170
1200
  }
@@ -1177,8 +1207,13 @@ function mountQuotaCard({
1177
1207
  const auto = snapshot?.autoReset;
1178
1208
  if (auto !== void 0 && typeof auto === "object") {
1179
1209
  if (autoOpen === true) {
1180
- section.append(renderAutoResetRow("5h", "fiveHour", auto.fiveHour, auto));
1181
- section.append(renderAutoResetRow("week", "week", auto.week, auto));
1210
+ const grid = doc.createElement("div");
1211
+ grid.className = "dqw-auto-grid";
1212
+ grid.append(
1213
+ renderAutoResetRow("5h", "fiveHour", auto.fiveHour, auto),
1214
+ renderAutoResetRow("week", "week", auto.week, auto)
1215
+ );
1216
+ section.append(grid);
1182
1217
  }
1183
1218
  const last = auto.lastEvent;
1184
1219
  if (last?.kind === "auto") {
@@ -1234,7 +1269,7 @@ function mountQuotaCard({
1234
1269
  }
1235
1270
  function renderDetail(detail, key, provider, fromPanelNav = false) {
1236
1271
  detail.replaceChildren();
1237
- detail.append(panelHeader({ title: copy.providerNames[key], showBack: fromPanelNav }));
1272
+ detail.append(panelHeader({ title: copy.providerNames[key], showBack: fromPanelNav, showRefresh: key === "glm" }));
1238
1273
  if (provider?.status === "stale") {
1239
1274
  const bannerPrefix = provider.errorKind === "auth" ? copy.authRejected : copy.stale;
1240
1275
  detail.append(text(doc, "p", "dqw-error", `${bannerPrefix}${provider.error ? `: ${provider.error}` : ""}`));
@@ -1310,7 +1345,7 @@ function mountQuotaCard({
1310
1345
  }
1311
1346
  if (provider?.credential === "configured" && provider?.providerId === PROVIDER_IDS.glmDomestic) {
1312
1347
  detail.append(renderResetSection());
1313
- if (resetInfo === void 0 && !resetLoading) {
1348
+ if (resetInfo === void 0 && knownResetCards === void 0 && !resetLoading) {
1314
1349
  void loadResetCards();
1315
1350
  }
1316
1351
  }
@@ -1491,6 +1526,8 @@ function mountQuotaCard({
1491
1526
  };
1492
1527
  const render = (next) => {
1493
1528
  snapshot = next;
1529
+ const cards = next?.reset?.status === "ready" ? next.reset.cards : void 0;
1530
+ if (Array.isArray(cards?.fiveHour) && Array.isArray(cards?.week)) knownResetCards = cards;
1494
1531
  renderAll();
1495
1532
  };
1496
1533
  const showTransportError = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
5
5
  "type": "module",
6
6
  "main": "./src/host.mjs",
package/src/client.mjs CHANGED
@@ -66,9 +66,8 @@ const COPY = {
66
66
  count: '{count} 张',
67
67
  none: '无可用',
68
68
  use: '重置',
69
- reloadLink: '(刷新)',
70
69
  autoToggle: '自动用卡',
71
- autoActive: '自动用卡·已启用',
70
+ autoActive: '自动用卡 · 已启用',
72
71
  expiry: '{date} 前有效',
73
72
  confirmTitle: '确认使用重置卡?',
74
73
  confirmWeek: '将消耗 1 张每周重置卡,立即重置每周额度与 5 小时额度。',
@@ -80,8 +79,11 @@ const COPY = {
80
79
  succeeded: '已重置',
81
80
  failed: '重置失败',
82
81
  loadFailed: '读取重置卡失败',
83
- autoFiveHour: '自动用卡(5 小时)· 已用 {threshold}% 触发',
84
- autoWeek: '自动用卡(每周)· 已用 {threshold}% 触发',
82
+ // Auto rows render as three grid cells — window label, a centered ·
83
+ // separator, and the used note — so the · column lines up across rows.
84
+ autoFiveHour: '自动用卡(5 小时)',
85
+ autoWeek: '自动用卡(每周)',
86
+ autoUsed: '已用 {threshold}% 触发',
85
87
  autoImmediate: '当前已用 {used},已超阈值:开启后会立即消耗一张卡',
86
88
  autoSaveFailed: '开关未能保存,请重试',
87
89
  autoPersistFailed: '开关已生效,但未能写入磁盘(重启后会丢失)',
@@ -135,7 +137,6 @@ const COPY = {
135
137
  count: '{count}',
136
138
  none: 'none',
137
139
  use: 'Reset',
138
- reloadLink: '(Refresh)',
139
140
  autoToggle: 'Auto-use',
140
141
  autoActive: 'Auto-use · on',
141
142
  expiry: 'expires {date}',
@@ -149,8 +150,10 @@ const COPY = {
149
150
  succeeded: 'Reset done',
150
151
  failed: 'Reset failed',
151
152
  loadFailed: 'Could not read reset cards',
152
- autoFiveHour: 'Auto-use (5h) · trigger at {threshold}% used',
153
- autoWeek: 'Auto-use (weekly) · trigger at {threshold}% used',
153
+ // Same three-cell grid as zh: label · used note.
154
+ autoFiveHour: 'Auto-use (5h)',
155
+ autoWeek: 'Auto-use (weekly)',
156
+ autoUsed: 'trigger at {threshold}% used',
154
157
  autoImmediate: 'Currently {used} used — already past the threshold: turning this on spends a card immediately',
155
158
  autoSaveFailed: 'Could not save the switch — retry',
156
159
  autoPersistFailed: 'Switch applied, but it could not be written to disk (lost on restart)',
@@ -229,6 +232,8 @@ const FLOAT_STYLE_TEXT = `
229
232
  .dqw-panel-back { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; margin: 0; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer; }
230
233
  .dqw-panel-back svg { display: block; width: 16px; height: 16px; }
231
234
  .dqw-panel-back:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
235
+ .dqw-panel-refresh { flex: none; margin: 0; padding: 4px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer; font: inherit; font-size: 10px; line-height: 14px; opacity: .72; }
236
+ .dqw-panel-refresh:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); opacity: 1; }
232
237
  .dqw-panel-close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 24px; height: 24px; margin: 0; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer; font: inherit; font-size: 12px; line-height: 1; }
233
238
  .dqw-panel-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
234
239
  .dqw-overview-list { display: flex; flex-direction: column; gap: 2px; }
@@ -268,16 +273,11 @@ const FLOAT_STYLE_TEXT = `
268
273
  .dqw-win-mid { opacity: .65; white-space: nowrap; }
269
274
  .dqw-win-used { opacity: .65; white-space: nowrap; }
270
275
  .dqw-win-val { min-width: 0; font-weight: 600; }
271
- /* Reset cards: a bordered block below the window grid. Every control is a real
272
- <button> so keyboard focus and the disabled state come from the platform. */
276
+ /* Reset cards: keep the dashed divider even when an empty wallet collapses to
277
+ one row. The panel's 立即刷新 is the only manual re-check control. */
273
278
  .dqw-reset { border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 4px; }
274
- .dqw-reset-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 10px; line-height: 14px; }
279
+ .dqw-reset-head, .dqw-reset-collapsed { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 10px; line-height: 14px; }
275
280
  .dqw-reset-title { opacity: .65; }
276
- .dqw-reset-reload { border: 0; background: none; padding: 0; font: inherit; font-size: 10px; color: var(--dsw-alias-label-secondary, inherit); opacity: .65; cursor: pointer; text-decoration: underline; }
277
- .dqw-reset-reload:hover { opacity: 1; }
278
- .dqw-reset-reload:disabled { cursor: default; opacity: .35; text-decoration: none; }
279
- .dqw-reset-head-left { display: flex; align-items: baseline; min-width: 0; }
280
- .dqw-reset-head-left.is-en { gap: 4px; }
281
281
  .dqw-auto-master { display: flex; align-items: center; gap: 4px; cursor: pointer; font-size: 10px; line-height: 14px; }
282
282
  .dqw-auto-master .dqw-auto-box { accent-color: var(--dsw-alias-button-primary-fill, #5b8def); }
283
283
  .dqw-reset-lines { display: grid; grid-template-columns: max-content max-content minmax(0, 1fr) max-content max-content; column-gap: 6px; row-gap: 4px; align-items: center; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
@@ -306,13 +306,21 @@ const FLOAT_STYLE_TEXT = `
306
306
  .dqw-reset-notice.is-error { color: var(--dsw-alias-label-danger, #c93c3c); }
307
307
  .dqw-reset-auto { font-size: 10px; line-height: 14px; opacity: .6; }
308
308
  /* Auto-use switches: a real checkbox per window, backed by a host-side file so
309
- automatic spending keeps working while no browser is open. */
310
- .dqw-auto { display: flex; flex-direction: column; gap: 1px; }
311
- .dqw-auto-row { display: flex; align-items: center; gap: 6px; font-size: 10.5px; line-height: 16px; cursor: pointer; }
309
+ automatic spending keeps working while no browser is open. Both rows share one
310
+ grid (same recipe as .dqw-reset-lines): the window label stays left-aligned,
311
+ the · sits in its own narrow centered column so the two rows line up, and the
312
+ used note follows after the column gap. display:contents keeps the <label>
313
+ wrapping its checkbox for click-to-toggle while the cells join the grid. */
314
+ .dqw-auto-grid { display: grid; grid-template-columns: max-content max-content 12px minmax(0, 1fr); column-gap: 6px; row-gap: 2px; align-items: center; font-size: 10.5px; line-height: 16px; }
315
+ .dqw-auto { display: contents; }
316
+ .dqw-auto-row { display: contents; }
317
+ .dqw-auto-grid label > :is(input, span) { cursor: pointer; }
312
318
  .dqw-auto-box { width: 12px; height: 12px; margin: 0; flex: none; cursor: pointer; accent-color: var(--dsw-alias-button-primary-fill, #5b8def); }
313
319
  .dqw-auto-box:disabled { cursor: default; }
314
- .dqw-auto-label { opacity: .75; }
315
- .dqw-auto-hint { font-size: 10px; line-height: 14px; padding-left: 18px; color: var(--dsw-alias-label-warning, #d29922); }
320
+ .dqw-auto-label { opacity: .75; white-space: nowrap; }
321
+ .dqw-auto-dot { justify-self: center; opacity: .75; }
322
+ .dqw-auto-used { opacity: .75; min-width: 0; }
323
+ .dqw-auto-hint { grid-column: 1 / -1; font-size: 10px; line-height: 14px; padding-left: 18px; color: var(--dsw-alias-label-warning, #d29922); }
316
324
  .dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
317
325
  `
318
326
 
@@ -802,9 +810,11 @@ export function mountQuotaCard({
802
810
  let queuedPlace = false
803
811
  let openKey
804
812
  let panelNav = false
805
- // Reset-card state is client-local: the card list is fetched only while the GLM
806
- // detail is open, so a closed panel never costs an extra upstream round trip.
807
- let resetInfo // last listResetCards payload (or a synthesized error)
813
+ // The host now refreshes the wallet on a slow independent clock; the detail
814
+ // still fetches on first open. Keep the last authoritative list through a
815
+ // transient list failure so the section never vanishes or invents zero cards.
816
+ let knownResetCards
817
+ let resetInfo // last on-demand listResetCards payload (or an error)
808
818
  let resetLoading = false
809
819
  let resetPending // reset type awaiting confirmation, or undefined
810
820
  let resetBusy // reset type currently being spent, or undefined
@@ -875,8 +885,8 @@ export function mountQuotaCard({
875
885
  return [row]
876
886
  }
877
887
 
878
- /** Panel chrome: optional back, title, always a close affordance. */
879
- const panelHeader = ({ title, showBack = false }) => {
888
+ /** Panel chrome: optional back/refresh, title and close affordance. */
889
+ const panelHeader = ({ title, showBack = false, showRefresh = false }) => {
880
890
  const header = doc.createElement('div')
881
891
  header.className = 'dqw-panel-header'
882
892
  if (showBack) {
@@ -890,6 +900,15 @@ export function mountQuotaCard({
890
900
  header.append(back)
891
901
  }
892
902
  header.append(text(doc, 'p', 'dqw-panel-title', title))
903
+ if (showRefresh) {
904
+ const refresh = doc.createElement('button')
905
+ refresh.type = 'button'
906
+ refresh.className = 'dqw-panel-refresh'
907
+ refresh.dataset.action = 'panel-refresh'
908
+ refresh.textContent = copy.actions.refreshNow
909
+ refresh.setAttribute('aria-label', copy.actions.refreshNow)
910
+ header.append(refresh)
911
+ }
893
912
  const close = doc.createElement('button')
894
913
  close.type = 'button'
895
914
  close.className = 'dqw-panel-close'
@@ -956,11 +975,8 @@ export function mountQuotaCard({
956
975
  panel.append(overviewActions())
957
976
  }
958
977
 
959
- /**
960
- * Fetch the reset-card list and repaint the open panel.
961
- * Called when the GLM detail opens and after a spend; never on the poller, so a
962
- * closed panel costs nothing upstream.
963
- */
978
+ /** Fetch a live card list for the detail or after spending, independently of
979
+ * the host's ten-minute background snapshot and its force-refresh route. */
964
980
  async function loadResetCards() {
965
981
  if (resetLoading || disposed) return
966
982
  resetLoading = true
@@ -968,6 +984,7 @@ export function mountQuotaCard({
968
984
  try {
969
985
  const payload = await fetchResetEnvelope(fetchImpl, CLIENT_ROUTES.resetCards)
970
986
  resetInfo = payload
987
+ if (payload.ok === true && payload.cards !== undefined) knownResetCards = payload.cards
971
988
  } catch {
972
989
  resetInfo = { ok: false, error: 'request-failed' }
973
990
  } finally {
@@ -995,6 +1012,7 @@ export function mountQuotaCard({
995
1012
  // The host returns the post-reset card list; fall back to a re-list when it
996
1013
  // could not refresh, so the panel never offers a card that is already gone.
997
1014
  resetInfo = payload.cards !== undefined ? { ok: true, cards: payload.cards } : resetInfo
1015
+ if (payload.cards !== undefined) knownResetCards = payload.cards
998
1016
  } else {
999
1017
  resetNotice = { kind: 'error', message: payload.message ?? copy.resetCards.failed }
1000
1018
  }
@@ -1100,9 +1118,13 @@ export function mountQuotaCard({
1100
1118
  box.disabled = autoSaving === true
1101
1119
  box.addEventListener('change', () => { void saveAutoReset(configKey, box.checked) })
1102
1120
  const threshold = typeof entry?.threshold === 'number' ? entry.threshold : AUTO_RESET_THRESHOLD_FALLBACK
1103
- row.append(box, text(doc, 'span', 'dqw-auto-label',
1104
- (windowKey === '5h' ? copy.resetCards.autoFiveHour : copy.resetCards.autoWeek)
1105
- .replace('{threshold}', formatNumber(threshold, locale))))
1121
+ // Structured cells — window label (left), · (centered column, aligned across
1122
+ // rows), used note — joined by the shared .dqw-auto-grid column gap.
1123
+ const cards = copy.resetCards
1124
+ row.append(box,
1125
+ text(doc, 'span', 'dqw-auto-label', windowKey === '5h' ? cards.autoFiveHour : cards.autoWeek),
1126
+ text(doc, 'span', 'dqw-auto-dot', '·'),
1127
+ text(doc, 'span', 'dqw-auto-used', cards.autoUsed.replace('{threshold}', formatNumber(threshold, locale))))
1106
1128
  wrap.append(row)
1107
1129
 
1108
1130
  // Usage is already past the threshold: turning this on spends a card at the
@@ -1116,57 +1138,74 @@ export function mountQuotaCard({
1116
1138
  return wrap
1117
1139
  }
1118
1140
 
1141
+ /** Only an authoritative successful list can mean "no cards". Errors retain
1142
+ * the last good inventory, preventing a transient upstream failure from hiding
1143
+ * controls or claiming that a still-available card is gone. */
1144
+ function resetCardsKnownEmpty() {
1145
+ if (knownResetCards === undefined) return false
1146
+ return Array.isArray(knownResetCards.fiveHour) && Array.isArray(knownResetCards.week)
1147
+ && knownResetCards.fiveHour.length === 0 && knownResetCards.week.length === 0
1148
+ }
1149
+
1150
+ function resetCollapsed() {
1151
+ if (!resetCardsKnownEmpty() || autoOpen || resetPending !== undefined
1152
+ || resetBusy !== undefined || resetNotice !== undefined || autoSaveError !== undefined) return false
1153
+ const auto = snapshot?.autoReset
1154
+ if (auto?.fiveHour?.enabled === true || auto?.week?.enabled === true
1155
+ || auto?.persistError !== undefined || auto?.lastEvent !== undefined) return false
1156
+ return true
1157
+ }
1158
+
1159
+ /** This checkbox only reveals the real per-window switches; keep it reachable
1160
+ * on the single-line empty state so an account without cards can still pre-arm. */
1161
+ function buildAutoMaster(auto) {
1162
+ if (auto === undefined || typeof auto !== 'object') return undefined
1163
+ const master = doc.createElement('label')
1164
+ master.className = 'dqw-auto-master'
1165
+ const masterBox = doc.createElement('input')
1166
+ masterBox.type = 'checkbox'
1167
+ masterBox.className = 'dqw-auto-box'
1168
+ masterBox.checked = autoOpen === true
1169
+ masterBox.disabled = autoSaving === true
1170
+ masterBox.addEventListener('change', () => {
1171
+ autoOpen = masterBox.checked
1172
+ saveAutoOpen(win.localStorage, autoOpen)
1173
+ syncPop()
1174
+ })
1175
+ // An unchecked disclosure must never imply the host's auto-use switch is off.
1176
+ const anyEnabled = auto.fiveHour?.enabled === true || auto.week?.enabled === true
1177
+ master.append(text(doc, 'span', 'dqw-reset-title',
1178
+ anyEnabled ? copy.resetCards.autoActive : copy.resetCards.autoToggle), masterBox)
1179
+ return master
1180
+ }
1181
+
1119
1182
  /** The reset block: counts, per-type reset buttons, inline confirm and last result. */
1120
1183
  function renderResetSection() {
1121
1184
  const section = doc.createElement('div')
1122
1185
  section.className = 'dqw-reset'
1123
- const head = doc.createElement('div')
1124
- head.className = 'dqw-reset-head'
1125
- // Left: the title carries the manual reload affordance —「额度重置卡(刷新)」.
1126
- const headLeft = doc.createElement('div')
1127
- headLeft.className = `dqw-reset-head-left${locale === 'en' ? ' is-en' : ''}`
1128
- headLeft.append(text(doc, 'span', 'dqw-reset-title', copy.resetCards.section))
1129
- const reload = doc.createElement('button')
1130
- reload.type = 'button'
1131
- reload.className = 'dqw-reset-reload'
1132
- reload.textContent = resetLoading ? copy.loading : copy.resetCards.reloadLink
1133
- reload.disabled = resetLoading || resetBusy !== undefined
1134
- reload.addEventListener('click', () => { loadResetCards() })
1135
- headLeft.append(reload)
1136
- head.append(headLeft)
1137
- // Right: a pure visibility toggle. Checking it only reveals the two auto-use
1138
- // rows below; actually enabling a window stays with its own per-window switch,
1139
- // so the default panel is three lines and no single click can arm auto-spend.
1140
1186
  const autoHeader = snapshot?.autoReset
1141
- if (autoHeader !== undefined && typeof autoHeader === 'object') {
1142
- const master = doc.createElement('label')
1143
- master.className = 'dqw-auto-master'
1144
- const masterBox = doc.createElement('input')
1145
- masterBox.type = 'checkbox'
1146
- masterBox.className = 'dqw-auto-box'
1147
- masterBox.checked = autoOpen === true
1148
- masterBox.disabled = autoSaving === true
1149
- masterBox.addEventListener('change', () => {
1150
- autoOpen = masterBox.checked
1151
- saveAutoOpen(win.localStorage, autoOpen)
1152
- syncPop()
1153
- })
1154
- // Expansion is not authorization: disclose the persisted host switch state
1155
- // even when these rows are collapsed, so an unchecked box cannot imply off.
1156
- const anyEnabled = autoHeader.fiveHour?.enabled === true || autoHeader.week?.enabled === true
1157
- master.append(text(doc, 'span', 'dqw-reset-title',
1158
- anyEnabled ? copy.resetCards.autoActive : copy.resetCards.autoToggle), masterBox)
1159
- head.append(master)
1187
+ const master = buildAutoMaster(autoHeader)
1188
+ if (resetCollapsed()) {
1189
+ const row = doc.createElement('div')
1190
+ row.className = 'dqw-reset-collapsed'
1191
+ row.append(text(doc, 'span', 'dqw-reset-title', `${copy.resetCards.section} · ${copy.resetCards.none}`))
1192
+ if (master !== undefined) row.append(master)
1193
+ section.append(row)
1194
+ return section
1160
1195
  }
1196
+ const head = doc.createElement('div')
1197
+ head.className = 'dqw-reset-head'
1198
+ head.append(text(doc, 'span', 'dqw-reset-title', copy.resetCards.section))
1199
+ if (master !== undefined) head.append(master)
1161
1200
  section.append(head)
1162
1201
 
1163
- const cards = resetInfo?.ok === true && resetInfo.cards !== undefined ? resetInfo.cards : undefined
1202
+ const cards = knownResetCards
1164
1203
  if (cards === undefined) {
1165
1204
  // Nothing fetched yet, a failed fetch, or no GLM credential on the host: say so
1166
1205
  // instead of drawing two rows of zeroes that look like "no cards left".
1167
1206
  const message = resetLoading
1168
1207
  ? copy.loading
1169
- : resetInfo?.message ?? (resetInfo === undefined ? copy.loading : copy.resetCards.loadFailed)
1208
+ : resetInfo?.message ?? snapshot?.reset?.error ?? (resetInfo === undefined ? copy.loading : copy.resetCards.loadFailed)
1170
1209
  section.append(text(doc, 'p', 'dqw-muted', message))
1171
1210
  return section
1172
1211
  }
@@ -1185,9 +1224,13 @@ export function mountQuotaCard({
1185
1224
  if (auto !== undefined && typeof auto === 'object') {
1186
1225
  // The two auto-use rows stay collapsed behind the header toggle; the event
1187
1226
  // notices below still render while collapsed so auto activity stays visible.
1227
+ // One shared grid is what keeps the · column aligned across both rows.
1188
1228
  if (autoOpen === true) {
1189
- section.append(renderAutoResetRow('5h', 'fiveHour', auto.fiveHour, auto))
1190
- section.append(renderAutoResetRow('week', 'week', auto.week, auto))
1229
+ const grid = doc.createElement('div')
1230
+ grid.className = 'dqw-auto-grid'
1231
+ grid.append(renderAutoResetRow('5h', 'fiveHour', auto.fiveHour, auto),
1232
+ renderAutoResetRow('week', 'week', auto.week, auto))
1233
+ section.append(grid)
1191
1234
  }
1192
1235
  const last = auto.lastEvent
1193
1236
  if (last?.kind === 'auto') {
@@ -1237,7 +1280,7 @@ export function mountQuotaCard({
1237
1280
 
1238
1281
  function renderDetail(detail, key, provider, fromPanelNav = false) {
1239
1282
  detail.replaceChildren()
1240
- detail.append(panelHeader({ title: copy.providerNames[key], showBack: fromPanelNav }))
1283
+ detail.append(panelHeader({ title: copy.providerNames[key], showBack: fromPanelNav, showRefresh: key === 'glm' }))
1241
1284
  if (provider?.status === 'stale') {
1242
1285
  const bannerPrefix = provider.errorKind === 'auth' ? copy.authRejected : copy.stale
1243
1286
  detail.append(text(doc, 'p', 'dqw-error', `${bannerPrefix}${provider.error ? `: ${provider.error}` : ''}`))
@@ -1320,10 +1363,9 @@ export function mountQuotaCard({
1320
1363
  // international (z.ai) provider would only ever render a permanent failure.
1321
1364
  if (provider?.credential === 'configured' && provider?.providerId === PROVIDER_IDS.glmDomestic) {
1322
1365
  detail.append(renderResetSection())
1323
- // Cards are fetched lazily: opening the GLM detail is the first moment the
1324
- // list is worth an upstream call, and the fetch repaints this panel when it
1325
- // lands.
1326
- if (resetInfo === undefined && !resetLoading) {
1366
+ // The host snapshot carries the full recent wallet. Fetch lazily only
1367
+ // when neither it nor a prior on-demand read has a usable card list.
1368
+ if (resetInfo === undefined && knownResetCards === undefined && !resetLoading) {
1327
1369
  void loadResetCards()
1328
1370
  }
1329
1371
  }
@@ -1540,6 +1582,8 @@ export function mountQuotaCard({
1540
1582
 
1541
1583
  const render = (next) => {
1542
1584
  snapshot = next
1585
+ const cards = next?.reset?.status === 'ready' ? next.reset.cards : undefined
1586
+ if (Array.isArray(cards?.fiveHour) && Array.isArray(cards?.week)) knownResetCards = cards
1543
1587
  renderAll()
1544
1588
  }
1545
1589
 
@@ -1657,6 +1701,7 @@ export function mountQuotaCard({
1657
1701
  // sit inside this shadow tree, so a panel-level listener sees the real
1658
1702
  // target with no retargeting ambiguity.
1659
1703
  if (event.target?.closest?.('[data-action="panel-refresh"]')) {
1704
+ // The host force-refresh re-lists cards too; do not issue a duplicate GET.
1660
1705
  void poll(true)
1661
1706
  return
1662
1707
  }
package/src/host.mjs CHANGED
@@ -516,13 +516,24 @@ export function createQuotaService(ctx, options = {}) {
516
516
  // same window is retried, so a persistent error cannot hammer
517
517
  // the upstream every poll.
518
518
 
519
- function refresh() {
520
- if (pollPromise !== undefined) return pollPromise
519
+ function refresh(forceCards = false) {
520
+ if (pollPromise !== undefined) {
521
+ // A manual refresh must still re-list cards when it overlaps a scheduled
522
+ // round whose ten-minute gate skipped the list.
523
+ if (forceCards !== true) return pollPromise
524
+ return pollPromise.then(async () => {
525
+ if (!disposed) await probeResetCards(true)
526
+ return view()
527
+ })
528
+ }
521
529
  if (disposed) return Promise.resolve(view())
522
530
  pollPromise = Promise.all([probeGlm(), probeCopilot()])
523
531
  .then(async () => {
524
532
  updatedAt = now()
533
+ // Spending gets priority over the read-only background list: otherwise
534
+ // two wallet reads could race and a manual spend could slip between them.
525
535
  await maybeAutoReset()
536
+ await probeResetCards(forceCards === true)
526
537
  return view()
527
538
  })
528
539
  .finally(() => { pollPromise = undefined })
@@ -530,11 +541,31 @@ export function createQuotaService(ctx, options = {}) {
530
541
  }
531
542
 
532
543
  // --- Reset cards -----------------------------------------------------------
533
- // Card state is kept apart from the quota snapshot: a card list is only fetched
534
- // on demand (opening the panel, or right before spending one), and a failed fetch
535
- // must never blank the provider rows the poller maintains.
544
+ // Card state is independent of the quota probes: poll it on its own slow
545
+ // clock, as well as on demand and before spending. A failed list never blanks
546
+ // the provider rows or turns a known card count into an authoritative zero.
536
547
  let resetState
537
548
  let resetSpendInFlight = false
549
+ let resetProbePromise
550
+ const RESET_CARDS_POLL_INTERVAL_MS = 10 * 60_000
551
+
552
+ async function probeResetCards(force = false) {
553
+ if (configuredGlmProvider(ctx) !== PROVIDER_IDS.glmDomestic || resetSpendInFlight) return
554
+ if (resetProbePromise !== undefined) return resetProbePromise
555
+ if (!force && resetState?.checkedAt !== undefined
556
+ && now() - resetState.checkedAt < RESET_CARDS_POLL_INTERVAL_MS) return
557
+ const probe = (async () => {
558
+ try {
559
+ await listResetCards()
560
+ } catch (error) {
561
+ // Even unexpected credential failures cannot break the quota poll round.
562
+ const { message, kind } = classifyProbeError(error)
563
+ resetState = { status: 'error', error: message, errorKind: kind, checkedAt: now() }
564
+ }
565
+ })()
566
+ resetProbePromise = probe
567
+ try { await probe } finally { resetProbePromise = undefined }
568
+ }
538
569
 
539
570
  /** Resolve the GLM provider + key, or report why no reset can be attempted. */
540
571
  async function resetCredential() {
@@ -548,11 +579,8 @@ export function createQuotaService(ctx, options = {}) {
548
579
  return { provider, apiKey }
549
580
  }
550
581
 
551
- /**
552
- * Fetch the available reset cards.
553
- * `force` is advisory only — the endpoint is cheap and the panel opens rarely, so
554
- * every call re-reads upstream rather than serving a stale count before a spend.
555
- */
582
+ /** Fetch the live available cards. The poll clock gates only the background
583
+ * probe; an explicit list or spend always re-reads upstream. */
556
584
  async function listResetCards() {
557
585
  const credential = await resetCredential()
558
586
  if (credential.error !== undefined) {
@@ -584,6 +612,9 @@ export function createQuotaService(ctx, options = {}) {
584
612
  if (resetSpendInFlight) return { ok: false, error: 'busy', message: '重置操作进行中,请稍后重试' }
585
613
  resetSpendInFlight = true
586
614
  try {
615
+ // A spend re-lists against live state; wait for any cosmetic background
616
+ // read first so its older result cannot overwrite the post-spend wallet.
617
+ if (resetProbePromise !== undefined) await resetProbePromise
587
618
  return await performResetCard(args, autoWindowKey)
588
619
  } finally {
589
620
  resetSpendInFlight = false
@@ -842,7 +873,7 @@ export function makeRefreshRoute(ctx, service) {
842
873
  handler: async (req, res) => {
843
874
  if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'POST')) return
844
875
  try {
845
- await service.refresh()
876
+ await service.refresh(true)
846
877
  responseJson(res, 200, service.overview())
847
878
  } catch {
848
879
  responseJson(res, 500, { ok: false, error: 'refresh failed' })