dsh-peak-status 1.0.2 → 1.0.4
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 +23 -1
- package/README.zh-CN.md +23 -1
- package/lib/client.js +390 -61
- package/lib/index.js +214 -5
- package/package.json +3 -2
- package/src/client.js +390 -61
- package/src/index.js +214 -5
package/README.md
CHANGED
|
@@ -17,10 +17,20 @@ DeepSeek's official peak/valley API pricing (effective 2026-08-17, weekends adju
|
|
|
17
17
|
|
|
18
18
|
Weekends (Saturday and Sunday) are charged at the valley price all day — no peak windows on weekends.
|
|
19
19
|
|
|
20
|
+
The Chinese official work calendar refines the weekend half of that rule:
|
|
21
|
+
|
|
22
|
+
| Day | State (Beijing time) |
|
|
23
|
+
| :-- | :-- |
|
|
24
|
+
| 法定节假日 (statutory holiday) | valley for the whole day, exactly like a weekend |
|
|
25
|
+
| 调休 makeup workday (a weekend worked) | the normal weekday windows apply |
|
|
26
|
+
|
|
27
|
+
So the chip shows 🟢 **国庆节** instead of a red peak on 1 October, and 🔴 peak windows on a 调休 Saturday.
|
|
28
|
+
|
|
20
29
|
## What you get
|
|
21
30
|
|
|
22
31
|
- A colored status dot — **red = peak, green = off-peak** — plus the live Beijing clock (`HH:MM:SS`, timezone `Asia/Shanghai`, independent of your machine's timezone)
|
|
23
32
|
- A countdown: `剩 3h 54m · 价格 ×2` during peak / `距高峰 3h 54m · 半价` off-peak
|
|
33
|
+
- Holiday awareness: on a 法定节假日 the chip names the festival (e.g. `国庆节`) and counts down to the first peak window after the break; on a 调休 workday it shows the normal peak state and tags the day
|
|
24
34
|
- Click the chip to expand a detail card: peak windows, the next transition with a `HH:MM:SS` countdown, and a progress bar of the current phase
|
|
25
35
|
- Fully themed with the web UI's native `--dsw-alias-*` variables — it blends into the interface instead of floating over it
|
|
26
36
|
- Mounted persistently: it survives dsh web restarts
|
|
@@ -41,10 +51,22 @@ Then restart `dsh web` (or refresh the page). The chip appears at the top-right
|
|
|
41
51
|
|
|
42
52
|
## Usage
|
|
43
53
|
|
|
44
|
-
The chip updates every second. Click it to expand/collapse the detail card; hover for a quick tooltip. Peak/off-peak state follows the official schedule above, computed in Beijing time.
|
|
54
|
+
The chip updates every second. Click it to expand/collapse the detail card; hover for a quick tooltip. Peak/off-peak state follows the official schedule above, computed in Beijing time over the official work calendar.
|
|
45
55
|
|
|
46
56
|
If DeepSeek ever changes the peak schedule, a new release will ship with the updated windows — open an issue on this repository if you notice a mismatch.
|
|
47
57
|
|
|
58
|
+
## Holiday calendar
|
|
59
|
+
|
|
60
|
+
The calendar (法定节假日 + 调休 makeup days) is fetched over the network, with three layers of fallback:
|
|
61
|
+
|
|
62
|
+
1. **Host route** — the plugin's host half serves a same-origin route `/dsh-peak-status/holiday/<year>`. It tries jiejiariapi first and apihubs second, normalizes both shapes and caches the result in memory for 6 hours, so every browser tab shares one fetch.
|
|
63
|
+
2. **Browser fallback** — if that route is unavailable, the browser half calls `api.apihubs.cn` directly (it is CORS-enabled).
|
|
64
|
+
3. **Plain weekend rule** — with no calendar at all the chip behaves exactly like 1.0.3: weekday windows plus weekend valley.
|
|
65
|
+
|
|
66
|
+
Normalized days are cached in `localStorage` for 24 hours, so a restart or a spell offline still renders the right state, and the detail card always reports the source (`已同步 · jiejiariapi` / `未同步 · 暂按周末规则`).
|
|
67
|
+
|
|
68
|
+
This is the *official Chinese work calendar* reading of the pricing: statutory holidays are valley and 调休 days are workdays. DeepSeek's published adjustment only mentions Saturday and Sunday — if the calendar ever diverges from what is billed, the chip follows the calendar.
|
|
69
|
+
|
|
48
70
|
## License
|
|
49
71
|
|
|
50
72
|
[MIT](LICENSE)
|
package/README.zh-CN.md
CHANGED
|
@@ -17,10 +17,20 @@ DeepSeek 官方峰谷定价(2026-08-17 生效,2026-08-23 起周末调整)
|
|
|
17
17
|
|
|
18
18
|
周末(周六、周日)全天按谷价计费,不设高峰时段。
|
|
19
19
|
|
|
20
|
+
中国官方工作日历对"周末"这一半规则做了细化:
|
|
21
|
+
|
|
22
|
+
| 日期 | 状态(北京时间) |
|
|
23
|
+
| :-- | :-- |
|
|
24
|
+
| 法定节假日 | 全天谷价,与周末完全一致 |
|
|
25
|
+
| 调休上班日(周末上班) | 照工作日高峰窗口计费 |
|
|
26
|
+
|
|
27
|
+
所以 10 月 1 日胶囊显示的是 🟢 **国庆节**,而不是红色高峰;调休的周六则照常显示 🔴 高峰窗口。
|
|
28
|
+
|
|
20
29
|
## 功能
|
|
21
30
|
|
|
22
31
|
- 状态圆点——**红 = 高峰,绿 = 空闲**,外加实时北京时钟(`HH:MM:SS`,时区 `Asia/Shanghai`,与本机时区无关)
|
|
23
32
|
- 倒计时:高峰时显示 `剩 3h 54m · 价格 ×2`,空闲时显示 `距高峰 3h 54m · 半价`
|
|
33
|
+
- 节假日感知:法定节假日直接显示节日名(如 `国庆节`),并倒计时到假期结束后的第一个高峰窗口;调休上班日显示为正常高峰状态并标注该日
|
|
24
34
|
- 点击胶囊展开详情卡:高峰窗口、下一转折(含 `HH:MM:SS` 倒计时)、当前阶段进度条
|
|
25
35
|
- 全部使用 Web UI 原生 `--dsw-alias-*` 主题变量,与界面完全融合,没有悬浮贴片
|
|
26
36
|
- 持久挂载:重启 dsh web 后依然存在
|
|
@@ -41,10 +51,22 @@ dsh plugin --profile web add github:xiaoyi-xx/dsh-peak-status
|
|
|
41
51
|
|
|
42
52
|
## 使用
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
胶囊每秒自动更新。点击展开/收起详情卡,悬停有快捷提示。高峰/空闲状态严格按上面官方时刻表、以北京时间计算,并叠加中国官方工作日历。
|
|
45
55
|
|
|
46
56
|
如果 DeepSeek 以后调整高峰时段,我们会发布包含新时段的新版本——发现不一致欢迎在仓库提 Issue。
|
|
47
57
|
|
|
58
|
+
## 节假日表
|
|
59
|
+
|
|
60
|
+
节假日表(法定节假日 + 调休上班日)通过网络获取,共三层降级:
|
|
61
|
+
|
|
62
|
+
1. **host 同源路由** —— 插件 host 半提供 `/dsh-peak-status/holiday/<year>`。它先取 jiejiariapi、失败再取 apihubs,归一化两种返回结构并在内存缓存 6 小时,所有浏览器标签页共用一次拉取。
|
|
63
|
+
2. **浏览器直连兜底** —— 路由不可用时,浏览器半改直连 `api.apihubs.cn`(该源允许跨域)。
|
|
64
|
+
3. **纯周末规则** —— 完全拿不到日历时,胶囊行为与 1.0.3 完全一致:工作日窗口 + 周末全谷价。
|
|
65
|
+
|
|
66
|
+
归一化后的结果还会写入 `localStorage` 缓存 24 小时,所以重启或短暂断网时状态依旧正确;详情卡始终标注数据来源(`已同步 · jiejiariapi` / `未同步 · 暂按周末规则`)。
|
|
67
|
+
|
|
68
|
+
需要说明:这是按**中国官方工作日历**理解峰谷计价——法定节假日算谷价、调休日算工作日。DeepSeek 公布的调整只提到周六与周日;若官方日历与实际计费口径出现分歧,胶囊以日历为准。
|
|
69
|
+
|
|
48
70
|
## 许可证
|
|
49
71
|
|
|
50
72
|
[MIT](LICENSE)
|
package/lib/client.js
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
* weekends: Saturday & Sunday all day at the valley price
|
|
14
14
|
* off-peak: everything else (priced at half of peak)
|
|
15
15
|
*
|
|
16
|
+
* The Chinese official work calendar refines the weekend half of that rule:
|
|
17
|
+
* 法定节假日 (holiday rest days) are valley all day, and 调休 makeup days keep
|
|
18
|
+
* the normal weekday windows. The calendar is fetched over the network — the
|
|
19
|
+
* host half serves it same-origin, a CORS-capable public source and finally
|
|
20
|
+
* the plain weekend rule are the fallbacks — and cached in localStorage.
|
|
21
|
+
*
|
|
16
22
|
* Red = peak (bold), green = off-peak; live Beijing clock, countdown to the
|
|
17
23
|
* next transition, click to expand a small popover with the full detail.
|
|
18
24
|
* Mounted persistently via the profile's cordis.patch.yml, so it survives
|
|
@@ -22,15 +28,32 @@
|
|
|
22
28
|
// The host client-modules expects a bundle to register the id of the entry
|
|
23
29
|
// it is mounted as — the package name ("@local/dsh-peak-status" for a
|
|
24
30
|
// profile @local install, "dsh-peak-status" when installed from npm).
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
31
|
+
//
|
|
32
|
+
// Single-file loads use /plugins/<id>/client.js, so the id is derivable
|
|
33
|
+
// from document.currentScript.src. Since dsh 0.1.2-alpha.3 the web shell
|
|
34
|
+
// may serve plugin bundles as one shared combo script
|
|
35
|
+
// (/plugins/??<id>/client.js,<id2>/client.js&rev=...) — currentScript.src
|
|
36
|
+
// then names every bundle in the batch, not this one. Fall back to
|
|
37
|
+
// scanning the combo URL for our own package leaf, which is present in
|
|
38
|
+
// both install shapes.
|
|
28
39
|
var MODULE_ID = (function () {
|
|
40
|
+
var OWN = 'dsh-peak-status'
|
|
29
41
|
try {
|
|
30
42
|
var el = document.currentScript
|
|
31
43
|
if (el && el.src) {
|
|
32
|
-
var
|
|
33
|
-
|
|
44
|
+
var src = el.src
|
|
45
|
+
var single = /\/plugins\/([^/]+)\/client\.js(?:\?|$)/.exec(src)
|
|
46
|
+
if (single) return decodeURIComponent(single[1])
|
|
47
|
+
var combo = /\/plugins\/\?\?([^&]+)/.exec(src)
|
|
48
|
+
if (combo) {
|
|
49
|
+
var parts = combo[1].split(',')
|
|
50
|
+
for (var i = 0; i < parts.length; i++) {
|
|
51
|
+
var m = /^(.+?)\/client\.js$/.exec(parts[i])
|
|
52
|
+
if (m && decodeURIComponent(m[1]).split('/').pop() === OWN) {
|
|
53
|
+
return decodeURIComponent(m[1])
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
34
57
|
}
|
|
35
58
|
} catch (error) { /* ignore */ }
|
|
36
59
|
return null
|
|
@@ -54,10 +77,14 @@
|
|
|
54
77
|
var PEAK_COLOR = 'var(--dsw-alias-state-error-primary, #e5484d)'
|
|
55
78
|
var OFF_COLOR = 'var(--dsw-alias-state-success-primary, #30a46c)'
|
|
56
79
|
|
|
57
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Beijing time parts: calendar date (y/mo/d), clock, and ISO weekday
|
|
82
|
+
* (1=Mon … 7=Sun). `mo` is the month; minutes are `m`, timezone-proof.
|
|
83
|
+
*/
|
|
58
84
|
function bjParts() {
|
|
59
85
|
var parts = new Intl.DateTimeFormat('en-GB', {
|
|
60
86
|
timeZone: TZ, hour12: false, weekday: 'short',
|
|
87
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
61
88
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
62
89
|
}).formatToParts(new Date())
|
|
63
90
|
var get = function (type) {
|
|
@@ -68,6 +95,9 @@
|
|
|
68
95
|
}
|
|
69
96
|
var wdMap = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
|
|
70
97
|
return {
|
|
98
|
+
y: Number(get('year')),
|
|
99
|
+
mo: Number(get('month')),
|
|
100
|
+
d: Number(get('day')),
|
|
71
101
|
h: Number(get('hour')),
|
|
72
102
|
m: Number(get('minute')),
|
|
73
103
|
s: Number(get('second')),
|
|
@@ -75,53 +105,302 @@
|
|
|
75
105
|
}
|
|
76
106
|
}
|
|
77
107
|
|
|
108
|
+
// ── 节假日表(网络拉取 + 本地缓存) ────────────────────────────────────
|
|
109
|
+
// 数据源优先级:host 半同源路由 → 浏览器可直连的 CORS 源 → 内置周末规则。
|
|
110
|
+
// 全部失败时退化成 1.0.3 的行为:胶囊照常工作,只在详情里标注未同步。
|
|
111
|
+
var CAL_ROUTE = '/dsh-peak-status/holiday/'
|
|
112
|
+
var CAL_CORS_URL = function (year) { return 'https://api.apihubs.cn/holiday/get?year=' + year + '&size=400' }
|
|
113
|
+
var CAL_TTL_MS = 24 * 60 * 60 * 1000
|
|
114
|
+
var CAL_TIMEOUT_MS = 8000
|
|
115
|
+
var CAL_KEY = 'dsh-peak-status:holiday:'
|
|
116
|
+
var CAL_SCAN_LIMIT = 40
|
|
117
|
+
|
|
118
|
+
/** year → { days, source, fetchedAt };pending[year] 防止并发重复拉取。 */
|
|
119
|
+
var cal = { years: {}, pending: {}, listeners: [] }
|
|
120
|
+
|
|
121
|
+
/** 订阅节假日表变化,返回退订函数(React 侧用它触发重渲染)。 */
|
|
122
|
+
function calSubscribe(listener) {
|
|
123
|
+
cal.listeners.push(listener)
|
|
124
|
+
return function () {
|
|
125
|
+
var index = cal.listeners.indexOf(listener)
|
|
126
|
+
if (index >= 0) cal.listeners.splice(index, 1)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function calNotify() {
|
|
131
|
+
for (var i = 0; i < cal.listeners.length; i++) {
|
|
132
|
+
try { cal.listeners[i]() } catch (error) { /* 单个订阅者失败不影响其它 */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 'YYYY-MM-DD' —— 日历表的键。 */
|
|
137
|
+
function dayKey(y, mo, d) { return y + '-' + pad(mo) + '-' + pad(d) }
|
|
138
|
+
|
|
139
|
+
/** 自 1970-01-01 起的天序号;用 UTC 换算,绕开本地时区与夏令时。 */
|
|
140
|
+
function dayOrdinal(y, mo, d) { return Math.round(Date.UTC(y, mo - 1, d) / 86400000) }
|
|
141
|
+
function ordinalToParts(ordinal) {
|
|
142
|
+
var at = new Date(ordinal * 86400000)
|
|
143
|
+
return { y: at.getUTCFullYear(), mo: at.getUTCMonth() + 1, d: at.getUTCDate() }
|
|
144
|
+
}
|
|
145
|
+
/** 1970-01-01 是周四 → ISO 1=周一 … 7=周日。 */
|
|
146
|
+
function weekdayOf(ordinal) { return ((ordinal + 3) % 7 + 7) % 7 + 1 }
|
|
147
|
+
|
|
148
|
+
function storageGet(key) {
|
|
149
|
+
try { return window.localStorage.getItem(key) } catch (error) { return null }
|
|
150
|
+
}
|
|
151
|
+
function storageSet(key, value) {
|
|
152
|
+
try { window.localStorage.setItem(key, value) } catch (error) { /* 隐私模式/配额满:忽略 */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `20261001` 或 `2026-10-01` → `2026-10-01`;其它 → null。 */
|
|
156
|
+
function toIsoDate(value) {
|
|
157
|
+
if (typeof value === 'string') return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null
|
|
158
|
+
if (typeof value !== 'number' || !isFinite(value)) return null
|
|
159
|
+
var text = String(Math.trunc(value))
|
|
160
|
+
return /^\d{8}$/.test(text) ? text.slice(0, 4) + '-' + text.slice(4, 6) + '-' + text.slice(6, 8) : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 只保留结构正确的条目,脏缓存不至于把 UI 弄崩。 */
|
|
164
|
+
function sanitizeDays(days) {
|
|
165
|
+
if (days === null || typeof days !== 'object') return null
|
|
166
|
+
var out = {}
|
|
167
|
+
var count = 0
|
|
168
|
+
for (var key in days) {
|
|
169
|
+
if (!Object.prototype.hasOwnProperty.call(days, key)) continue
|
|
170
|
+
var row = days[key]
|
|
171
|
+
if (row === null || typeof row !== 'object' || typeof row.off !== 'boolean') continue
|
|
172
|
+
out[key] = { name: typeof row.name === 'string' ? row.name : '', off: row.off }
|
|
173
|
+
count++
|
|
174
|
+
}
|
|
175
|
+
return count > 0 ? out : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** jiejiariapi:{ 'YYYY-MM-DD': { date, name, isOffDay } },只列特殊日。 */
|
|
179
|
+
function fromJiejiariapi(payload) {
|
|
180
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
181
|
+
var days = {}
|
|
182
|
+
var count = 0
|
|
183
|
+
for (var key in payload) {
|
|
184
|
+
if (!Object.prototype.hasOwnProperty.call(payload, key)) continue
|
|
185
|
+
var row = payload[key]
|
|
186
|
+
if (row === null || typeof row !== 'object' || typeof row.isOffDay !== 'boolean') continue
|
|
187
|
+
var date = toIsoDate(row.date === undefined ? key : row.date)
|
|
188
|
+
if (date === null) continue
|
|
189
|
+
days[date] = { name: typeof row.name === 'string' ? row.name : '', off: row.isOffDay }
|
|
190
|
+
count++
|
|
191
|
+
}
|
|
192
|
+
return count > 0 ? days : null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* apihubs:{ data: { list: [ { date: 20261001, holiday_recess, holiday_overtime } ] } }。
|
|
197
|
+
* 数字是节日编码:holiday_recess=1 放假,holiday_overtime≠10 为调休补班(见下方注释)。
|
|
198
|
+
*/
|
|
199
|
+
function fromApihubs(payload) {
|
|
200
|
+
if (payload === null || typeof payload !== 'object') return null
|
|
201
|
+
var data = payload.data !== null && typeof payload.data === 'object' ? payload.data : payload
|
|
202
|
+
var list = Array.isArray(data.list) ? data.list : (Array.isArray(data) ? data : null)
|
|
203
|
+
if (list === null) return null
|
|
204
|
+
var days = {}
|
|
205
|
+
var count = 0
|
|
206
|
+
for (var i = 0; i < list.length; i++) {
|
|
207
|
+
var row = list[i]
|
|
208
|
+
if (row === null || typeof row !== 'object') continue
|
|
209
|
+
var date = toIsoDate(row.date)
|
|
210
|
+
if (date === null) continue
|
|
211
|
+
// apihubs 用节日编号编码:holiday_recess=1 是放假日,holiday_overtime
|
|
212
|
+
// 为 10 表示非调休,其余值(11/22/55/88…)都是周末补班。
|
|
213
|
+
var off = row.holiday_recess === 1
|
|
214
|
+
var overtime = typeof row.holiday_overtime === 'number' && row.holiday_overtime !== 10
|
|
215
|
+
if (!off && !overtime) continue
|
|
216
|
+
days[date] = { name: '', off: off }
|
|
217
|
+
count++
|
|
218
|
+
}
|
|
219
|
+
return count > 0 ? days : null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 归一化任一支持的载荷(host 路由 / jiejiariapi / apihubs)。 */
|
|
223
|
+
function normalizeCalendar(payload) {
|
|
224
|
+
if (payload === null || typeof payload !== 'object') return null
|
|
225
|
+
if (payload.days !== undefined) return sanitizeDays(payload.days)
|
|
226
|
+
return fromJiejiariapi(payload) || fromApihubs(payload)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 带超时的 JSON 拉取;浏览器不支持 fetch 时直接失败,走下一层降级。 */
|
|
230
|
+
function fetchJson(url) {
|
|
231
|
+
return new Promise(function (resolve, reject) {
|
|
232
|
+
if (typeof fetch !== 'function') { reject(new Error('fetch unavailable')); return }
|
|
233
|
+
var controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
234
|
+
var timer = setTimeout(function () {
|
|
235
|
+
if (controller !== null) controller.abort()
|
|
236
|
+
reject(new Error('timeout'))
|
|
237
|
+
}, CAL_TIMEOUT_MS)
|
|
238
|
+
var options = { headers: { accept: 'application/json' } }
|
|
239
|
+
if (controller !== null) options.signal = controller.signal
|
|
240
|
+
fetch(url, options).then(function (response) {
|
|
241
|
+
if (!response.ok) throw new Error('HTTP ' + response.status)
|
|
242
|
+
return response.json()
|
|
243
|
+
}).then(function (json) {
|
|
244
|
+
clearTimeout(timer)
|
|
245
|
+
resolve(json)
|
|
246
|
+
}, function (error) {
|
|
247
|
+
clearTimeout(timer)
|
|
248
|
+
reject(error)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 读缓存:内存 → localStorage。 */
|
|
254
|
+
function cachedYear(year) {
|
|
255
|
+
var entry = cal.years[year]
|
|
256
|
+
if (entry !== undefined) return entry
|
|
257
|
+
var raw = storageGet(CAL_KEY + year)
|
|
258
|
+
if (raw === null) return null
|
|
259
|
+
var parsed = null
|
|
260
|
+
try { parsed = JSON.parse(raw) } catch (error) { return null }
|
|
261
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
262
|
+
var days = sanitizeDays(parsed.days)
|
|
263
|
+
if (days === null) return null
|
|
264
|
+
entry = { days: days, source: String(parsed.source || 'cache'), fetchedAt: Number(parsed.fetchedAt) || 0 }
|
|
265
|
+
cal.years[year] = entry
|
|
266
|
+
return entry
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 拉取一年:先同源路由(含节日名),再浏览器可直连的 CORS 源。 */
|
|
270
|
+
function loadYear(year) {
|
|
271
|
+
return fetchJson(CAL_ROUTE + year).then(function (payload) {
|
|
272
|
+
var days = normalizeCalendar(payload)
|
|
273
|
+
if (days === null) return null
|
|
274
|
+
return {
|
|
275
|
+
days: days,
|
|
276
|
+
source: String(payload === null || payload === undefined || payload.source === undefined ? 'host' : payload.source),
|
|
277
|
+
fetchedAt: Date.now(),
|
|
278
|
+
}
|
|
279
|
+
}, function () { return null }).then(function (entry) {
|
|
280
|
+
if (entry !== null) return entry
|
|
281
|
+
return fetchJson(CAL_CORS_URL(year)).then(function (payload) {
|
|
282
|
+
var days = normalizeCalendar(payload)
|
|
283
|
+
return days === null ? null : { days: days, source: 'apihubs', fetchedAt: Date.now() }
|
|
284
|
+
}, function () { return null })
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 需要时异步补拉一年;命中新鲜缓存或已在拉取则直接返回。 */
|
|
289
|
+
function ensureYear(year) {
|
|
290
|
+
var entry = cachedYear(year)
|
|
291
|
+
if (entry !== null && Date.now() - entry.fetchedAt < CAL_TTL_MS) return
|
|
292
|
+
if (cal.pending[year] === true) return
|
|
293
|
+
cal.pending[year] = true
|
|
294
|
+
loadYear(year).then(function (loaded) {
|
|
295
|
+
cal.pending[year] = false
|
|
296
|
+
if (loaded === null) { calNotify(); return }
|
|
297
|
+
cal.years[year] = loaded
|
|
298
|
+
storageSet(CAL_KEY + year, JSON.stringify({ source: loaded.source, fetchedAt: loaded.fetchedAt, days: loaded.days }))
|
|
299
|
+
calNotify()
|
|
300
|
+
}, function () {
|
|
301
|
+
cal.pending[year] = false
|
|
302
|
+
calNotify()
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 该年的日历;未同步/未缓存 → null,调用方按周末规则退化。 */
|
|
307
|
+
function daysOfYear(year) {
|
|
308
|
+
var entry = cachedYear(year)
|
|
309
|
+
return entry === null ? null : entry.days
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 详情卡里的同步状态文案。 */
|
|
313
|
+
function calStatusText(year) {
|
|
314
|
+
if (cal.years[year] !== undefined) return '已同步 · ' + cal.years[year].source
|
|
315
|
+
if (cal.pending[year] === true) return '同步中…'
|
|
316
|
+
return '未同步 · 暂按周末规则'
|
|
317
|
+
}
|
|
318
|
+
|
|
78
319
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
320
|
+
* 一天的工作/休息属性。
|
|
321
|
+
* 有日历:法定节假日 = 全天谷价;调休上班日 = 照工作日窗口;其余照常。
|
|
322
|
+
* 无日历:退化成 1.0.3 的规则(周六周日休息,其余工作日)。
|
|
323
|
+
* @param key - 'YYYY-MM-DD'。
|
|
324
|
+
* @param weekday - ISO 1=周一 … 7=周日。
|
|
325
|
+
* @param days - 该年归一化日历或 null。
|
|
84
326
|
*/
|
|
85
|
-
function
|
|
86
|
-
var
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
327
|
+
function classifyDay(key, weekday, days) {
|
|
328
|
+
var row = days === null || days === undefined ? null : days[key]
|
|
329
|
+
if (row !== undefined && row !== null) {
|
|
330
|
+
if (row.off === true) return { rest: true, kind: 'holiday', name: row.name || '' }
|
|
331
|
+
if (weekday === 6 || weekday === 7) return { rest: false, kind: 'makeup', name: row.name || '' }
|
|
332
|
+
return { rest: false, kind: 'weekday', name: row.name || '' }
|
|
333
|
+
}
|
|
334
|
+
if (weekday === 6 || weekday === 7) return { rest: true, kind: 'weekend', name: '' }
|
|
335
|
+
return { rest: false, kind: 'weekday', name: '' }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function classifyOrdinal(ordinal, daysOf) {
|
|
339
|
+
var parts = ordinalToParts(ordinal)
|
|
340
|
+
return classifyDay(dayKey(parts.y, parts.mo, parts.d), weekdayOf(ordinal), daysOf(parts.y))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 下一个转折点:当天剩下的窗口边界,或下一个工作日 09:00(可跨假期)。 */
|
|
344
|
+
function nextTransition(ordinal, nowMin, daysOf) {
|
|
345
|
+
if (!classifyOrdinal(ordinal, daysOf).rest) {
|
|
100
346
|
for (var i = 0; i < BOUNDS.length; i++) {
|
|
101
|
-
if (BOUNDS[i] >
|
|
347
|
+
if (BOUNDS[i] > nowMin) return { ordinal: ordinal, min: BOUNDS[i] }
|
|
102
348
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
phaseStartMin = BOUNDS[3]
|
|
117
|
-
phaseLenMin = daysAhead * 1440 + BOUNDS[0] - phaseStartMin
|
|
349
|
+
}
|
|
350
|
+
for (var step = 1; step <= CAL_SCAN_LIMIT; step++) {
|
|
351
|
+
var ahead = ordinal + step
|
|
352
|
+
if (!classifyOrdinal(ahead, daysOf).rest) return { ordinal: ahead, min: BOUNDS[0] }
|
|
353
|
+
}
|
|
354
|
+
return { ordinal: ordinal + 1, min: BOUNDS[0] } // 40 天内没有工作日:理论上不可达
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 上一个转折点:当天已过的最后一个边界,或上一个工作日 18:00。 */
|
|
358
|
+
function prevTransition(ordinal, nowMin, daysOf) {
|
|
359
|
+
if (!classifyOrdinal(ordinal, daysOf).rest) {
|
|
360
|
+
for (var i = BOUNDS.length - 1; i >= 0; i--) {
|
|
361
|
+
if (BOUNDS[i] <= nowMin) return { ordinal: ordinal, min: BOUNDS[i] }
|
|
118
362
|
}
|
|
119
363
|
}
|
|
364
|
+
for (var step = 1; step <= CAL_SCAN_LIMIT; step++) {
|
|
365
|
+
var behind = ordinal - step
|
|
366
|
+
if (!classifyOrdinal(behind, daysOf).rest) return { ordinal: behind, min: BOUNDS[3] }
|
|
367
|
+
}
|
|
368
|
+
return { ordinal: ordinal - 1, min: BOUNDS[3] } // 40 天内没有工作日:理论上不可达
|
|
369
|
+
}
|
|
120
370
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Peak/off-peak state at one Beijing-time instant.
|
|
373
|
+
* The official work calendar decides which days may peak at all: holiday
|
|
374
|
+
* rest days are valley across their whole span and makeup workdays keep the
|
|
375
|
+
* weekday windows, so both the current state and its neighbouring
|
|
376
|
+
* transitions are found by scanning days instead of weekday arithmetic.
|
|
377
|
+
* @param t - bjParts() result (calendar date, clock, ISO weekday).
|
|
378
|
+
* @param daysOf - year → normalized days; defaults to the loaded cache.
|
|
379
|
+
* @returns { peak, nextMin, daysAhead, nextOrdinal, nextWeekday, secsToNext, progress, day }
|
|
380
|
+
*/
|
|
381
|
+
function compute(t, daysOf) {
|
|
382
|
+
var lookup = daysOf === undefined ? daysOfYear : daysOf
|
|
383
|
+
var ordinal = dayOrdinal(t.y, t.mo, t.d)
|
|
384
|
+
var nowMin = t.h * 60 + t.m
|
|
385
|
+
var day = classifyDay(dayKey(t.y, t.mo, t.d), t.weekday, lookup(t.y))
|
|
386
|
+
var peak = !day.rest &&
|
|
387
|
+
((nowMin >= BOUNDS[0] && nowMin < BOUNDS[1]) || (nowMin >= BOUNDS[2] && nowMin < BOUNDS[3]))
|
|
388
|
+
var next = nextTransition(ordinal, nowMin, lookup)
|
|
389
|
+
var prev = prevTransition(ordinal, nowMin, lookup)
|
|
390
|
+
var nowAbs = ordinal * 1440 + nowMin
|
|
391
|
+
var nextAbs = next.ordinal * 1440 + next.min
|
|
392
|
+
var prevAbs = prev.ordinal * 1440 + prev.min
|
|
393
|
+
var phaseLen = nextAbs - prevAbs
|
|
394
|
+
return {
|
|
395
|
+
peak: peak,
|
|
396
|
+
nextMin: next.min,
|
|
397
|
+
daysAhead: next.ordinal - ordinal,
|
|
398
|
+
nextOrdinal: next.ordinal,
|
|
399
|
+
nextWeekday: weekdayOf(next.ordinal),
|
|
400
|
+
secsToNext: (nextAbs - nowAbs) * 60 - t.s,
|
|
401
|
+
progress: phaseLen > 0 ? Math.min(100, Math.max(0, ((nowAbs - prevAbs) / phaseLen) * 100)) : 0,
|
|
402
|
+
day: day,
|
|
403
|
+
}
|
|
125
404
|
}
|
|
126
405
|
|
|
127
406
|
function pad(n) { return (n < 10 ? '0' : '') + n }
|
|
@@ -142,12 +421,17 @@
|
|
|
142
421
|
var sec = s % 60
|
|
143
422
|
return pad(h) + ':' + pad(m) + ':' + pad(sec)
|
|
144
423
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
424
|
+
/**
|
|
425
|
+
* Human label for the next boundary: "18:00" / "明天 09:00" / "周一 09:00",
|
|
426
|
+
* and an explicit date once the jump passes a week — a 国庆 break or a 调休
|
|
427
|
+
* week easily spans six days and a weekday name would read as next week.
|
|
428
|
+
*/
|
|
429
|
+
function fmtBoundaryLabel(s) {
|
|
147
430
|
if (s.daysAhead === 0) return fmtBoundary(s.nextMin)
|
|
148
431
|
if (s.daysAhead === 1) return '明天 ' + fmtBoundary(s.nextMin)
|
|
149
|
-
|
|
150
|
-
|
|
432
|
+
if (s.daysAhead <= 6) return '周' + WEEKDAY_LABELS[s.nextWeekday - 1] + ' ' + fmtBoundary(s.nextMin)
|
|
433
|
+
var parts = ordinalToParts(s.nextOrdinal)
|
|
434
|
+
return pad(parts.mo) + '-' + pad(parts.d) + ' ' + fmtBoundary(s.nextMin)
|
|
151
435
|
}
|
|
152
436
|
|
|
153
437
|
function PeakChip() {
|
|
@@ -156,25 +440,49 @@
|
|
|
156
440
|
var setTick = tickState[1]
|
|
157
441
|
var open = react.useState(false)
|
|
158
442
|
var setOpen = open[1]
|
|
443
|
+
var setBump = react.useState(0)[1]
|
|
159
444
|
|
|
160
445
|
react.useEffect(function () {
|
|
161
|
-
var id = setInterval(function () {
|
|
446
|
+
var id = setInterval(function () {
|
|
447
|
+
var next = bjParts()
|
|
448
|
+
setTick(next)
|
|
449
|
+
ensureYear(next.y)
|
|
450
|
+
if (next.mo === 12) ensureYear(next.y + 1) // 跨年:提前取下一年的表
|
|
451
|
+
}, 1000)
|
|
162
452
|
return function () { clearInterval(id) }
|
|
163
453
|
}, [])
|
|
164
454
|
|
|
455
|
+
// The calendar arrives asynchronously — re-render when it lands. Until
|
|
456
|
+
// then compute() degrades to the plain weekday/weekend rule on its own.
|
|
457
|
+
react.useEffect(function () {
|
|
458
|
+
return calSubscribe(function () { setBump(function (n) { return n + 1 }) })
|
|
459
|
+
}, [])
|
|
460
|
+
|
|
165
461
|
var s = compute(tick)
|
|
166
|
-
var
|
|
462
|
+
var day = s.day
|
|
167
463
|
var color = s.peak ? PEAK_COLOR : OFF_COLOR
|
|
168
464
|
var clock = fmtClock(tick)
|
|
169
|
-
var boundaryLabel = fmtBoundaryLabel(
|
|
465
|
+
var boundaryLabel = fmtBoundaryLabel(s)
|
|
466
|
+
var stateWord = s.peak
|
|
467
|
+
? '高峰期'
|
|
468
|
+
: (day.kind === 'holiday' ? (day.name || '法定节假日') : (day.kind === 'weekend' ? '周末谷价' : '空闲期'))
|
|
469
|
+
var dayNote = day.kind === 'holiday'
|
|
470
|
+
? ('今日' + (day.name === '' ? '为法定节假日' : '为' + day.name) + ':全天按谷价计费。')
|
|
471
|
+
: (day.kind === 'makeup'
|
|
472
|
+
? ('今日为调休上班日(周' + WEEKDAY_LABELS[tick.weekday - 1] + '):高峰窗口照常。')
|
|
473
|
+
: '')
|
|
474
|
+
var offNote = day.kind === 'holiday'
|
|
475
|
+
? ('(' + (day.name === '' ? '法定节假日' : day.name) + ' 全天谷价)')
|
|
476
|
+
: (day.kind === 'weekend' ? '(周末全天谷价)' : '')
|
|
477
|
+
var tooltip = (s.peak
|
|
478
|
+
? '高峰期(工作日 9:00-12:00 / 14:00-18:00):价格 = 空闲 ×2。' + boundaryLabel + ' 结束。'
|
|
479
|
+
: '空闲期:价格为高峰一半' + offNote + ',下一高峰 ' + boundaryLabel + '。') + dayNote + '点击展开详情'
|
|
170
480
|
|
|
171
481
|
var chip = react.createElement(
|
|
172
482
|
'div',
|
|
173
483
|
{
|
|
174
484
|
onClick: function () { setOpen(!open[0]) },
|
|
175
|
-
title:
|
|
176
|
-
? '高峰期(工作日 9:00-12:00 / 14:00-18:00):价格 = 空闲 ×2。' + boundaryLabel + ' 结束,点击展开详情'
|
|
177
|
-
: '空闲期:价格为高峰一半' + (weekend ? '(周末全天谷价)' : '') + '。下一高峰 ' + boundaryLabel + ',点击展开详情',
|
|
485
|
+
title: tooltip,
|
|
178
486
|
style: {
|
|
179
487
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
180
488
|
fontFamily: FONT, fontSize: 12, lineHeight: '20px',
|
|
@@ -186,8 +494,7 @@
|
|
|
186
494
|
},
|
|
187
495
|
[
|
|
188
496
|
react.createElement('span', { key: 'dot', style: { width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 } }),
|
|
189
|
-
react.createElement('span', { key: 'state', style: { fontWeight: s.peak ? 700 : 500, color: color } },
|
|
190
|
-
s.peak ? '高峰期' : (weekend ? '周末谷价' : '空闲期')),
|
|
497
|
+
react.createElement('span', { key: 'state', style: { fontWeight: s.peak ? 700 : 500, color: color } }, stateWord),
|
|
191
498
|
react.createElement('span', { key: 'clock', style: { fontVariantNumeric: 'tabular-nums' } }, clock),
|
|
192
499
|
react.createElement('span', { key: 'count', style: { fontVariantNumeric: 'tabular-nums' } },
|
|
193
500
|
s.peak
|
|
@@ -216,7 +523,7 @@
|
|
|
216
523
|
[
|
|
217
524
|
react.createElement('div', { key: 'l1', style: { display: 'flex', alignItems: 'center', gap: 6 } }, [
|
|
218
525
|
react.createElement('span', { key: 'st', style: { fontWeight: 700, color: color } },
|
|
219
|
-
s.peak ? '高峰时段' :
|
|
526
|
+
s.peak ? '高峰时段' : stateWord),
|
|
220
527
|
react.createElement('span', { key: 'tm', style: { fontVariantNumeric: 'tabular-nums' } },
|
|
221
528
|
clock + '(北京)'),
|
|
222
529
|
react.createElement('span', { key: 'price', style: { color: color, fontWeight: 500 } },
|
|
@@ -224,8 +531,11 @@
|
|
|
224
531
|
]),
|
|
225
532
|
react.createElement('div', { key: 'l2' },
|
|
226
533
|
'高峰窗口(工作日):09:00–12:00 / 14:00–18:00'),
|
|
227
|
-
react.createElement('div', { key: 'l2b', style: { color:
|
|
228
|
-
'
|
|
534
|
+
react.createElement('div', { key: 'l2b', style: { color: day.rest ? OFF_COLOR : DIM, fontWeight: day.rest ? 600 : 400 } },
|
|
535
|
+
'法定节假日与周末(周六、周日):全天按谷价计费'),
|
|
536
|
+
react.createElement('div', { key: 'l2c', style: { color: day.kind === 'makeup' ? PEAK_COLOR : DIM, fontWeight: day.kind === 'makeup' ? 600 : 400 } },
|
|
537
|
+
'调休上班日:照工作日高峰窗口计费'),
|
|
538
|
+
dayNote === '' ? null : react.createElement('div', { key: 'daynote', style: { color: color, fontWeight: 600 } }, dayNote),
|
|
229
539
|
react.createElement('div', { key: 'l3', style: { fontVariantNumeric: 'tabular-nums' } },
|
|
230
540
|
'下一转折:' + boundaryLabel + ' · 倒计时 ' + fmtDurFull(s.secsToNext)),
|
|
231
541
|
react.createElement('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
|
|
@@ -238,6 +548,8 @@
|
|
|
238
548
|
react.createElement('span', { key: 'pct', style: { fontSize: 10.5, fontVariantNumeric: 'tabular-nums' } },
|
|
239
549
|
s.progress.toFixed(0) + '%'),
|
|
240
550
|
]),
|
|
551
|
+
react.createElement('div', { key: 'cal', style: { fontSize: 10.5, opacity: 0.85 } },
|
|
552
|
+
'节假日表(北京):' + calStatusText(tick.y)),
|
|
241
553
|
react.createElement('div', { key: 'foot', style: { fontSize: 10.5, lineHeight: 1.5, opacity: 0.85 } },
|
|
242
554
|
'官方峰谷定价 8/17 生效,8/23 起周末全天谷价;空闲价 = 高峰一半。'),
|
|
243
555
|
],
|
|
@@ -269,6 +581,23 @@
|
|
|
269
581
|
|
|
270
582
|
exports.apply = apply
|
|
271
583
|
exports.inject = inject
|
|
584
|
+
// Test seam for scripts/test-peak.mjs: the harness drives these pure
|
|
585
|
+
// functions directly instead of mirroring them, so the shipped algorithm
|
|
586
|
+
// is the one under test. The loader never reads it.
|
|
587
|
+
exports.__test = {
|
|
588
|
+
compute: compute,
|
|
589
|
+
classifyDay: classifyDay,
|
|
590
|
+
dayKey: dayKey,
|
|
591
|
+
dayOrdinal: dayOrdinal,
|
|
592
|
+
ordinalToParts: ordinalToParts,
|
|
593
|
+
weekdayOf: weekdayOf,
|
|
594
|
+
normalizeCalendar: normalizeCalendar,
|
|
595
|
+
calRoute: CAL_ROUTE,
|
|
596
|
+
calCorsUrl: CAL_CORS_URL,
|
|
597
|
+
calStatusText: calStatusText,
|
|
598
|
+
ensureYear: ensureYear,
|
|
599
|
+
daysOfYear: daysOfYear,
|
|
600
|
+
}
|
|
272
601
|
return module.exports
|
|
273
602
|
}
|
|
274
603
|
|