balance-dsh 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +809 -156
  2. package/lib/index.js +499 -34
  3. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -118,28 +118,210 @@ const CN_CALENDAR = {
118
118
 
119
119
  // 节假日自动补录:内置表仅作**离线兜底**,运行时从社区维护的源拉取(其 papers 字段直接引用国务院公告)。
120
120
  // 源不可用/无网络/被 CSP 拦截时静默失败,继续用内置表;拉取结果按日期覆盖同键条目。
121
- const CN_CALENDAR_SOURCE = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master";
121
+ // 节假日数据源(同一份数据、多个 CDN)。实测 cdn.jsdelivr.net 在国内经常连不上,
122
+ // fastly / gcore 那两个镜像反而稳定,所以按「先快后慢、逐个回退」排序,
123
+ // 任何一个能通就够 —— 这也让插件在某个 CDN 挂掉之后仍然能自己更新。
124
+ const CN_CALENDAR_SOURCES = [
125
+ "https://fastly.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
126
+ "https://gcore.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
127
+ "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master",
128
+ "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
129
+ ];
130
+ const CN_CALENDAR_SOURCE = CN_CALENDAR_SOURCES[0]; // 兼容旧引用
131
+ const HOLIDAYS_FILENAME = "balance-holidays.json";
132
+ /** 缓存有效期:超过这个天数就在启动时后台重拉一次(用户什么都不用做)。 */
133
+ const CALENDAR_TTL_MS = 30 * 24 * 60 * 60 * 1000;
122
134
  const cnCalendarYears = new Set();
135
+ // 已同步的节假日数据。days = 「YYYY-MM-DD」→ holiday / workday,
136
+ // 落盘到 <dshHome>/balance-holidays.json —— 重启后直接用缓存,断网也不退化成内置表。
137
+ // missing = 连上了但还没有数据的年份(典型:国务院还没发次年的放假安排)。
138
+ // 这些年份**不受 30 天 TTL 约束**:缓存再新鲜也每次启动补试一次,
139
+ // 否则在「11 月发了次年公告、但上次同步刚好锁住 30 天」这个窗口里会白等一个月。
140
+ const cnCalendarState = { days: {}, fetchedAt: 0, years: [], missing: [], file: "", source: "" };
141
+
142
+ /** 节假日缓存文件路径(空串 = 定位不到 home,缓存功能静默降级为纯内存)。 */
143
+ function holidaysFilePath(ctx) {
144
+ const home = dshHomeDir(ctx);
145
+ if (home === "") return "";
146
+ const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
147
+ return home.replace(/[\\/]+$/, "") + sep + HOLIDAYS_FILENAME;
148
+ }
123
149
 
124
- /** 拉取指定年份的节假日安排并合并进 CN_CALENDAR(幂等;失败静默,不阻塞调用方)。 */
125
- async function ensureCnCalendar(year) {
126
- if (cnCalendarYears.has(year)) return;
127
- cnCalendarYears.add(year); // 先占位,避免并发重复请求
150
+ /** 拉取单年节假日并合并进 CN_CALENDAR / cnCalendarState;返回条数,失败返回 -1。
151
+ * 逐个试 CN_CALENDAR_SOURCES:某个 CDN 挂掉就换下一个,全挂才算失败。 */
152
+ async function fetchCalendarYear(year) {
153
+ for (const base of CN_CALENDAR_SOURCES) {
154
+ try {
155
+ const res = await fetch(base + "/" + year + ".json", { signal: AbortSignal.timeout(6000) });
156
+ if (!res.ok) continue;
157
+ const data = await res.json();
158
+ if (!Array.isArray(data?.days)) continue;
159
+ let count = 0;
160
+ for (const item of data.days) {
161
+ if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
162
+ const kind = item.isOffDay ? "holiday" : "workday";
163
+ CN_CALENDAR[item.date] = kind;
164
+ cnCalendarState.days[item.date] = kind;
165
+ count += 1;
166
+ }
167
+ }
168
+ cnCalendarState.source = base;
169
+ return count;
170
+ } catch {
171
+ // 这个源连不上:换下一个
172
+ }
173
+ }
174
+ return -1;
175
+ }
176
+
177
+ /** 读本地缓存(启动时调一次)。文件不存在 / 损坏一律当「没有缓存」。 */
178
+ async function loadCalendarCache(ctx) {
179
+ cnCalendarState.file = holidaysFilePath(ctx);
180
+ if (cnCalendarState.file === "") return false;
128
181
  try {
129
- const res = await fetch(CN_CALENDAR_SOURCE + "/" + year + ".json", { signal: AbortSignal.timeout(8000) });
130
- if (!res.ok) return;
131
- const data = await res.json();
132
- if (!Array.isArray(data?.days)) return;
133
- for (const item of data.days) {
134
- if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
135
- CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
182
+ const { readFile } = await import("node:fs/promises");
183
+ const parsed = JSON.parse(await readFile(cnCalendarState.file, "utf8"));
184
+ const days = parsed && typeof parsed === "object" && parsed.days && typeof parsed.days === "object" ? parsed.days : null;
185
+ if (days === null) return false;
186
+ let count = 0;
187
+ for (const [date, kind] of Object.entries(days)) {
188
+ if (typeof date === "string" && (kind === "holiday" || kind === "workday")) {
189
+ CN_CALENDAR[date] = kind;
190
+ cnCalendarState.days[date] = kind;
191
+ count += 1;
136
192
  }
137
193
  }
194
+ cnCalendarState.fetchedAt = Number(parsed.fetchedAt) || 0;
195
+ if (typeof parsed.source === "string") cnCalendarState.source = parsed.source;
196
+ cnCalendarState.years = Array.isArray(parsed.years)
197
+ ? parsed.years.map((y) => Number(y)).filter((y) => Number.isFinite(y)).sort()
198
+ : [];
199
+ cnCalendarState.missing = Array.isArray(parsed.missing)
200
+ ? parsed.missing.map((y) => Number(y)).filter((y) => Number.isFinite(y)).sort()
201
+ : [];
202
+ return count > 0;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /** 写本地缓存:先写 .tmp 再 rename,避免写一半把文件写坏。 */
209
+ async function saveCalendarCache() {
210
+ const file = cnCalendarState.file;
211
+ if (file === "") return false;
212
+ try {
213
+ const { mkdir, writeFile, rename } = await import("node:fs/promises");
214
+ const { dirname } = await import("node:path");
215
+ await mkdir(dirname(file), { recursive: true });
216
+ const tmp = file + ".tmp";
217
+ const payload = {
218
+ fetchedAt: cnCalendarState.fetchedAt,
219
+ years: cnCalendarState.years,
220
+ missing: cnCalendarState.missing,
221
+ source: cnCalendarState.source || CN_CALENDAR_SOURCE,
222
+ days: cnCalendarState.days,
223
+ };
224
+ await writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8");
225
+ await rename(tmp, file);
226
+ return true;
138
227
  } catch {
139
- // 网络不可用:保留内置表(该年调休会退化为「周末全天空闲」)
228
+ return false;
140
229
  }
141
230
  }
142
231
 
232
+ /** 当前节假日数据状态(GET /balance-api?calendar=1 直接返回它)。 */
233
+ function calendarStatus() {
234
+ return {
235
+ fetchedAt: cnCalendarState.fetchedAt,
236
+ years: cnCalendarState.years.slice(),
237
+ missing: cnCalendarState.missing.slice(),
238
+ count: Object.keys(cnCalendarState.days).length,
239
+ source: cnCalendarState.source || CN_CALENDAR_SOURCE,
240
+ days: cnCalendarState.days,
241
+ ttlDays: Math.round(CALENDAR_TTL_MS / 86400000),
242
+ };
243
+ }
244
+
245
+ /** 同步当年 ±1 年(覆盖 90 天统计窗口与跨年场景)。
246
+ * force=false 且缓存新鲜、且没有缺数据的年份时,完全跳过网络;
247
+ * 缓存新鲜但有 missing(如次年安排还没发布),只补拉那几个年份。 */
248
+ async function refreshCnCalendar(ctx, force) {
249
+ // 兜底:正常路径是 apply 里先 loadCalendarCache 把 file 填好;
250
+ // 但只要有人直接点「立即同步」而这个状态还没初始化,这里也得能落盘。
251
+ if (cnCalendarState.file === "") cnCalendarState.file = holidaysFilePath(ctx);
252
+ const fresh = cnCalendarState.fetchedAt > 0 && Date.now() - cnCalendarState.fetchedAt < CALENDAR_TTL_MS;
253
+ const stale = force || !fresh;
254
+ if (!stale && cnCalendarState.missing.length === 0) {
255
+ return { ok: true, skipped: true, ...calendarStatus() };
256
+ }
257
+ const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
258
+ // 只缺某几年 → 只补拉那几年,不要把已有数据的年份再拉一遍
259
+ const targets = stale ? [y - 1, y, y + 1] : cnCalendarState.missing.slice();
260
+ const done = [];
261
+ const failed = [];
262
+ const stillMissing = [];
263
+ let added = 0;
264
+ for (const year of targets) {
265
+ const n = await fetchCalendarYear(year);
266
+ if (n > 0) {
267
+ added += n;
268
+ done.push(year);
269
+ cnCalendarYears.add(year);
270
+ } else if (n === 0) {
271
+ // 连上了、但这一年还没数据(次年放假安排尚未公布)——不是失败,但要继续补试
272
+ stillMissing.push(year);
273
+ } else {
274
+ failed.push(year);
275
+ }
276
+ }
277
+ // 已拿到数据的年份从 missing 里摘掉;「连上了但没数据」的补进去(保持升序去重)。
278
+ // 注意「连不上」(n < 0)不算 missing —— 那是网络问题,交给 30 天 TTL 兜,
279
+ // 否则断网用户每次启动都会去拉三年、白白等超时。
280
+ //
281
+ // 下限 y-1:放假安排必然在当年之前公布,比 y-1 还早却还「缺」的年份不可能是
282
+ // 「待公布」,只可能是历史残留(例如插件停用两年后重新打开,那几年再也不会
283
+ // 被 targets 命中,会一直留着)。这类年份直接丢弃,否则设置页会永久显示
284
+ // 「2025 待公布」这种荒谬文本。上限不需要管:y+1 就是 target 的上界。
285
+ const floorYear = y - 1;
286
+ const kept = cnCalendarState.missing.filter((year) => year >= floorYear && !done.includes(year));
287
+ cnCalendarState.missing = [...new Set([...kept, ...stillMissing])].sort((x, z) => x - z);
288
+ if (done.length === 0 && failed.length > 0 && !stale) {
289
+ // 只补拉 missing 且全部连不上:保持原样,不动 fetchedAt(避免把数据「洗新」)
290
+ await saveCalendarCache();
291
+ return { ok: false, error: "连不上节假日数据源,继续使用本地数据。", failedYears: failed, ...calendarStatus() };
292
+ }
293
+ if (done.length === 0 && stillMissing.length > 0) {
294
+ // 连上了但确实还没公布(例如次年安排还没发)——不算错误,静默保持
295
+ await saveCalendarCache();
296
+ return { ok: true, pending: stillMissing, added: 0, ...calendarStatus() };
297
+ }
298
+ if (done.length === 0) {
299
+ return {
300
+ ok: false,
301
+ error: "连不上节假日数据源,继续使用本地数据。",
302
+ failedYears: failed,
303
+ ...calendarStatus(),
304
+ };
305
+ }
306
+ cnCalendarState.fetchedAt = Date.now();
307
+ cnCalendarState.years = [...new Set([...cnCalendarState.years, ...done])].sort((x, z) => x - z);
308
+ await saveCalendarCache();
309
+ return { ok: true, added, years: done, failedYears: failed, pending: cnCalendarState.missing.slice(), ...calendarStatus() };
310
+ }
311
+
312
+ /** 拉取指定年份(保留旧入口):内部走 fetchCalendarYear,成功后落盘。 */
313
+ async function ensureCnCalendar(year) {
314
+ if (cnCalendarYears.has(year)) return;
315
+ cnCalendarYears.add(year); // 先占位,避免并发重复请求
316
+ const n = await fetchCalendarYear(year);
317
+ if (n < 0) return;
318
+ if (!cnCalendarState.years.includes(year)) {
319
+ cnCalendarState.years = [...cnCalendarState.years, year].sort((x, z) => x - z);
320
+ }
321
+ if (cnCalendarState.fetchedAt === 0) cnCalendarState.fetchedAt = Date.now();
322
+ void saveCalendarCache();
323
+ }
324
+
143
325
  /** 北京时区日期键 YYYY-MM-DD(传入已 +8h 偏移的 Date)。 */
144
326
  function cnDateKey(d) {
145
327
  return d.getUTCFullYear() + "-" + String(d.getUTCMonth() + 1).padStart(2, "0") + "-" + String(d.getUTCDate()).padStart(2, "0");
@@ -345,7 +527,7 @@ function formatBalance(balance) {
345
527
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
346
528
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
347
529
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
348
- const PLUGIN_VERSION = "2.0.1";
530
+ const PLUGIN_VERSION = "2.0.3";
349
531
 
350
532
  // ─── 检查更新 ───────────────────────────────────────────────────────────────
351
533
  // 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
@@ -354,10 +536,195 @@ const UPDATE_CACHE_MS = 10 * 60 * 1000;
354
536
  const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/balance-dsh/latest";
355
537
  let updateCache = { at: 0, value: null };
356
538
 
539
+ /** 语义化版本比较:a > b 返回正、相等返回 0、a < b 返回负。
540
+ * 只比三段数字(忽略预发布后缀),够用来判断「npm 上的是不是比本地新」。 */
541
+ function compareVersion(a, b) {
542
+ const parse = (v) =>
543
+ String(v ?? "")
544
+ .trim()
545
+ .replace(/^v/i, "")
546
+ .split("-")[0]
547
+ .split(".")
548
+ .map((x) => parseInt(x, 10) || 0);
549
+ const x = parse(a);
550
+ const y = parse(b);
551
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
552
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
553
+ if (d !== 0) return d;
554
+ }
555
+ return 0;
556
+ }
557
+
558
+ /** 本插件的安装根目录(.../node_modules/balance-dsh),由 import.meta.url 反推。
559
+ * 定位不到返回空串(调用方报错,不动任何文件)。 */
560
+ async function installedPluginDir() {
561
+ try {
562
+ const { fileURLToPath } = await import("node:url");
563
+ const { dirname } = await import("node:path");
564
+ // .../node_modules/balance-dsh/lib/index.js → 上两级就是包根
565
+ return dirname(dirname(fileURLToPath(import.meta.url)));
566
+ } catch {
567
+ return "";
568
+ }
569
+ }
570
+
571
+ /** 极简 tar.gz 解包:只取普通文件、去掉 npm 固定的 package/ 前缀,返回 [{ rel, data }]。
572
+ * 不处理符号链接(npm 包是禁掉 symlink 的)与 ustar 长文件名(本包路径都很短)。 */
573
+ async function extractPackageFiles(gz) {
574
+ const { gunzipSync } = await import("node:zlib");
575
+ const tar = gunzipSync(gz);
576
+ const files = [];
577
+ let off = 0;
578
+ while (off + 512 <= tar.length) {
579
+ const header = tar.subarray(off, off + 512);
580
+ // 尾部是全零块,遇到就结束
581
+ let allZero = true;
582
+ for (let i = 0; i < 512; i += 1) {
583
+ if (header[i] !== 0) {
584
+ allZero = false;
585
+ break;
586
+ }
587
+ }
588
+ if (allZero) break;
589
+ const name = header.toString("utf8", 0, 100).replace(/\0.*$/, "");
590
+ const size = parseInt(header.toString("utf8", 124, 136).replace(/\0.*$/, "").trim(), 8) || 0;
591
+ const typeFlag = header.toString("utf8", 156, 157);
592
+ // "0" / "" / NUL = 普通文件;"5" = 目录,"2" = 符号链接,都跳过
593
+ const isFile = typeFlag === "0" || typeFlag === "" || typeFlag === "\0";
594
+ if (isFile && size > 0 && name !== "") {
595
+ const rel = name.startsWith("package/") ? name.slice("package/".length) : name;
596
+ if (rel !== "" && !rel.endsWith("/") && !rel.includes("..")) {
597
+ files.push({ rel, data: tar.subarray(off + 512, off + 512 + size) });
598
+ }
599
+ }
600
+ off += 512 + Math.ceil(size / 512) * 512;
601
+ }
602
+ return files;
603
+ }
604
+
605
+ /** tarball 地址(npm 的固定布局)。 */
606
+ function updateTarballUrl(version) {
607
+ return "https://registry.npmjs.org/balance-dsh/-/balance-dsh-" + version + ".tgz";
608
+ }
609
+
610
+ /** 就地更新本插件:下载 npm 上的最新 tarball → 解包 → 逐文件覆盖自己的安装目录。
611
+ *
612
+ * 几个刻意的取舍:
613
+ * - 只覆盖 tarball 里有的文件,不删任何东西。上游删掉的文件会残留,但残留无害,
614
+ * 比误删用户目录里的东西安全得多。
615
+ * - 覆盖前把旧文件备份到 <dshHome>/balance-update-backup/<from>-<时间戳>/,可手工回滚。
616
+ * - 逐个文件写、单个失败只记进 failed 不中断 —— 最典型的是 Windows 上 package.json
617
+ * 被 DSH 占着(之前同步时就踩过),但 lib/*.js 能写进去,功能不受影响。
618
+ * - 覆盖后 lib/index.js 的改动**必须重启 DSH** 才生效:Node 不会热替换已加载的模块。
619
+ * 返回值里带 restart:true,让界面明确提示。
620
+ */
621
+ async function installLatestVersion(ctx) {
622
+ const found = await fetchLatestVersion();
623
+ if (found.error) return { ok: false, error: found.error };
624
+ const latest = found.latest;
625
+ if (compareVersion(latest, PLUGIN_VERSION) <= 0) {
626
+ return { ok: true, installed: false, from: PLUGIN_VERSION, to: latest, note: "已是最新版本" };
627
+ }
628
+
629
+ const root = await installedPluginDir();
630
+ if (root === "") return { ok: false, error: "定位不到插件安装目录" };
631
+
632
+ let gz;
633
+ try {
634
+ const signal =
635
+ typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
636
+ ? AbortSignal.timeout(20000)
637
+ : undefined;
638
+ const res = await fetch(updateTarballUrl(latest), { signal });
639
+ if (!res || !res.ok) throw new Error("HTTP " + (res ? res.status : "?"));
640
+ gz = Buffer.from(await res.arrayBuffer());
641
+ } catch (error) {
642
+ return { ok: false, error: "下载 " + latest + " 失败:" + (error instanceof Error ? error.message : String(error)) };
643
+ }
644
+
645
+ let files;
646
+ try {
647
+ files = await extractPackageFiles(gz);
648
+ } catch (error) {
649
+ return { ok: false, error: "解包失败:" + (error instanceof Error ? error.message : String(error)) };
650
+ }
651
+ if (files.length === 0) return { ok: false, error: "下载到的包里没有文件" };
652
+
653
+ const { mkdir, writeFile, readFile, rename, unlink } = await import("node:fs/promises");
654
+ const { dirname, join, resolve, sep } = await import("node:path");
655
+
656
+ // 备份目录:拿不到 dshHome 就跳过备份(仍继续更新,失败项会体现在 failed 里)
657
+ const home = dshHomeDir(ctx);
658
+ const backupRoot =
659
+ home === "" ? "" : join(home, "balance-update-backup", PLUGIN_VERSION + "-" + Date.now());
660
+
661
+ const written = [];
662
+ const failed = [];
663
+ const rootAbs = resolve(root);
664
+ const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep;
665
+
666
+ for (const file of files) {
667
+ const dst = resolve(rootAbs, file.rel);
668
+ // 防路径穿越:解析后必须仍在包目录里
669
+ if (!dst.startsWith(rootPrefix)) {
670
+ failed.push(file.rel + "(路径越界,已跳过)");
671
+ continue;
672
+ }
673
+ try {
674
+ if (backupRoot !== "") {
675
+ try {
676
+ const old = await readFile(dst);
677
+ const bak = join(backupRoot, file.rel);
678
+ await mkdir(dirname(bak), { recursive: true });
679
+ await writeFile(bak, old);
680
+ } catch {
681
+ // 旧文件不存在(新版本新增的文件)或不可读:没有可备份的,继续
682
+ }
683
+ }
684
+ await mkdir(dirname(dst), { recursive: true });
685
+ // 先写同目录临时文件再 rename:原子替换 + 断开硬链接(profile 里有些文件是
686
+ // pnpm 硬链接,直接 writeFile 会写进同一个 inode,可能污染 store / 其它 profile)。
687
+ const staging = dst + ".new-" + Date.now() + "-" + written.length;
688
+ try {
689
+ await writeFile(staging, file.data);
690
+ await rename(staging, dst);
691
+ } catch (error) {
692
+ try {
693
+ await unlink(staging);
694
+ } catch {
695
+ // 临时文件没建起来或已清理,忽略
696
+ }
697
+ throw error;
698
+ }
699
+ written.push(file.rel);
700
+ } catch (error) {
701
+ failed.push(file.rel + ":" + (error instanceof Error ? error.message : String(error)));
702
+ }
703
+ }
704
+
705
+ if (written.length === 0) {
706
+ return { ok: false, error: "没有文件写入成功:" + failed.join(";") };
707
+ }
708
+
709
+ // 缓存里的 latest 可能已经过时,更新完作废,下次检查重新问 registry
710
+ updateCache = { at: 0, value: null };
711
+
712
+ return {
713
+ ok: true,
714
+ installed: true,
715
+ from: PLUGIN_VERSION,
716
+ to: latest,
717
+ files: written.length,
718
+ failed,
719
+ backup: backupRoot,
720
+ restart: true,
721
+ };
722
+ }
723
+
357
724
  /** 查 npm 上 balance-dsh 的最新版本。成功返回 { latest },失败返回 { error }(失败不缓存,下次可重试)。 */
358
- async function fetchLatestVersion() {
725
+ async function fetchLatestVersion(force) {
359
726
  const now = Date.now();
360
- if (updateCache.value && now - updateCache.at < UPDATE_CACHE_MS) return updateCache.value;
727
+ if (!force && updateCache.value && now - updateCache.at < UPDATE_CACHE_MS) return updateCache.value;
361
728
  if (typeof fetch !== "function") return { error: "当前运行环境没有 fetch" };
362
729
  try {
363
730
  let signal;
@@ -693,6 +1060,10 @@ const DEFAULT_SETTINGS = {
693
1060
  statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
694
1061
  statsCacheSec: 300, // 统计结果缓存秒数
695
1062
  // 计费
1063
+ // 高峰时段窗口。官方当前是「工作日北京 9-12、14-18」,但这条也是会变的
1064
+ // (改了时段就等于改了哪几个小时翻倍)。写成文本而不是数组:输入框直接对应,
1065
+ // 没有「数组 → 界面 → 数组」的转换损耗,用户手改也不会因为半截输入被弹回。
1066
+ peakWindowsText: "9-12, 14-18",
696
1067
  peakMultiplier: 2, // 高峰时段倍率
697
1068
  priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
698
1069
  pricePro: null, // v4-pro 覆盖价
@@ -729,6 +1100,38 @@ function normalizeNewTagSeen(raw) {
729
1100
  return out;
730
1101
  }
731
1102
 
1103
+ /** 高峰窗口文本 → 数组。接受 "9-12, 14-18"、"9-12,14-18"、"9~12" 这几种写法。
1104
+ * 逐段校验(0 ≤ start < end ≤ 24,整点),**任何一段不合法就整串作废返回 null** ——
1105
+ * 宁可回退官方默认窗口,也不要拿半对半错的窗口去算钱。
1106
+ * end = 24 表示到当天结束(hour 最大 23,`hour < end` 覆盖 23:00-23:59)。 */
1107
+ function parsePeakWindowsText(text) {
1108
+ if (typeof text !== "string") return null;
1109
+ const out = [];
1110
+ for (const chunk of text.split(/[,,;;]/)) {
1111
+ const part = chunk.trim();
1112
+ if (part === "") continue;
1113
+ const m = part.match(/^(\d{1,2})\s*[-~到至]\s*(\d{1,2})$/);
1114
+ if (!m) return null;
1115
+ const start = Number(m[1]);
1116
+ const end = Number(m[2]);
1117
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
1118
+ if (start < 0 || end > 24 || start >= end) return null;
1119
+ out.push({ start, end });
1120
+ }
1121
+ return out.length > 0 ? out : null;
1122
+ }
1123
+
1124
+ /** 高峰窗口文本的**类型清洗**(不校验语法)。
1125
+ * 用户打到一半("9-")也要能存下来继续编辑,所以这里不判合法;
1126
+ * 真正解析发生在 withSettings(服务端算钱)与客户端 currentPeriod(显示标签),
1127
+ * 解析失败一律回退官方默认窗口 —— 计费永远不会因为一段烂文本而错。 */
1128
+ function normalizePeakWindowsText(raw, fallback) {
1129
+ if (typeof raw !== "string") return fallback;
1130
+ const text = raw.trim();
1131
+ if (text === "") return fallback;
1132
+ return text.slice(0, 80);
1133
+ }
1134
+
732
1135
  /** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
733
1136
  * 四个字段都没填则整组丢弃(等价于不覆盖)。 */
734
1137
  function normalizePriceOverride(raw) {
@@ -769,6 +1172,7 @@ function normalizeSettings(raw) {
769
1172
  fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
770
1173
  statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
771
1174
  statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
1175
+ peakWindowsText: normalizePeakWindowsText(src.peakWindowsText, d.peakWindowsText),
772
1176
  peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
773
1177
  priceFlash: normalizePriceOverride(src.priceFlash),
774
1178
  pricePro: normalizePriceOverride(src.pricePro),
@@ -784,6 +1188,9 @@ function withSettings(config, settings) {
784
1188
  return {
785
1189
  ...config,
786
1190
  apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
1191
+ // 设置里改的高峰窗口优先;文本解析不出来就回退官方默认窗口,
1192
+ // 绝不能把 undefined 透给 isPeakAt(那会让 isPeakAt 自己再 fallback,语义重复)
1193
+ peakWindows: parsePeakWindowsText(s.peakWindowsText) ?? DEFAULT_PEAK_WINDOWS,
787
1194
  peakMultiplier: s.peakMultiplier,
788
1195
  priceByModel,
789
1196
  statsSessionLimit: s.statsSessionLimit,
@@ -791,9 +1198,9 @@ function withSettings(config, settings) {
791
1198
  };
792
1199
  }
793
1200
 
794
- /** 设置文件路径:优先 DSH 自己的 home(dshHomePath 服务),再环境变量,最后 ~/.dsh。
795
- * 返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
796
- function settingsFilePath(ctx) {
1201
+ /** DSH 的 home 目录:优先 dshHomePath 服务,再环境变量,最后 ~/.dsh。
1202
+ * 返回空串表示定位不到,此时设置读写与更新备份一律静默降级。 */
1203
+ function dshHomeDir(ctx) {
797
1204
  let home = "";
798
1205
  try {
799
1206
  const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
@@ -808,6 +1215,12 @@ function settingsFilePath(ctx) {
808
1215
  const base = process.env.USERPROFILE || process.env.HOME || "";
809
1216
  if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
810
1217
  }
1218
+ return home;
1219
+ }
1220
+
1221
+ /** 设置文件路径。返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
1222
+ function settingsFilePath(ctx) {
1223
+ const home = dshHomeDir(ctx);
811
1224
  if (home === "") return "";
812
1225
  const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
813
1226
  return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
@@ -1423,14 +1836,17 @@ export function apply(ctx, config = {}) {
1423
1836
  // 时统一清理,保证重复 apply 不冲突。
1424
1837
  const disposers = [];
1425
1838
 
1426
- // 后台补录节假日数据(当年 ±1 年,覆盖 90 天统计窗口与跨年场景)。
1427
- // 故意不 await:拉取失败不能影响插件启动;isPeakAt 保持同步,未拉到时继续用内置表。
1428
- {
1429
- const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
1430
- void ensureCnCalendar(y - 1);
1431
- void ensureCnCalendar(y);
1432
- void ensureCnCalendar(y + 1);
1433
- }
1839
+ // 节假日数据:先读本地缓存(这样断网、改过系统时间也照样按上次同步的结果算钱),
1840
+ // 再看缓存是不是过期(默认 30 天),过期才去拉。故意不 await —— 网络慢不能拖住启动,
1841
+ // isPeakAt 保持同步函数,缓存没到位时继续用内置表。
1842
+ void (async () => {
1843
+ try {
1844
+ await loadCalendarCache(ctx);
1845
+ await refreshCnCalendar(ctx, false);
1846
+ } catch {
1847
+ // 任何异常都只是「这次没更新」,不影响余额与计费
1848
+ }
1849
+ })();
1434
1850
 
1435
1851
  // 1) /balance 命令:保留命令行查询入口
1436
1852
  ctx.inject(["commands"], (commandCtx) => {
@@ -1502,12 +1918,9 @@ export function apply(ctx, config = {}) {
1502
1918
  }
1503
1919
  return;
1504
1920
  }
1505
- if (req.method !== "GET") {
1506
- writeJson(res, 405, { ok: false, error: "method not allowed" });
1507
- return;
1508
- }
1509
- // 检查更新:GET /balance-api?update=1 → 返回 npm 上的最新版本。
1510
- // 与余额无关,独立成一路,失败也不影响其它字段。
1921
+ // 检查更新 / 就地更新:同一个参数,GET 查询、POST 安装。
1922
+ // 它排在下面那道 405 之前 —— 这是除了设置写入之外唯一放开 POST 的入口,
1923
+ // 且只认 ?update=1(其余非 GET 仍然 405)。
1511
1924
  let wantUpdate = false;
1512
1925
  try {
1513
1926
  wantUpdate = new URL(req.url, "http://localhost").searchParams.get("update") === "1";
@@ -1515,7 +1928,27 @@ export function apply(ctx, config = {}) {
1515
1928
  wantUpdate = false;
1516
1929
  }
1517
1930
  if (wantUpdate) {
1518
- const found = await fetchLatestVersion();
1931
+ if (req.method === "POST") {
1932
+ // POST → 下载 npm 上最新 tarball 覆盖本机安装(见 installLatestVersion)。
1933
+ // 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
1934
+ try {
1935
+ const result = await installLatestVersion(httpCtx);
1936
+ writeJson(res, 200, { current: PLUGIN_VERSION, ...result });
1937
+ } catch (error) {
1938
+ writeJson(res, 200, {
1939
+ ok: false,
1940
+ current: PLUGIN_VERSION,
1941
+ error: error instanceof Error ? error.message : String(error),
1942
+ });
1943
+ }
1944
+ return;
1945
+ }
1946
+ if (req.method !== "GET") {
1947
+ writeJson(res, 405, { ok: false, error: "method not allowed" });
1948
+ return;
1949
+ }
1950
+ // force:用户手动点「检查更新」时不走 10 分钟缓存,否则刚更新过会显示旧结果
1951
+ const found = await fetchLatestVersion(true);
1519
1952
  if (found && found.error) {
1520
1953
  writeJson(res, 200, { ok: false, current: PLUGIN_VERSION, error: found.error });
1521
1954
  } else {
@@ -1523,6 +1956,38 @@ export function apply(ctx, config = {}) {
1523
1956
  }
1524
1957
  return;
1525
1958
  }
1959
+ // 节假日数据:GET 读状态(含 days 明细,客户端据此与服务端同口径判断峰谷),
1960
+ // POST 强制重新同步。和 update 一样排在下面那道 405 之前。
1961
+ let wantCalendar = false;
1962
+ try {
1963
+ wantCalendar = new URL(req.url, "http://localhost").searchParams.get("calendar") === "1";
1964
+ } catch {
1965
+ wantCalendar = false;
1966
+ }
1967
+ if (wantCalendar) {
1968
+ if (req.method === "POST") {
1969
+ try {
1970
+ writeJson(res, 200, await refreshCnCalendar(httpCtx, true));
1971
+ } catch (error) {
1972
+ writeJson(res, 200, {
1973
+ ok: false,
1974
+ error: error instanceof Error ? error.message : String(error),
1975
+ ...calendarStatus(),
1976
+ });
1977
+ }
1978
+ return;
1979
+ }
1980
+ if (req.method !== "GET") {
1981
+ writeJson(res, 405, { ok: false, error: "method not allowed" });
1982
+ return;
1983
+ }
1984
+ writeJson(res, 200, { ok: true, ...calendarStatus() });
1985
+ return;
1986
+ }
1987
+ if (req.method !== "GET") {
1988
+ writeJson(res, 405, { ok: false, error: "method not allowed" });
1989
+ return;
1990
+ }
1526
1991
  // 客户端把当前会话使用的模型通过 ?model= 传入,服务端按模型取价;
1527
1992
  // 可选 ?session= 传入会话 id,服务端返回该会话的分时段 usage(跨时段精确计费用)
1528
1993
  let model = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",