balance-dsh 2.0.2 → 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.
- package/lib/client.js +426 -75
- package/lib/index.js +275 -22
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -36,7 +36,7 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
整串按字符切成格子,只有值真正变了的格子才画三层(影子撑尺寸 / 旧字上滑出 / 新字下滑入),
|
|
37
37
|
没变的字符就是普通文本 —— 所以花掉 0.01 只有末位在翻,小数点和高位数字纹丝不动。
|
|
38
38
|
外层 overflow:hidden 把滑动部分裁在字高内,读起来像机械计数器过位。 */
|
|
39
|
-
.balance-footer__roll{display:inline-flex;align-items:baseline;white-space:pre}
|
|
39
|
+
.balance-footer__roll{display:inline-flex;align-items:baseline;white-space:pre;flex:none}
|
|
40
40
|
.balance-footer__dcell{display:inline-block;vertical-align:bottom;white-space:pre}
|
|
41
41
|
.balance-footer__dcell--roll{position:relative;overflow:hidden}
|
|
42
42
|
.balance-footer__dghost{display:block;visibility:hidden;white-space:pre}
|
|
@@ -58,7 +58,24 @@ window.__ModuleLoader__.load({
|
|
|
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)}
|
|
61
|
-
.balance-footer__value{position:relative;z-index:0;isolation:isolate;display:inline-flex;align-items:center;font-variant-numeric:tabular-nums;font-weight:600;font-size:16px;line-height:1;white-space:nowrap;padding:7px 14px;border-radius:999px;corner-shape:round;margin-left:
|
|
61
|
+
.balance-footer__value{box-sizing:border-box;position:relative;z-index:0;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;font-weight:600;font-size:16px;line-height:1;white-space:nowrap;padding:7px 14px;border-radius:999px;corner-shape:round;margin-left:auto;margin-right:4px;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s);flex:none;/* 宽度恒定 121px:靠「固定 px + 位数多就缩字号」两条一起实现。
|
|
62
|
+
⚠️ 开头的 box-sizing:border-box 是前提,不能省:min-width 默认算的是「内容盒」,
|
|
63
|
+
少了它 121px 会变成 121 + 左右 padding 28px = 149px 总宽,胶囊凭空胖一圈
|
|
64
|
+
(用户反馈「我的余额胶囊为什么这么长」,真实客户端实测正是 149px)。DSH 页面上
|
|
65
|
+
没有全局 border-box,刻度条 / 去充值 / FAB 都各自显式写了,只有这一处漏过。
|
|
66
|
+
胶囊内容是 total_balance + " " + currency(见 totalText),16px 粗体实测天然宽度:
|
|
67
|
+
0.00 CNY 105.6 · 10.32 CNY 111.5 · 100.00 CNY 121.4 · 1234.56 CNY 131.3 · 12345.67 CNY 141.1
|
|
68
|
+
余额 10.32 -> 9.87 少一个整数位就窄 6px,胶囊会跳(用户反馈「从10块掉到9块时
|
|
69
|
+
余额胶囊宽度变了」);而四位数以上又会反过来撑宽、把右边挤出容器。
|
|
70
|
+
所以:0~999.99 用 16px + min-width 撑到 121px;超过四位数自动降字号
|
|
71
|
+
(--dense 13px / --dense2 11.5px),缩回 121px 以内。两端都恒定。
|
|
72
|
+
⚠️ min-width 必须用 px 不能用 em:em 相对自身 font-size,一缩字号锁定宽度也跟着缩,
|
|
73
|
+
宽度照样跳。也别按「¥14.34 • 空闲」这种臆想格式去量 —— timeline 胶囊里只有
|
|
74
|
+
金额+货币码,先前按假格式算出 6.6em 恰好等于 0.00 CNY 的天然宽度,对任何值都不生效(踩过)。
|
|
75
|
+
justify-content:flex-end 让内容靠右,位数少时留白落在左边。 */min-width:112px}
|
|
76
|
+
/* 余额位数多时降档,配合上面的固定宽度,让长金额也收在 121px 内 */
|
|
77
|
+
.balance-footer__value--dense{font-size:13px}
|
|
78
|
+
.balance-footer__value--dense2{font-size:11.5px}
|
|
62
79
|
.balance-footer__value:hover{filter:brightness(.85);transform:scale(1.06)}
|
|
63
80
|
/* 旧版布局:胶囊靠右 —— 这是备份 v1.3.1 里的原始写法(margin-left:auto)。
|
|
64
81
|
不能放到基础规则上,新版 timeline 用 margin:0 auto 居中,会和它打架。 */
|
|
@@ -79,18 +96,40 @@ window.__ModuleLoader__.load({
|
|
|
79
96
|
/* 时段刻度条:色带固定 = 一整天,指针随时刻从左走到右。
|
|
80
97
|
结构:[meta 行(今日/时段/剩余)] + [轨道(边框) → 裁剪层(1px 内缩 → 泛光 + 色带) + 指针]。
|
|
81
98
|
轨道刻意做扁(20px 压到 13px),上方留一行给文字,整块像个"仪表盘"而不是"进度胶囊"。 */
|
|
82
|
-
.balance-footer__timeline{position:relative;z-index:0;display:inline-flex;flex-direction:column;align-items:center;gap:4px;flex:
|
|
83
|
-
/* meta 行:等宽数字,三项之间用中点分隔感靠 gap
|
|
84
|
-
|
|
99
|
+
.balance-footer__timeline{position:relative;z-index:0;display:inline-flex;flex-direction:column;align-items:center;gap:4px;flex:1 1 auto;min-width:0;white-space:nowrap;margin:0;overflow:hidden}
|
|
100
|
+
/* meta 行:等宽数字,三项之间用中点分隔感靠 gap 制造。
|
|
101
|
+
三项都 flex:none、都 nowrap,所以这一行只会随内容变宽、永不主动收缩 ——
|
|
102
|
+
「今日 ¥1234.56」比「今日 ¥14.34」宽两三个数字位,余额胶囊又同时要宽度,
|
|
103
|
+
两者硬碰硬时右侧胶囊会被挤出容器裁掉(余额四位数 + 消费四位数就能复现)。
|
|
104
|
+
所以给它 max-width + flex-wrap:空间不够时三项自己折行,块变高(--stacked 是
|
|
105
|
+
height:auto,撑得住),而不是把胶囊顶出去。 */
|
|
106
|
+
.balance-footer__timeline-meta{display:inline-flex;align-items:center;gap:6px;row-gap:3px;flex-wrap:wrap;justify-content:center;max-width:100%;font-size:11px;line-height:1;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin:0}
|
|
85
107
|
.balance-footer__timeline-meta-item{flex:none;white-space:nowrap}
|
|
108
|
+
/* 「今日 ¥x」独占第一行(flex-basis:100%),于是下面那行永远是「空闲 剩 3h57m」。
|
|
109
|
+
不加这条时宽度够就会并成一行,用户看到的顺序就变成「今日 … 空闲 剩 …」,
|
|
110
|
+
得不到他要的「空闲跟剩余时间一栏」(2026-10-30 指定)。
|
|
111
|
+
⚠️ 必须配 text-align:center:占满 100% 之后默认是左对齐,而下面那行 group 是居中的,
|
|
112
|
+
两行就会错开(2026-10-30 反馈「今日消费位置不对」)。 */
|
|
113
|
+
.balance-footer__timeline-meta-item--today{flex:0 0 100%;text-align:center}
|
|
114
|
+
/* meta 的第二段(时段词 + 剩余时间)作为一个整体不拆开:折行时它整段落到
|
|
115
|
+
「今日 ¥x」下面那行,于是「空闲」永远和「剩 3h57m」同行。 */
|
|
116
|
+
.balance-footer__timeline-meta-group{display:inline-flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0;white-space:nowrap;max-width:100%}
|
|
86
117
|
/* 时段词单独上色:高峰红、空闲绿(其余两项保持中性灰,避免整行花掉) */
|
|
87
118
|
.balance-footer__timeline-meta-item--peak{font-weight:600;color:var(--dsw-alias-state-error-primary,#e5484d)}
|
|
88
119
|
.balance-footer__timeline-meta-item--offpeak{font-weight:600;color:var(--dsw-alias-state-success-primary,#22c55e)}
|
|
89
|
-
|
|
120
|
+
/* ⚠️ 需要「实心彩底 + 白字」的地方一律用硬编码 #4d6bfe,不要用 var(--dsw-alias-brand-primary)。
|
|
121
|
+
实测(2026-10-30 用户截图):DSH 暗色主题里这个变量的值是**接近纯白**的 ——
|
|
122
|
+
官方拿它画 toggle 开关和分组竖条,所以那些地方保持白色才对;
|
|
123
|
+
但套在「白底 + 白字」的徽标上就彻底看不见(用户反馈「NEW 标签还有 bug」)。 */
|
|
124
|
+
/* 轨道外框 1px(原 2px):2px 在 15px 高的条上显得笨重,压住里面彩色时段。
|
|
125
|
+
边框只负责描边,色带与描边之间的空隙由下面 clip 的 1px 内缩提供。 */
|
|
126
|
+
.balance-footer__timeline-track{position:relative;display:block;box-sizing:border-box;width:100%;max-width:100%;height:15px;margin:0;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#8a8a8a) 70%,transparent);border-radius:999px;corner-shape:round;flex:none}
|
|
90
127
|
/* 描边统一中性灰:时段已经由 meta 行的「高峰 / 空闲」文字给出,
|
|
91
128
|
轨道再上色就是同一信息说三遍,还会和色带里的红绿抢注意力。
|
|
92
129
|
类名保留(--peak / --offpeak 仍在 JSX 上),将来想恢复彩色描边直接补规则即可。 */
|
|
93
|
-
/* 内嵌裁剪层:四边各让 1px
|
|
130
|
+
/* 内嵌裁剪层:四边各让 1px,色带裁在这里,颜色与边框之间留一线空隙。
|
|
131
|
+
注意这 1px 是**独立的视觉空隙**,不是给边框让位 —— 边框改窄(2px→1px)时
|
|
132
|
+
它必须留着,否则色带会直接贴到描边上,看起来糊成一整块。 */
|
|
94
133
|
.balance-footer__timeline-clip{position:absolute;left:1px;right:1px;top:1px;bottom:1px;overflow:hidden;border-radius:999px;corner-shape:round;isolation:isolate}
|
|
95
134
|
/* 泛光层:同一渐变模糊后叠在色带**上面**,靠模糊边缘往外散光。
|
|
96
135
|
刻意不用 screen 混合、也不把 brightness 拉太高——那会把 #e5484d 推成粉白,
|
|
@@ -98,16 +137,20 @@ window.__ModuleLoader__.load({
|
|
|
98
137
|
.balance-footer__timeline-glow{position:absolute;left:-3px;right:-3px;top:-3px;bottom:-3px;pointer-events:none;filter:blur(3px) saturate(1.5) brightness(1.15);opacity:.45;z-index:2}
|
|
99
138
|
/* 色带本身:不透明 + 轻微提饱和,颜色是主角 */
|
|
100
139
|
.balance-footer__timeline-band{position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;filter:saturate(1.25) brightness(1.12);z-index:1}
|
|
101
|
-
|
|
140
|
+
/* 指针位置由 JS 每 500ms 直接写(见 BalanceFooter 里的 pointerRef effect),刻意不用 transition:
|
|
141
|
+
一天走完 100%,每分钟才 0.069%(200px 轨道上 0.14px),过渡的连续性完全依赖浏览器
|
|
142
|
+
是否继续推进动画时钟——后台标签页会把过渡暂停,切回来时指针直接落到终点,那就是
|
|
143
|
+
用户看到的「瞬移」。逐帧写 left 才是绝对连续的,跨零点也自然回到起点。 */
|
|
144
|
+
.balance-footer__timeline-pointer{position:absolute;top:-1px;bottom:-1px;width:2px;margin-left:-1px;background:var(--dsw-alias-label-primary,#fff);box-shadow:0 0 3px rgba(255,255,255,.7),0 0 2px rgba(0,0,0,.55);pointer-events:none;z-index:2}
|
|
102
145
|
/* ── 侧栏入口的红点 + 未读高亮 ─────────────────────────────────────────── */
|
|
103
146
|
.balance-panel-icon{position:relative;display:inline-flex;align-items:center;justify-content:center}
|
|
104
147
|
/* 未读时图标本身染成品牌色,比周围灰图标更跳 */
|
|
105
|
-
.balance-panel-icon--unread{color
|
|
148
|
+
.balance-panel-icon--unread{color:#4d6bfe}
|
|
106
149
|
/* 红点:贴在图标右上角,深色描边保证在任意底色上都看得清 */
|
|
107
150
|
.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
151
|
/* 有新版本时的文字提示:品牌色实心小胶囊,绝对定位压在图标右上,
|
|
109
152
|
视觉上落在「余额与用量」几个字的上方。不做脉冲 —— 那是红点的语言,且静止更醒目。 */
|
|
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
|
|
153
|
+
.balance-panel-icon__new{position:absolute;left:10px;top:-10px;box-sizing:border-box;padding:1px 4px;border-radius:999px;corner-shape:round;background:#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)}
|
|
111
154
|
@keyframes balance-dot-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.82);opacity:.72}}
|
|
112
155
|
@media (prefers-reduced-motion:reduce){.balance-panel-icon__dot{animation:none}}
|
|
113
156
|
/* ── 侧栏面板页(点左侧「余额与用量」后渲染在主区域) ──────────────────── */
|
|
@@ -278,14 +321,16 @@ window.__ModuleLoader__.load({
|
|
|
278
321
|
先前用的是实心品牌色渐变 + 白字 + 三层强光晕,
|
|
279
322
|
在深色设置页里太刺眼(2026-10-30 反馈「太亮了」)。
|
|
280
323
|
现在:品牌色 18% 薄底 + 6px 弱光晕,呼吸幅度也收窄。 */
|
|
281
|
-
|
|
324
|
+
/* NEW 徽标:实心品牌蓝 + 白字。原先用 color-mix 调淡(62% 蓝 + 38% 白字、18% 底),
|
|
325
|
+
实测在客户端里淡到看起来就是一行白字,用户反馈「没颜色」—— 小尺寸下必须给足饱和度。 */
|
|
326
|
+
.balance-settings__new-badge{flex:none;box-sizing:border-box;padding:0 5px;border-radius:5px;corner-shape:round;font-size:9px;line-height:14px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#fff;background:#4d6bfe;animation:balance-new-glow 3.2s ease-in-out infinite}
|
|
282
327
|
/* 呼吸式光晕:只有两层,最大 7px / 32%,足以有「光」的感觉但不刺眼 */
|
|
283
328
|
@keyframes balance-new-glow{
|
|
284
|
-
0%,100%{box-shadow:0 0 4px color-mix(in srgb
|
|
285
|
-
50%{box-shadow:0 0 7px color-mix(in srgb
|
|
329
|
+
0%,100%{box-shadow:0 0 4px color-mix(in srgb,#4d6bfe 22%,transparent),0 0 8px color-mix(in srgb,#4d6bfe 10%,transparent)}
|
|
330
|
+
50%{box-shadow:0 0 7px color-mix(in srgb,#4d6bfe 40%,transparent),0 0 14px color-mix(in srgb,#4d6bfe 18%,transparent)}
|
|
286
331
|
}
|
|
287
332
|
/* 尊重减少动态效果:不呼吸,保持平均亮度 */
|
|
288
|
-
@media (prefers-reduced-motion:reduce){.balance-settings__new-badge{animation:none;box-shadow:0 0 5px color-mix(in srgb
|
|
333
|
+
@media (prefers-reduced-motion:reduce){.balance-settings__new-badge{animation:none;box-shadow:0 0 5px color-mix(in srgb,#4d6bfe 28%,transparent)}}
|
|
289
334
|
.balance-settings__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 0}
|
|
290
335
|
/* 行与行之间用半像素淡线分开,避免几行文字堆成一片 */
|
|
291
336
|
.balance-settings__row + .balance-settings__row{border-top:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.12))}
|
|
@@ -294,11 +339,24 @@ window.__ModuleLoader__.load({
|
|
|
294
339
|
.balance-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
|
|
295
340
|
.balance-settings__control{display:flex;align-items:center;gap:6px;flex:none}
|
|
296
341
|
.balance-settings__unit{font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
297
|
-
|
|
342
|
+
/* 文本类输入:数字框那套 96px + 右对齐会把「9-12, 14-18」截掉,单独放宽并左对齐 */
|
|
343
|
+
.balance-settings__input--text{width:132px;text-align:left;font-variant-numeric:normal;letter-spacing:.2px}
|
|
344
|
+
.balance-settings__input--text::placeholder{color:var(--dsw-alias-label-tertiary)}/* 输入框底色照抄 DSH 官方设置页(dsh-client-ui-settings-models 的 input 规则):
|
|
345
|
+
那边用 --dsw-alias-bg-layer-1,不是 layer-3。深色主题下三层分别是
|
|
346
|
+
bg-layer-1 #232324 / bg-layer-2 #2c2c2e / bg-layer-3 #353638,页面底 bg-base #151517 —
|
|
347
|
+
用 layer-3 比底色亮 32 级,在设置卡片里就是一块发闷的灰板(用户反馈「那个框的颜色太灰了」)。
|
|
348
|
+
layer-1 只亮 14 级,和官方观感一致;浅色主题下 layer-1 = #fff 与底色同色,
|
|
349
|
+
层次完全靠 .5px 的 border-l4 撑,这也是官方那套的样子。 */
|
|
350
|
+
.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-1,rgba(127,127,127,.06));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;font-variant-numeric:tabular-nums;outline:none}
|
|
298
351
|
.balance-settings__input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}
|
|
299
352
|
.balance-settings__input--wide{width:100%;max-width:340px;min-width:0;text-align:left}
|
|
300
353
|
/* 关掉侧边栏入口后的提醒:壳用 DSH 官方 Modal,这里只留正文排版。 */
|
|
301
354
|
.balance-tip-body{margin:0;font-size:13px;line-height:1.7;color:var(--dsw-alias-label-secondary)}
|
|
355
|
+
/* 节假日数据行:右侧是「状态文案 + 立即同步」两段,窄屏时自动换行靠右。 */
|
|
356
|
+
.balance-settings__calendar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
|
|
357
|
+
.balance-settings__calendar-note{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);text-align:right;font-variant-numeric:tabular-nums;max-width:200px}
|
|
358
|
+
.balance-settings__calendar-note--warn{color:var(--dsw-alias-state-warn-primary,#d97706)}
|
|
359
|
+
.balance-settings__calendar-note--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
|
|
302
360
|
.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
361
|
.balance-tip-ok:hover{opacity:.88}
|
|
304
362
|
.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}
|
|
@@ -314,13 +372,13 @@ window.__ModuleLoader__.load({
|
|
|
314
372
|
默认是中性底色,只在「有新版本」时提亮成品牌色、「刚更新完」时转成功色,
|
|
315
373
|
失败不染色(网络波动不该看起来像错误告警)。 */
|
|
316
374
|
.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
|
|
375
|
+
.balance-settings__update--newer{border-color:color-mix(in srgb,#4d6bfe 42%,transparent);background:color-mix(in srgb,#4d6bfe 14%,transparent)}
|
|
318
376
|
.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
377
|
.balance-settings__update-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
320
378
|
.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
379
|
.balance-settings__update-ver{font-weight:600;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
|
|
322
380
|
/* 新版徽标:实心品牌色胶囊,形状必须是正圆角(DSH 全局把圆角改成 superellipse 了) */
|
|
323
|
-
.balance-settings__update-badge{flex:none;padding:1px 7px;border-radius:999px;corner-shape:round;background
|
|
381
|
+
.balance-settings__update-badge{flex:none;padding:1px 7px;border-radius:999px;corner-shape:round;background:#4d6bfe;color:#fff;font-size:11px;font-weight:600;line-height:16px;white-space:nowrap}
|
|
324
382
|
.balance-settings__update-btns{display:flex;align-items:center;gap:6px;flex:none}
|
|
325
383
|
/* 查询中的进度文案:跟按钮同一行、排在左边,灰一点,不跟主文案抢注意力。 */
|
|
326
384
|
.balance-settings__update-busy{flex:none;font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
|
|
@@ -333,7 +391,7 @@ window.__ModuleLoader__.load({
|
|
|
333
391
|
.balance-settings__status{flex:1;min-width:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
|
|
334
392
|
.balance-settings__status--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
|
|
335
393
|
.balance-settings__status--ok{color:var(--dsw-alias-state-success-primary,#22c55e)}
|
|
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}
|
|
394
|
+
.balance-settings__btn{box-sizing:border-box;flex:none;white-space:nowrap;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
395
|
.balance-settings__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-active,rgba(127,127,127,.18))}
|
|
338
396
|
.balance-settings__btn:disabled{opacity:.4;cursor:default}
|
|
339
397
|
.balance-settings__btn--primary{border-color:transparent;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
|
|
@@ -613,6 +671,37 @@ window.__ModuleLoader__.load({
|
|
|
613
671
|
{ start: 14, end: 18 },
|
|
614
672
|
];
|
|
615
673
|
const PEAK_WEEKDAYS = [1, 2, 3, 4, 5]; // 1=周一 … 5=周五,0/6=周末
|
|
674
|
+
|
|
675
|
+
// 生效中的高峰窗口。默认 = 官方当前口径,但设置里的「高峰时段」一改就换成用户的。
|
|
676
|
+
// **判定(currentPeriod)和刻度(periodBounds)都必须读 activePeakWindows**,
|
|
677
|
+
// 直接读 PEAK_WINDOWS 会让改了设置的人看到标签和刻度条对不上。
|
|
678
|
+
let activePeakWindows = PEAK_WINDOWS;
|
|
679
|
+
|
|
680
|
+
/** "9-12, 14-18" → [{start,end}]。与服务端 parsePeakWindowsText 同规则:
|
|
681
|
+
* 逐段校验,任何一段不合法整串作废(返回 null),宁可回退默认窗口也不错算。 */
|
|
682
|
+
function parsePeakWindowsText(text) {
|
|
683
|
+
if (typeof text !== "string") return null;
|
|
684
|
+
const out = [];
|
|
685
|
+
for (const chunk of text.split(/[,,;;]/)) {
|
|
686
|
+
const part = chunk.trim();
|
|
687
|
+
if (part === "") continue;
|
|
688
|
+
const m = part.match(/^(\d{1,2})\s*[-~到至]\s*(\d{1,2})$/);
|
|
689
|
+
if (!m) return null;
|
|
690
|
+
const start = Number(m[1]);
|
|
691
|
+
const end = Number(m[2]);
|
|
692
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
|
|
693
|
+
if (start < 0 || end > 24 || start >= end) return null;
|
|
694
|
+
out.push({ start, end });
|
|
695
|
+
}
|
|
696
|
+
return out.length > 0 ? out : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** 应用设置里的窗口文本;解析失败保持当前窗口不动(不因为半截输入把标签打乱)。 */
|
|
700
|
+
function applyPeakWindowsText(text) {
|
|
701
|
+
const parsed = parsePeakWindowsText(text);
|
|
702
|
+
if (parsed !== null) activePeakWindows = parsed;
|
|
703
|
+
return activePeakWindows;
|
|
704
|
+
}
|
|
616
705
|
// 中国大陆法定节假日与调休(北京时区)。**必须与 lib/index.js 的 CN_CALENDAR 完全一致**
|
|
617
706
|
// (服务端负责算钱、客户端只负责显示时段标签)。来源:国办发明电〔2025〕7号,每年需补录。
|
|
618
707
|
const CN_CALENDAR = {
|
|
@@ -641,23 +730,32 @@ window.__ModuleLoader__.load({
|
|
|
641
730
|
|
|
642
731
|
// 节假日自动补录:与 lib/index.js 同源、同逻辑(客户端只用来显示时段标签)。
|
|
643
732
|
// 拉取失败静默 —— 拿不到就继续用上面的内置表。
|
|
644
|
-
|
|
733
|
+
// 与服务端同一份多源列表:某个 CDN 不通就换下一个。
|
|
734
|
+
const CN_CALENDAR_SOURCES = [
|
|
735
|
+
"https://fastly.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
|
|
736
|
+
"https://gcore.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
|
|
737
|
+
"https://raw.githubusercontent.com/NateScarlet/holiday-cn/master",
|
|
738
|
+
"https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
|
|
739
|
+
];
|
|
645
740
|
const cnCalendarYears = new Set();
|
|
646
741
|
async function ensureCnCalendar(year) {
|
|
647
742
|
if (cnCalendarYears.has(year)) return;
|
|
648
743
|
cnCalendarYears.add(year); // 先占位,避免并发重复请求
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
744
|
+
for (const base of CN_CALENDAR_SOURCES) {
|
|
745
|
+
try {
|
|
746
|
+
const res = await fetch(base + "/" + year + ".json", { signal: AbortSignal.timeout(6000) });
|
|
747
|
+
if (!res.ok) continue;
|
|
748
|
+
const data = await res.json();
|
|
749
|
+
if (!Array.isArray(data?.days)) continue;
|
|
750
|
+
for (const item of data.days) {
|
|
751
|
+
if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
|
|
752
|
+
CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
|
|
753
|
+
}
|
|
657
754
|
}
|
|
755
|
+
return;
|
|
756
|
+
} catch {
|
|
757
|
+
// 这个源连不上:换下一个;全挂就保留内置表
|
|
658
758
|
}
|
|
659
|
-
} catch {
|
|
660
|
-
// 网络不可用:保留内置表
|
|
661
759
|
}
|
|
662
760
|
}
|
|
663
761
|
{
|
|
@@ -738,7 +836,7 @@ window.__ModuleLoader__.load({
|
|
|
738
836
|
const weekday = d.getUTCDay(); // 0=周日 … 6=周六
|
|
739
837
|
// 与服务端 isPeakAt 同口径:官方定价页把「周末及法定节假日全天」都算空闲,
|
|
740
838
|
// 没有调休例外,所以 workday(调休上班的周末)也按周末处理。
|
|
741
|
-
return PEAK_WEEKDAYS.includes(weekday) &&
|
|
839
|
+
return PEAK_WEEKDAYS.includes(weekday) && activePeakWindows.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
|
|
742
840
|
}
|
|
743
841
|
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
744
842
|
/** 当前时段的区间与剩余时间(供刻度条上方那行 meta 用)。
|
|
@@ -755,7 +853,7 @@ window.__ModuleLoader__.load({
|
|
|
755
853
|
let end = null;
|
|
756
854
|
if (kind === "peak") {
|
|
757
855
|
const h = (now - dayStart) / HOUR;
|
|
758
|
-
for (const w of
|
|
856
|
+
for (const w of activePeakWindows) {
|
|
759
857
|
if (h >= w.start && h < w.end) {
|
|
760
858
|
start = dayStart + w.start * HOUR;
|
|
761
859
|
end = dayStart + w.end * HOUR;
|
|
@@ -792,17 +890,44 @@ window.__ModuleLoader__.load({
|
|
|
792
890
|
const rh = h % 24;
|
|
793
891
|
return rh === 0 ? d + "d" : d + "d" + rh + "h";
|
|
794
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* 余额位数多时返回降字号用的 class(空串 = 不降)。
|
|
895
|
+
*
|
|
896
|
+
* 胶囊的宽度是固定的 121px(.balance-footer__value 的 min-width),正常 16px 字号
|
|
897
|
+
* 能容纳到「999.99 CNY」(121.4px 天然宽度,实测)。整数位一旦到 4 位,
|
|
898
|
+
* 天然宽度就超过固定宽度(1234.56 CNY = 131.3px),会把胶囊撑出侧栏右缘 ——
|
|
899
|
+
* 所以按整数位数降一档字号,让它重新收回 121px 以内。
|
|
900
|
+
*
|
|
901
|
+
* 按「整数位数」而不是整串字符数判断:`¥` 前缀、货币码长短、有没有小数都会
|
|
902
|
+
* 干扰字符数,但整数位数就是金额量级本身,判断最稳。
|
|
903
|
+
* 负数(理论上不会出现)先剥掉符号再数,免得 "-" 被算成一个数位。
|
|
904
|
+
*/
|
|
905
|
+
function amountDensity(raw) {
|
|
906
|
+
const text = String(raw ?? "");
|
|
907
|
+
const intPart = text.split(".")[0].replace(/\D/g, "");
|
|
908
|
+
if (intPart.length >= 7) return " balance-footer__value--dense2";
|
|
909
|
+
if (intPart.length >= 3) return " balance-footer__value--dense";
|
|
910
|
+
return "";
|
|
911
|
+
}
|
|
795
912
|
// ─── 时段刻度条:色带固定 = 一整天,指针随时刻从左走到右 ────────────────
|
|
796
913
|
// 轨道铺满当天 00:00 → 次日 00:00 的时段色带(不滚动),指针位置 = 现在时刻在当天的比例。
|
|
797
|
-
//
|
|
914
|
+
// 指针每秒前进一次(pointerNow 独立节拍),CSS transition:left 1s linear 补成连续滑动;
|
|
915
|
+
// 跨零点那一帧用 --snap 关掉过渡,直接落到新一天的起点。
|
|
798
916
|
const DAY_MS = 24 * 3600000;
|
|
917
|
+
/** 当天 00:00(北京时间)的毫秒时间戳。色带、指针、跨零点判定共用同一个基准。 */
|
|
918
|
+
function dayStartOf(now) {
|
|
919
|
+
return Math.floor((now + BEIJING_OFFSET_MS) / DAY_MS) * DAY_MS - BEIJING_OFFSET_MS;
|
|
920
|
+
}
|
|
921
|
+
/** 此刻在一整天里的百分比位置(0 → 100)。指针每秒调它一次。 */
|
|
922
|
+
function pointerPercent(now) {
|
|
923
|
+
return ((now - dayStartOf(now)) / DAY_MS) * 100;
|
|
924
|
+
}
|
|
799
925
|
/** 生成刻度条:当天色带渐变 + 时段文字 + 指针位置。
|
|
800
926
|
* @returns {{ gradient: string, pointerPct: number }} */
|
|
801
927
|
function buildTimeline(now) {
|
|
802
928
|
const HOUR = 3600000;
|
|
803
929
|
// 当天 00:00(北京时间)。BEIJING_OFFSET_MS 东为正,先平移到北京再取整到天。
|
|
804
|
-
const
|
|
805
|
-
const dayStart = Math.floor(bj / DAY_MS) * DAY_MS - BEIJING_OFFSET_MS;
|
|
930
|
+
const dayStart = dayStartOf(now);
|
|
806
931
|
const dayEnd = dayStart + DAY_MS;
|
|
807
932
|
// 逐小时采样找出切换点(官方时段都在整点切换,1 小时精度足够)
|
|
808
933
|
const stops = [];
|
|
@@ -869,6 +994,7 @@ window.__ModuleLoader__.load({
|
|
|
869
994
|
fabLeft: 44,
|
|
870
995
|
statsSessionLimit: 200,
|
|
871
996
|
statsCacheSec: 300,
|
|
997
|
+
peakWindowsText: "9-12, 14-18",
|
|
872
998
|
peakMultiplier: 2,
|
|
873
999
|
priceFlash: null,
|
|
874
1000
|
pricePro: null,
|
|
@@ -878,6 +1004,83 @@ window.__ModuleLoader__.load({
|
|
|
878
1004
|
const settingsListeners = new Set();
|
|
879
1005
|
let settingsInFlight = null;
|
|
880
1006
|
|
|
1007
|
+
// 节假日数据(服务端 /balance-api?calendar=1 同步的那份)。
|
|
1008
|
+
// 必须把服务端下发的 days 合进客户端自己的 CN_CALENDAR —— 否则服务端按新公告算钱、
|
|
1009
|
+
// 客户端高峰/空闲标签还按内置表,同一时刻两边口径会打架。
|
|
1010
|
+
// missing = 服务端「连上了但还没公布」的年份(典型:次年放假安排要等国务院年底发)。
|
|
1011
|
+
// 只用于在设置页提示「2027 待公布」,不参与峰谷判定。
|
|
1012
|
+
let calendarCache = { fetchedAt: 0, years: [], missing: [], count: 0, loaded: false };
|
|
1013
|
+
let calendarInFlight = null;
|
|
1014
|
+
|
|
1015
|
+
/** 把服务端的 days 明细(YYYY-MM-DD → holiday/workday)合并进本地日历表。 */
|
|
1016
|
+
function applyCalendarDays(days) {
|
|
1017
|
+
if (!days || typeof days !== "object") return;
|
|
1018
|
+
let merged = 0;
|
|
1019
|
+
for (const [date, kind] of Object.entries(days)) {
|
|
1020
|
+
if (typeof date === "string" && (kind === "holiday" || kind === "workday")) {
|
|
1021
|
+
CN_CALENDAR[date] = kind;
|
|
1022
|
+
merged += 1;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return merged;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function adoptCalendar(data) {
|
|
1029
|
+
calendarCache = {
|
|
1030
|
+
fetchedAt: Number(data.fetchedAt) || 0,
|
|
1031
|
+
years: Array.isArray(data.years) ? data.years.map(Number).filter((y) => Number.isFinite(y)) : [],
|
|
1032
|
+
missing: Array.isArray(data.missing) ? data.missing.map(Number).filter((y) => Number.isFinite(y)) : [],
|
|
1033
|
+
count: Number(data.count) || 0,
|
|
1034
|
+
loaded: true,
|
|
1035
|
+
};
|
|
1036
|
+
applyCalendarDays(data.days);
|
|
1037
|
+
// 峰谷判定变了 → 让订阅者(余额行标签、消耗行、设置卡片)都重画
|
|
1038
|
+
emitSettings();
|
|
1039
|
+
return calendarCache;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** 读服务端节假日状态(并发合并)。失败静默 —— 拿不到就继续用内置表。 */
|
|
1043
|
+
function fetchCalendarStatus() {
|
|
1044
|
+
if (calendarInFlight !== null) return calendarInFlight;
|
|
1045
|
+
calendarInFlight = (async () => {
|
|
1046
|
+
try {
|
|
1047
|
+
const res = await fetch("/balance-api?calendar=1", { headers: { accept: "application/json" } });
|
|
1048
|
+
if (!res.ok) return calendarCache;
|
|
1049
|
+
const data = await res.json();
|
|
1050
|
+
if (!data || data.ok !== true) return calendarCache;
|
|
1051
|
+
return adoptCalendar(data);
|
|
1052
|
+
} catch {
|
|
1053
|
+
return calendarCache;
|
|
1054
|
+
} finally {
|
|
1055
|
+
calendarInFlight = null;
|
|
1056
|
+
}
|
|
1057
|
+
})();
|
|
1058
|
+
return calendarInFlight;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** 手动同步:POST 让服务端去拉当年 ±1 年的国务院公告并落盘。 */
|
|
1062
|
+
async function syncCalendar() {
|
|
1063
|
+
const res = await fetch("/balance-api?calendar=1", {
|
|
1064
|
+
method: "POST",
|
|
1065
|
+
headers: { accept: "application/json" },
|
|
1066
|
+
});
|
|
1067
|
+
const data = await res.json();
|
|
1068
|
+
if (data && data.ok === true) adoptCalendar(data);
|
|
1069
|
+
return data;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** 订阅节假日状态;首次使用时自动去读一次。 */
|
|
1073
|
+
function useCalendar() {
|
|
1074
|
+
const [, force] = react.useState(0);
|
|
1075
|
+
react.useEffect(() => {
|
|
1076
|
+
const listener = () => force((n) => n + 1);
|
|
1077
|
+
settingsListeners.add(listener);
|
|
1078
|
+
if (calendarCache.loaded !== true) void fetchCalendarStatus();
|
|
1079
|
+
return () => settingsListeners.delete(listener);
|
|
1080
|
+
}, []);
|
|
1081
|
+
return calendarCache;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
881
1084
|
function emitSettings() {
|
|
882
1085
|
for (const listener of settingsListeners) listener();
|
|
883
1086
|
}
|
|
@@ -896,6 +1099,8 @@ window.__ModuleLoader__.load({
|
|
|
896
1099
|
loaded: true,
|
|
897
1100
|
version: typeof data.version === "string" ? data.version : settingsCache.version,
|
|
898
1101
|
};
|
|
1102
|
+
// 高峰窗口跟着设置走:判定与刻度都得换,否则标签会和实际计费对不上
|
|
1103
|
+
applyPeakWindowsText(settingsCache.value.peakWindowsText);
|
|
899
1104
|
}
|
|
900
1105
|
} catch {
|
|
901
1106
|
// 服务端不可用:保持默认值,不打扰用户
|
|
@@ -922,6 +1127,7 @@ window.__ModuleLoader__.load({
|
|
|
922
1127
|
loaded: true,
|
|
923
1128
|
version: typeof data.version === "string" ? data.version : settingsCache.version,
|
|
924
1129
|
};
|
|
1130
|
+
applyPeakWindowsText(settingsCache.value.peakWindowsText);
|
|
925
1131
|
emitSettings();
|
|
926
1132
|
return settingsCache.value;
|
|
927
1133
|
}
|
|
@@ -1752,6 +1958,23 @@ window.__ModuleLoader__.load({
|
|
|
1752
1958
|
}
|
|
1753
1959
|
};
|
|
1754
1960
|
}, []);
|
|
1961
|
+
// 指针位置:500ms 一次直接改 DOM 的 left,不经 React state、也不用 CSS transition。
|
|
1962
|
+
// 一天走完 100%,每分钟只有 0.069%(200px 轨道上 0.14px):
|
|
1963
|
+
// · 按分钟 tick + 长过渡 —— 静止 59 秒再窜一下,且连续性押在浏览器动画时钟上,
|
|
1964
|
+
// 后台标签页会把过渡暂停,切回来时直接落到终点,那就是用户看到的「瞬移」;
|
|
1965
|
+
// · 每 500ms 重算一次 —— 相邻两次只差 0.0006%(0.0012px),视觉上就是匀速滑,
|
|
1966
|
+
// 不依赖过渡调度,也不会因为后台暂停而攒成一跳。
|
|
1967
|
+
// 只有 2px 的元素改 left,代价可忽略;不碰 state 就不触发组件重渲染。
|
|
1968
|
+
const pointerRef = react.useRef(null);
|
|
1969
|
+
react.useEffect(() => {
|
|
1970
|
+
const paint = () => {
|
|
1971
|
+
const el = pointerRef.current;
|
|
1972
|
+
if (el) el.style.left = pointerPercent(Date.now()).toFixed(4) + "%";
|
|
1973
|
+
};
|
|
1974
|
+
paint();
|
|
1975
|
+
const timer = setInterval(paint, 500);
|
|
1976
|
+
return () => clearInterval(timer);
|
|
1977
|
+
}, []);
|
|
1755
1978
|
const clickTimer = react.useRef(null);
|
|
1756
1979
|
const closeTimer = react.useRef(null);
|
|
1757
1980
|
const anchorRef = react.useRef(null);
|
|
@@ -1973,6 +2196,10 @@ window.__ModuleLoader__.load({
|
|
|
1973
2196
|
const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
|
|
1974
2197
|
const currency = b.currency || "CNY";
|
|
1975
2198
|
const totalText = b.total_balance + " " + currency;
|
|
2199
|
+
// 位数多就降字号:一旦整数位到 4 位,16px 的胶囊会宽过 121px 的固定宽度
|
|
2200
|
+
// (1234.56 CNY 131.3px / 12345.67 CNY 141.1px),把自己撑出容器右缘。
|
|
2201
|
+
// 降一档字号就能收回来,宽度重新恒定 —— 配合 .balance-footer__value 的 min-width。
|
|
2202
|
+
const valueDensity = amountDensity(b.total_balance);
|
|
1976
2203
|
if (depleted) {
|
|
1977
2204
|
return withPanel(jsx.jsxs("div", {
|
|
1978
2205
|
className: "balance-footer",
|
|
@@ -2000,55 +2227,74 @@ window.__ModuleLoader__.load({
|
|
|
2000
2227
|
// 时段模块:上方一行 meta(今日消耗 · 时段 · 剩余时间)+ 下方扁刻度条。
|
|
2001
2228
|
// 色带固定 = 当天完整时段,指针 = 此刻在当天的位置。
|
|
2002
2229
|
const span = periodSpan(timelineNow);
|
|
2003
|
-
|
|
2230
|
+
// meta 分两段:①「今日 ¥x」②「时段词 + 剩余时间」。
|
|
2231
|
+
// 第二段整体不换行(包在 __group 里),所以窄侧栏折行时一定是
|
|
2232
|
+
// 「今日 ¥x」独占上面一行、「空闲 剩 3h57m」落到下面一行 —— 用户 2026-10-30 指定。
|
|
2233
|
+
const metaItem = (text, cls, key) =>
|
|
2234
|
+
jsx.jsx("span", { className: "balance-footer__timeline-meta-item" + (cls || ""), children: text }, key);
|
|
2235
|
+
const tail = [
|
|
2236
|
+
metaItem(period === "peak" ? "高峰" : "空闲", " balance-footer__timeline-meta-item--" + period, "period"),
|
|
2237
|
+
];
|
|
2238
|
+
if (span !== null) tail.push(metaItem("剩 " + formatRemain(span.remainMs), "", "remain"));
|
|
2239
|
+
const metaChildren = [];
|
|
2004
2240
|
if (today !== null && Number.isFinite(Number(today.cost))) {
|
|
2005
|
-
|
|
2241
|
+
metaChildren.push(
|
|
2242
|
+
jsx.jsx(
|
|
2243
|
+
"span",
|
|
2244
|
+
{
|
|
2245
|
+
className: "balance-footer__timeline-meta-item balance-footer__timeline-meta-item--today",
|
|
2246
|
+
children: "今日 ¥" + Number(today.cost).toFixed(2),
|
|
2247
|
+
},
|
|
2248
|
+
"today",
|
|
2249
|
+
),
|
|
2250
|
+
);
|
|
2006
2251
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
});
|
|
2011
|
-
if (span !== null) metaItems.push({ text: "剩 " + formatRemain(span.remainMs), cls: "" });
|
|
2252
|
+
metaChildren.push(
|
|
2253
|
+
jsx.jsx("span", { className: "balance-footer__timeline-meta-group", children: tail }, "tail"),
|
|
2254
|
+
);
|
|
2012
2255
|
const metaNode = jsx.jsx("span", {
|
|
2013
2256
|
className: "balance-footer__timeline-meta",
|
|
2014
|
-
children:
|
|
2015
|
-
jsx.jsx("span", { className: "balance-footer__timeline-meta-item" + item.cls, children: item.text }, item.text + i)
|
|
2016
|
-
),
|
|
2257
|
+
children: metaChildren,
|
|
2017
2258
|
});
|
|
2018
2259
|
// 新版把「meta 行 + 刻度条」当结构主体,不存在"关掉只剩金额"的形态,
|
|
2019
2260
|
// 所以这里不看 showPeriodLabel;那个开关只在旧版有意义(见下面的 classic 分支)。
|
|
2020
2261
|
const timelineBand = buildTimeline(timelineNow);
|
|
2021
2262
|
// 刷新时不替换时段模块:刻度条保持原样,刷新反馈只落在右侧金额(半透明 + 浪潮)。
|
|
2263
|
+
// 刻度条在 timeline 内部、meta 下方,宽度跟随侧栏(width:100%):
|
|
2264
|
+
// [今日 ¥x / 空闲 剩 3h57m] [胶囊]
|
|
2265
|
+
// [==== 刻度条(跟随侧栏宽度)====]
|
|
2022
2266
|
const periodNode = jsx.jsxs("span", {
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2267
|
+
className: "balance-footer__timeline",
|
|
2268
|
+
children: [
|
|
2269
|
+
metaNode,
|
|
2270
|
+
jsx.jsxs("span", {
|
|
2271
|
+
className: "balance-footer__timeline-track balance-footer__timeline-track--" + period,
|
|
2272
|
+
children: [
|
|
2273
|
+
// 裁剪层让四周各留 1px,颜色不贴边框
|
|
2274
|
+
jsx.jsxs("span", {
|
|
2275
|
+
className: "balance-footer__timeline-clip",
|
|
2276
|
+
children: [
|
|
2277
|
+
// 泛光:同一渐变模糊后叠在色带上面,靠柔边往外散光
|
|
2278
|
+
jsx.jsx("span", {
|
|
2279
|
+
className: "balance-footer__timeline-glow",
|
|
2280
|
+
style: { background: timelineBand.gradient },
|
|
2281
|
+
}),
|
|
2282
|
+
// 色带本体:纯色块,不带任何文字
|
|
2283
|
+
jsx.jsx("span", {
|
|
2284
|
+
className: "balance-footer__timeline-band",
|
|
2285
|
+
style: { background: timelineBand.gradient },
|
|
2286
|
+
}),
|
|
2287
|
+
],
|
|
2288
|
+
}),
|
|
2289
|
+
// 指针:位置 = 此刻在当天的比例,随时间从左走到右
|
|
2290
|
+
jsx.jsx("span", {
|
|
2291
|
+
className: "balance-footer__timeline-pointer",
|
|
2292
|
+
ref: pointerRef,
|
|
2293
|
+
// 首帧先摆到位(rAF 还没起来),之后由 rAF 每帧接管
|
|
2294
|
+
style: { left: pointerPercent(Date.now()).toFixed(3) + "%" },
|
|
2295
|
+
}),
|
|
2296
|
+
],
|
|
2297
|
+
}),
|
|
2052
2298
|
],
|
|
2053
2299
|
});
|
|
2054
2300
|
// 布局:左侧 [meta + 刻度条] 竖排,右侧金额。行高不够时整块自动变高。
|
|
@@ -2072,7 +2318,7 @@ window.__ModuleLoader__.load({
|
|
|
2072
2318
|
jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
|
|
2073
2319
|
jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
|
|
2074
2320
|
jsx.jsxs("span", {
|
|
2075
|
-
className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
|
|
2321
|
+
className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + valueDensity,
|
|
2076
2322
|
children: [
|
|
2077
2323
|
totalText,
|
|
2078
2324
|
// 分隔点用元素而不是字符串:inline-flex 会把匿名文本条目的首尾空白按规范去掉,
|
|
@@ -2093,7 +2339,7 @@ window.__ModuleLoader__.load({
|
|
|
2093
2339
|
? jsx.jsx("span", { className: "balance-footer__spacer" })
|
|
2094
2340
|
: periodNode,
|
|
2095
2341
|
jsx.jsx("span", {
|
|
2096
|
-
className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + (state.refreshing ? " balance-footer__value--stale" : ""),
|
|
2342
|
+
className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + (state.refreshing ? " balance-footer__value--stale" : "") + valueDensity,
|
|
2097
2343
|
children: rollCells === null
|
|
2098
2344
|
? totalText
|
|
2099
2345
|
: jsx.jsx("span", {
|
|
@@ -2555,6 +2801,53 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
2555
2801
|
// 我们只给标题、正文和底部按钮。取不到时退化成纯文字按钮,绝不自己画遮罩。
|
|
2556
2802
|
const tipModal = primitives && typeof primitives.Modal === "function" ? primitives.Modal : null;
|
|
2557
2803
|
const tipButton = primitives && typeof primitives.Button === "function" ? primitives.Button : null;
|
|
2804
|
+
// 节假日数据:状态来自服务端,按钮触发一次强制同步。
|
|
2805
|
+
const calendar = useCalendar();
|
|
2806
|
+
const [calendarSync, setCalendarSync] = react.useState({ kind: "idle", text: "" });
|
|
2807
|
+
const calendarBusy = calendarSync.kind === "syncing";
|
|
2808
|
+
const calendarSummary = (() => {
|
|
2809
|
+
if (calendarSync.kind === "ok" || calendarSync.kind === "error") return calendarSync.text;
|
|
2810
|
+
if (calendar.fetchedAt > 0) {
|
|
2811
|
+
const at = new Date(calendar.fetchedAt);
|
|
2812
|
+
const stamp =
|
|
2813
|
+
at.getFullYear() + "-" + String(at.getMonth() + 1).padStart(2, "0") + "-" +
|
|
2814
|
+
String(at.getDate()).padStart(2, "0") + " " + String(at.getHours()).padStart(2, "0") + ":" +
|
|
2815
|
+
String(at.getMinutes()).padStart(2, "0");
|
|
2816
|
+
const years = calendar.years.length > 0 ? calendar.years.join(" / ") : "—";
|
|
2817
|
+
// 只报日期与时刻,不报完整年份列表:那串太长会把左边的标题挤成一字一行。
|
|
2818
|
+
// 「同步了哪几年」由服务端 days 的键直接决定,用户点「立即同步」那边也能看到。
|
|
2819
|
+
const pending = calendar.missing.length > 0 ? " · " + calendar.missing.join(" / ") + " 待公布" : "";
|
|
2820
|
+
return "已同步 " + calendar.count + " 天 · " + stamp.slice(5) + pending;
|
|
2821
|
+
}
|
|
2822
|
+
return "尚未同步,正在用插件内置表。";
|
|
2823
|
+
})();
|
|
2824
|
+
const onSyncCalendar = async () => {
|
|
2825
|
+
if (calendarBusy) return;
|
|
2826
|
+
setCalendarSync({ kind: "syncing", text: "正在同步…" });
|
|
2827
|
+
try {
|
|
2828
|
+
const data = await syncCalendar();
|
|
2829
|
+
if (data && data.ok === true) {
|
|
2830
|
+
const years = Array.isArray(data.years) && data.years.length > 0 ? data.years.join(" / ") : "—";
|
|
2831
|
+
const pending = Array.isArray(data.pending) && data.pending.length > 0
|
|
2832
|
+
? "(" + data.pending.join(" / ") + " 待公布)"
|
|
2833
|
+
: "";
|
|
2834
|
+
setCalendarSync({
|
|
2835
|
+
kind: "ok",
|
|
2836
|
+
text: "已更新 " + years + "(" + (Number(data.added) || 0) + " 天)" + pending,
|
|
2837
|
+
});
|
|
2838
|
+
} else {
|
|
2839
|
+
setCalendarSync({
|
|
2840
|
+
kind: "error",
|
|
2841
|
+
text: (data && data.error) || "同步失败,继续使用本地数据。",
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
} catch (error) {
|
|
2845
|
+
setCalendarSync({
|
|
2846
|
+
kind: "error",
|
|
2847
|
+
text: "同步失败:" + (error instanceof Error ? error.message : String(error)),
|
|
2848
|
+
});
|
|
2849
|
+
}
|
|
2850
|
+
};
|
|
2558
2851
|
const tipClose = () => setSidebarTip(false);
|
|
2559
2852
|
const tipFooter = tipButton
|
|
2560
2853
|
? jsx.jsx(tipButton, { variant: "primary", onClick: tipClose, children: "知道了" })
|
|
@@ -2767,6 +3060,24 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
2767
3060
|
jsx.jsx("span", { className: "balance-settings__unit", children: unit, key: key + "-unit" }),
|
|
2768
3061
|
]);
|
|
2769
3062
|
|
|
3063
|
+
// 文本行:用于「高峰时段」这类要手写表达式的项。
|
|
3064
|
+
// 边输边存(和 numberRow 一致),半截输入由服务端原样存下、解析失败时回退默认窗口,
|
|
3065
|
+
// 所以打到一半不会被弹回。
|
|
3066
|
+
const textRow = (key, label, hint, placeholder) =>
|
|
3067
|
+
row(
|
|
3068
|
+
key,
|
|
3069
|
+
label,
|
|
3070
|
+
hint,
|
|
3071
|
+
jsx.jsx("input", {
|
|
3072
|
+
className: "balance-settings__input balance-settings__input--text",
|
|
3073
|
+
type: "text",
|
|
3074
|
+
value: value[key] == null ? "" : String(value[key]),
|
|
3075
|
+
placeholder,
|
|
3076
|
+
spellCheck: false,
|
|
3077
|
+
onChange: (event) => patch(key, event.target.value),
|
|
3078
|
+
}),
|
|
3079
|
+
);
|
|
3080
|
+
|
|
2770
3081
|
// onToggle(可选)拿到的是「点完之后的新值」,用来在关掉某项时追加副作用
|
|
2771
3082
|
const toggleRow = (key, label, hint, onToggle) =>
|
|
2772
3083
|
row(
|
|
@@ -3042,7 +3353,45 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
3042
3353
|
]),
|
|
3043
3354
|
|
|
3044
3355
|
group("billing", "计费", [
|
|
3356
|
+
// 官方改时段就等于改「哪几个小时翻倍」。留成可编辑项,
|
|
3357
|
+
// 以后即使没人改代码,用户自己也能跟上官方口径。
|
|
3358
|
+
textRow(
|
|
3359
|
+
"peakWindowsText",
|
|
3360
|
+
"高峰时段",
|
|
3361
|
+
"工作日北京时间的这些时段按高峰计价。格式 9-12, 14-18(官方当前值)。写错了自动沿用上一组有效值。",
|
|
3362
|
+
"9-12, 14-18",
|
|
3363
|
+
),
|
|
3045
3364
|
numberRow("peakMultiplier", "高峰时段倍率", "官方为 2(高峰价 = 空闲价 × 倍率)。", 1, 10, "倍"),
|
|
3365
|
+
// 法定节假日/调休直接决定某天算高峰还是空闲(算钱和标签都靠它),
|
|
3366
|
+
// 内置表只是离线兜底。给一个手动同步按钮:即使哪天没人维护插件,
|
|
3367
|
+
// 用户自己点一下就能把下一年的国务院公告补进来。
|
|
3368
|
+
row(
|
|
3369
|
+
"calendar",
|
|
3370
|
+
"节假日数据",
|
|
3371
|
+
"法定节假日与调休从国务院公告同步。插件每 30 天自动同步一次,这里可以手动立即更新。",
|
|
3372
|
+
jsx.jsxs("div", {
|
|
3373
|
+
className: "balance-settings__calendar",
|
|
3374
|
+
children: [
|
|
3375
|
+
jsx.jsx("span", {
|
|
3376
|
+
className:
|
|
3377
|
+
"balance-settings__calendar-note" +
|
|
3378
|
+
(calendarSync.kind === "error"
|
|
3379
|
+
? " balance-settings__calendar-note--error"
|
|
3380
|
+
: calendar.fetchedAt > 0
|
|
3381
|
+
? ""
|
|
3382
|
+
: " balance-settings__calendar-note--warn"),
|
|
3383
|
+
children: calendarSummary,
|
|
3384
|
+
}),
|
|
3385
|
+
jsx.jsx("button", {
|
|
3386
|
+
type: "button",
|
|
3387
|
+
className: "balance-settings__btn",
|
|
3388
|
+
onClick: onSyncCalendar,
|
|
3389
|
+
disabled: calendarBusy,
|
|
3390
|
+
children: calendarBusy ? "同步中…" : "立即同步",
|
|
3391
|
+
}),
|
|
3392
|
+
],
|
|
3393
|
+
}),
|
|
3394
|
+
),
|
|
3046
3395
|
]),
|
|
3047
3396
|
|
|
3048
3397
|
jsx.jsxs("div", {
|
|
@@ -3171,6 +3520,8 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
3171
3520
|
});
|
|
3172
3521
|
// 启动检查更新:等 UI 先跑起来再查(2.5s),别跟首屏抢网络。
|
|
3173
3522
|
// 每次页面加载 = 每次打开 DeepSeek Harness,符合「每次打开都检查一次」。
|
|
3523
|
+
// 节假日数据:跟更新检查一样是「后台补一次」,但更早 —— 峰谷标签一上来就该是准的。
|
|
3524
|
+
void fetchCalendarStatus();
|
|
3174
3525
|
if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
|
|
3175
3526
|
window.setTimeout(() => {
|
|
3176
3527
|
void checkUpdateOnStartup();
|
package/lib/index.js
CHANGED
|
@@ -118,26 +118,208 @@ const CN_CALENDAR = {
|
|
|
118
118
|
|
|
119
119
|
// 节假日自动补录:内置表仅作**离线兜底**,运行时从社区维护的源拉取(其 papers 字段直接引用国务院公告)。
|
|
120
120
|
// 源不可用/无网络/被 CSP 拦截时静默失败,继续用内置表;拉取结果按日期覆盖同键条目。
|
|
121
|
-
|
|
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
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
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);
|
|
140
320
|
}
|
|
321
|
+
if (cnCalendarState.fetchedAt === 0) cnCalendarState.fetchedAt = Date.now();
|
|
322
|
+
void saveCalendarCache();
|
|
141
323
|
}
|
|
142
324
|
|
|
143
325
|
/** 北京时区日期键 YYYY-MM-DD(传入已 +8h 偏移的 Date)。 */
|
|
@@ -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.
|
|
530
|
+
const PLUGIN_VERSION = "2.0.3";
|
|
349
531
|
|
|
350
532
|
// ─── 检查更新 ───────────────────────────────────────────────────────────────
|
|
351
533
|
// 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
|
|
@@ -878,6 +1060,10 @@ const DEFAULT_SETTINGS = {
|
|
|
878
1060
|
statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
|
|
879
1061
|
statsCacheSec: 300, // 统计结果缓存秒数
|
|
880
1062
|
// 计费
|
|
1063
|
+
// 高峰时段窗口。官方当前是「工作日北京 9-12、14-18」,但这条也是会变的
|
|
1064
|
+
// (改了时段就等于改了哪几个小时翻倍)。写成文本而不是数组:输入框直接对应,
|
|
1065
|
+
// 没有「数组 → 界面 → 数组」的转换损耗,用户手改也不会因为半截输入被弹回。
|
|
1066
|
+
peakWindowsText: "9-12, 14-18",
|
|
881
1067
|
peakMultiplier: 2, // 高峰时段倍率
|
|
882
1068
|
priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
|
|
883
1069
|
pricePro: null, // v4-pro 覆盖价
|
|
@@ -914,6 +1100,38 @@ function normalizeNewTagSeen(raw) {
|
|
|
914
1100
|
return out;
|
|
915
1101
|
}
|
|
916
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
|
+
|
|
917
1135
|
/** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
|
|
918
1136
|
* 四个字段都没填则整组丢弃(等价于不覆盖)。 */
|
|
919
1137
|
function normalizePriceOverride(raw) {
|
|
@@ -954,6 +1172,7 @@ function normalizeSettings(raw) {
|
|
|
954
1172
|
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
955
1173
|
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
956
1174
|
statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
|
|
1175
|
+
peakWindowsText: normalizePeakWindowsText(src.peakWindowsText, d.peakWindowsText),
|
|
957
1176
|
peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
|
|
958
1177
|
priceFlash: normalizePriceOverride(src.priceFlash),
|
|
959
1178
|
pricePro: normalizePriceOverride(src.pricePro),
|
|
@@ -969,6 +1188,9 @@ function withSettings(config, settings) {
|
|
|
969
1188
|
return {
|
|
970
1189
|
...config,
|
|
971
1190
|
apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
|
|
1191
|
+
// 设置里改的高峰窗口优先;文本解析不出来就回退官方默认窗口,
|
|
1192
|
+
// 绝不能把 undefined 透给 isPeakAt(那会让 isPeakAt 自己再 fallback,语义重复)
|
|
1193
|
+
peakWindows: parsePeakWindowsText(s.peakWindowsText) ?? DEFAULT_PEAK_WINDOWS,
|
|
972
1194
|
peakMultiplier: s.peakMultiplier,
|
|
973
1195
|
priceByModel,
|
|
974
1196
|
statsSessionLimit: s.statsSessionLimit,
|
|
@@ -1614,14 +1836,17 @@ export function apply(ctx, config = {}) {
|
|
|
1614
1836
|
// 时统一清理,保证重复 apply 不冲突。
|
|
1615
1837
|
const disposers = [];
|
|
1616
1838
|
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
+
})();
|
|
1625
1850
|
|
|
1626
1851
|
// 1) /balance 命令:保留命令行查询入口
|
|
1627
1852
|
ctx.inject(["commands"], (commandCtx) => {
|
|
@@ -1731,6 +1956,34 @@ export function apply(ctx, config = {}) {
|
|
|
1731
1956
|
}
|
|
1732
1957
|
return;
|
|
1733
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
|
+
}
|
|
1734
1987
|
if (req.method !== "GET") {
|
|
1735
1988
|
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1736
1989
|
return;
|