dsh-dock 0.11.6 → 0.13.0

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.
@@ -21,9 +21,12 @@
21
21
  // fetchOfficial: true
22
22
  // pricing:
23
23
  // - { match: deepseek-v4-flash, input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
24
- // peaks: [{ start: 9, end: 14, input: 3, output: 9, cacheRead: 0.1, cacheWrite: 0 }] }
24
+ // peaks: [{ start: 9, end: 12, days: workday, input: 3, output: 9, cacheRead: 0.1, cacheWrite: 0 },
25
+ // { start: 14, end: 18, days: workday, input: 3, output: 9, cacheRead: 0.1, cacheWrite: 0 }] }
26
+ // holidays: ['2026-10-01~2026-10-07', '2027-01-01'] # 法定节假日(供 days=workday 判定)
25
27
  // 定价优先级:用户自定义单价 > 官网抓取刊例价 > 内置默认表 > 兜底单价。
26
28
  // 分时段价支持多段(start>end 跨零点、start=end 全天、小数小时表示半点);旧单段 peak 自动归一。
29
+ // 每段可带 days=all|workday|nonworkday:workday=非周末且非 holidays;nonworkday=周末或节假日。
27
30
  // 费用在查询/导出时按当前单价即时重算,因此改价无需重扫历史。
28
31
  import { sendJson, readBody, readDockRoot, mutateDockSection } from '../../src/host-core.js'
29
32
 
@@ -70,14 +73,22 @@ export const feature = {
70
73
  let usdCnyRate = 7.2
71
74
  let fallbackPricing = { match: '*', ...DEFAULT_FALLBACK }
72
75
  // 内置定价表(人民币元 / 百万 tokens), 按 model 子串匹配; 未匹配走 fallback。
73
- // 支持分时段定价: 条目可带 peaks=[{start,end,input,output,cacheRead,cacheWrite}, ...],
76
+ // 支持分时段定价: 条目可带 peaks=[{start,end,days,input,output,cacheRead,cacheWrite}, ...],
74
77
  // 命中某段(插件运行机器本地时间)用该段价, 其余时段用基准价; 支持多段与跨零点(start>end)。
75
- // 官方来源: DeepSeek-V4 系列 2026-08-17 生效的分时段刊例(高峰每日 9:00-14:00, 空闲为高峰一半)。
78
+ // days 默认 all(每天);workday=仅工作日(非周末且非配置的法定节假日)。
79
+ // 官方来源(按用户口径): DeepSeek 高峰为工作日 9:00-12:00、14:00-18:00 双倍;
80
+ // 周末与节假日全天为谷价(基准价),高峰外时段也回落基准价。
76
81
  const DEFAULT_PRICING = [
77
82
  { match: 'deepseek-v4-flash', input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
78
- peaks: [{ start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 }] },
83
+ peaks: [
84
+ { start: 9, end: 12, days: 'workday', input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 },
85
+ { start: 14, end: 18, days: 'workday', input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 },
86
+ ] },
79
87
  { match: 'deepseek-v4-pro', input: 4.5, output: 13.5, cacheRead: 0.15, cacheWrite: 0,
80
- peaks: [{ start: 9, end: 14, input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 }] },
88
+ peaks: [
89
+ { start: 9, end: 12, days: 'workday', input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 },
90
+ { start: 14, end: 18, days: 'workday', input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 },
91
+ ] },
81
92
  { match: 'deepseek-v3', input: 3.6, output: 7.2, cacheRead: 0.72, cacheWrite: 7.2 },
82
93
  { match: 'deepseek-chat', input: 1.94, output: 7.92, cacheRead: 0.5, cacheWrite: 7.92 },
83
94
  { match: 'deepseek-reasoner', input: 3.96, output: 15.77, cacheRead: 1.01, cacheWrite: 7.92 },
@@ -87,6 +98,8 @@ export const feature = {
87
98
  let pricingTable = DEFAULT_PRICING
88
99
  // 用户自定义单价(最高优先)与官网抓取开关/地址——均由 loadPricingConfig() 从 settings 刷新。
89
100
  let userPricing = []
101
+ // 法定节假日(YYYY-MM-DD 或 区间 YYYY-MM-DD~YYYY-MM-DD),供 peaks.days 判定工作日。
102
+ let holidays = []
90
103
  let fetchOfficialEnabled = true
91
104
  let pricingUrl = DEFAULT_PRICING_URL
92
105
  let fetchIntervalMs = DEFAULT_FETCH_INTERVAL_HOURS * 3600000
@@ -94,7 +107,7 @@ export const feature = {
94
107
  function normalizePricing(match, e) {
95
108
  const n = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
96
109
  const row = { match, input: n(e.input), output: n(e.output), cacheRead: n(e.cacheRead), cacheWrite: n(e.cacheWrite) }
97
- // 分时段价支持多段: peaks=[{start,end,input,output,cacheRead,cacheWrite}, ...]。
110
+ // 分时段价支持多段: peaks=[{start,end,days,input,output,cacheRead,cacheWrite}, ...]。
98
111
  // 兼容旧的单段写法 peak={...}(以及旧版本存盘数据), 统一归一到 peaks。
99
112
  const src = Array.isArray(e.peaks) && e.peaks.length ? e.peaks : (e.peak && typeof e.peak === 'object' ? [e.peak] : [])
100
113
  const peaks = []
@@ -102,6 +115,7 @@ export const feature = {
102
115
  if (!s || typeof s !== 'object') continue
103
116
  peaks.push({
104
117
  start: n(s.start), end: n(s.end),
118
+ days: normalizeDays(s.days),
105
119
  input: n(s.input), output: n(s.output),
106
120
  cacheRead: n(s.cacheRead), cacheWrite: n(s.cacheWrite),
107
121
  })
@@ -110,6 +124,11 @@ export const feature = {
110
124
  return row
111
125
  }
112
126
 
127
+ /** 归一日期类型:仅接受 all/workday/nonworkday,其余(含缺失)一律 all(旧数据零迁移)。 */
128
+ function normalizeDays(v) {
129
+ return v === 'workday' || v === 'nonworkday' ? v : 'all'
130
+ }
131
+
113
132
  /** 浅拷贝分时段数组(RPC 下发与落盘都用它,避免调用方改到内存态)。 */
114
133
  function clonePeaks(peaks) {
115
134
  return Array.isArray(peaks) ? peaks.map((s) => Object.assign({}, s)) : []
@@ -133,19 +152,53 @@ export const feature = {
133
152
  .filter((e) => e && typeof e === 'object' && typeof e.match === 'string' && e.match)
134
153
  .map((e) => normalizePricing(String(e.match).toLowerCase(), e))
135
154
  : []
155
+ holidays = Array.isArray(t.holidays)
156
+ ? t.holidays.filter((h) => typeof h === 'string' && h)
157
+ : []
136
158
  if (t.fallback && typeof t.fallback === 'object') fallbackPricing = normalizePricing('*', t.fallback)
137
159
  } catch (e) {
138
160
  console.error('[dsh-dock] tokenlog read pricing config failed', e && e.message)
139
161
  }
140
162
  }
141
163
 
142
- // 分时段命中: start<end 为普通区间 [start,end); start>end 表示跨零点(如 23~7 点);
164
+ // 本地日期键 YYYY-MM-DD(供节假日区间比较;字符串序即日期序)。
165
+ function dateKey(d) {
166
+ const p = (n) => (n < 10 ? '0' : '') + n
167
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
168
+ }
169
+
170
+ // 是否法定节假日:单日等值,或落入 start~end 区间(含两端)。
171
+ function isHoliday(d, list) {
172
+ const key = dateKey(d)
173
+ for (const h of list || []) {
174
+ const i = h.indexOf('~')
175
+ if (i > 0) {
176
+ if (key >= h.slice(0, i) && key <= h.slice(i + 1)) return true
177
+ } else if (h === key) return true
178
+ }
179
+ return false
180
+ }
181
+
182
+ // 段的日期类型是否匹配:all=每天;workday=非周末且非节假日;nonworkday=周末或节假日。
183
+ function daysMatch(days, d, holidayList) {
184
+ const kind = normalizeDays(days)
185
+ if (kind === 'all') return true
186
+ const w = d.getDay()
187
+ const weekend = w === 0 || w === 6
188
+ const holiday = isHoliday(d, holidayList)
189
+ if (kind === 'workday') return !weekend && !holiday
190
+ return weekend || holiday // nonworkday
191
+ }
192
+
193
+ // 分时段命中: 先过 days(日期类型),再过小时区间。
194
+ // start<end 为普通区间 [start,end); start>end 表示跨零点(如 23~7 点);
143
195
  // start===end 视为全天。多段命中时取第一段(按配置顺序)。
144
- function pickPeakSegment(peaks, hour) {
145
- if (!Array.isArray(peaks) || hour < 0) return null
196
+ function pickPeakSegment(peaks, hour, d, holidayList) {
197
+ if (!Array.isArray(peaks) || hour < 0 || !(d instanceof Date) || Number.isNaN(d.getTime())) return null
146
198
  for (const s of peaks) {
147
199
  const a = Number(s.start), b = Number(s.end)
148
200
  if (!Number.isFinite(a) || !Number.isFinite(b)) continue
201
+ if (!daysMatch(s.days, d, holidayList)) continue
149
202
  const hit = a === b ? true : (a < b ? (hour >= a && hour < b) : (hour >= a || hour < b))
150
203
  if (hit) return s
151
204
  }
@@ -180,13 +233,14 @@ export const feature = {
180
233
  }
181
234
  // 4) 兜底
182
235
  if (!row) { row = fallbackPricing; source = 'fallback' }
183
- // 分时段判断(本地时间, 支持多段与跨零点; 用小数小时以便 08:30 这类半点边界); 未命中用基准价
236
+ // 分时段判断(本地时间, 支持多段/跨零点/days 日期类型; 用小数小时以便 08:30 这类半点边界); 未命中用基准价
184
237
  let h = -1
238
+ let d = null
185
239
  if (typeof timeMs === 'number' && Number.isFinite(timeMs)) {
186
- const d = new Date(timeMs)
240
+ d = new Date(timeMs)
187
241
  h = d.getHours() + d.getMinutes() / 60
188
242
  }
189
- const seg = pickPeakSegment(row.peaks, h)
243
+ const seg = pickPeakSegment(row.peaks, h, d, holidays)
190
244
  const prices = seg
191
245
  ? { input: seg.input, output: seg.output, cacheRead: seg.cacheRead, cacheWrite: seg.cacheWrite }
192
246
  : { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite }
@@ -235,6 +289,7 @@ export const feature = {
235
289
  pricingUrl,
236
290
  pricingFetchIntervalHours: Math.round(fetchIntervalMs / 3600000),
237
291
  pricing: userPricing.map((r) => Object.assign({}, r, { peaks: clonePeaks(r.peaks) })),
292
+ holidays: holidays.slice(),
238
293
  fallback: Object.assign({}, fallbackPricing),
239
294
  builtin: pricingTable.map((r) => ({ match: r.match, input: r.input, output: r.output, cacheRead: r.cacheRead, cacheWrite: r.cacheWrite, peaks: clonePeaks(r.peaks) })),
240
295
  models: [...seen].sort(),
@@ -281,6 +336,7 @@ export const feature = {
281
336
  }
282
337
  // 分时段价(可多段): 每段 start/end 为 0~24 的本地小时(支持 8.5 表示 08:30);
283
338
  // start>end 表示跨零点, start===end 表示全天。
339
+ // days: all|workday|nonworkday —— 周末/节假日谷价时把高峰段标成 workday。
284
340
  const rawPeaks = Array.isArray(raw.peaks) ? raw.peaks : []
285
341
  const peaks = []
286
342
  for (const seg of rawPeaks) {
@@ -289,8 +345,12 @@ export const feature = {
289
345
  if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start > 24 || end < 0 || end > 24) {
290
346
  throw bad(`「${match}」分时段时刻需为 0~24 的小时数`)
291
347
  }
348
+ const days = seg.days === undefined || seg.days === null || seg.days === '' ? 'all' : String(seg.days)
349
+ if (days !== 'all' && days !== 'workday' && days !== 'nonworkday') {
350
+ throw bad(`「${match}」分时段日期类型需为 all / workday / nonworkday`)
351
+ }
292
352
  peaks.push({
293
- start, end,
353
+ start, end, days,
294
354
  input: nonNeg(seg.input, `${match} 分时段输入单价`),
295
355
  output: nonNeg(seg.output, `${match} 分时段输出单价`),
296
356
  cacheRead: nonNeg(seg.cacheRead, `${match} 分时段缓存命中单价`),
@@ -302,6 +362,25 @@ export const feature = {
302
362
  }
303
363
  next.pricing = rows
304
364
  }
365
+ if (input.holidays !== undefined) {
366
+ if (!Array.isArray(input.holidays)) throw bad('「法定节假日」需为数组')
367
+ const dayRe = /^\d{4}-\d{2}-\d{2}$/
368
+ const rangeRe = /^\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}$/
369
+ const list = []
370
+ for (const raw of input.holidays) {
371
+ const h = String(raw == null ? '' : raw).trim()
372
+ if (!h) continue
373
+ if (!dayRe.test(h) && !rangeRe.test(h)) {
374
+ throw bad(`节假日「${h}」格式不符:单日用 2026-10-01,区间用 2026-10-01~2026-10-07`)
375
+ }
376
+ if (rangeRe.test(h)) {
377
+ const [a, b] = h.split('~')
378
+ if (a > b) throw bad(`节假日区间「${h}」起始日期不能晚于结束日期`)
379
+ }
380
+ list.push(h)
381
+ }
382
+ next.holidays = list
383
+ }
305
384
  if (input.fallback !== undefined) {
306
385
  const f = input.fallback
307
386
  if (!f || typeof f !== 'object' || Array.isArray(f)) throw bad('「兜底单价」格式不符')
@@ -4,7 +4,7 @@
4
4
  // - 秒级时间范围查询 + 会话ID/提供商/模型(联动)/状态/推理强度 筛选,条件本地暂存
5
5
  // - 9 张 KPI 卡 + 分组统计表 + 明细表(点击表头排序、会话ID点击即筛选、100 行/页上下双分页)
6
6
  // - 状态列显示 HTTP 状态码徽章,行内【查看详情】弹窗展示完整信息;CSV 导出(中文表头,列序与明细表一致 + 合计行)
7
- // - 独立的「单价设置」子弹窗:按模型配置单价(支持多段分时价)并持久化,费用按自填单价重算
7
+ // - 独立的「单价设置」子弹窗:按模型配置单价(多段分时价 + 日期类型/法定节假日)并持久化,费用按自填单价重算
8
8
  // - 挂载即扫描历史+按暂存条件查询;挂载期间每 5s 静默自动刷新
9
9
  // - 查询等待动画:点「查询」后按钮转圈扫光 + 数据区顶部「记账小队清点」banner(轮换俏皮文案,
10
10
  // 等久了换语气),旧数据压暗禁点;结果到位时闪一次「✓ 数据已更新」并让 KPI 卡回弹
@@ -488,6 +488,9 @@ const css = `
488
488
  .dtok-pseg{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0;}
489
489
  .dtok-pseg-name{font-size:11px;color:var(--dsw-alias-label-secondary);min-width:112px;}
490
490
  .dtok-pseg-tilde{font-size:11px;color:var(--dsw-alias-label-secondary);}
491
+ .dtok-price select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 6px;font-size:12px;font-family:inherit;}
492
+ .dtok-price textarea{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px;font-size:12px;font-family:inherit;min-height:64px;resize:vertical;width:100%;box-sizing:border-box;}
493
+ .dtok-days{font-size:11px;}
491
494
  .dtok-src{font-size:11px;color:var(--dsw-alias-label-tertiary,#94a3b8);}
492
495
  .dtok-src.custom{color:var(--dsw-alias-state-success-primary,#4ade80);}
493
496
  .dtok-src.fallback{color:var(--dk-warn);}
@@ -571,10 +574,12 @@ function Detail({ rec, onClose, rate }) {
571
574
  // 独立子弹窗(可最大化 / 拖动 / 缩放),与功能坞面板解耦,方便配置较长时段表。
572
575
  function numStr(v) { return v === undefined || v === null ? "" : String(v); }
573
576
 
574
- /** 时段草稿:一个 { start,end,input,output,cacheRead,cacheWrite } 的可编辑副本。 */
577
+ /** 时段草稿:一个 { start,end,days,input,output,cacheRead,cacheWrite } 的可编辑副本。 */
575
578
  function segToDraft(s) {
579
+ const d = s && s.days;
576
580
  return {
577
581
  start: numStr(s && s.start), end: numStr(s && s.end),
582
+ days: d === "workday" || d === "nonworkday" ? d : "all",
578
583
  input: numStr(s && s.input), output: numStr(s && s.output),
579
584
  cacheRead: numStr(s && s.cacheRead), cacheWrite: numStr(s && s.cacheWrite),
580
585
  };
@@ -692,24 +697,27 @@ function PricingEditor({ onClose, onSaved, embedded }) {
692
697
  const [rate, setRate] = useState("7.2");
693
698
  const [fetchOn, setFetchOn] = useState(true);
694
699
  const [fb, setFb] = useState({ input: "", output: "", cacheRead: "", cacheWrite: "" });
700
+ const [holidaysText, setHolidaysText] = useState("");
695
701
  const [newMatch, setNewMatch] = useState("");
696
702
  const [msg, setMsg] = useState(null);
697
703
  const [saving, setSaving] = useState(false);
698
704
 
705
+ const applyCfg = (d) => {
706
+ setCfg(d);
707
+ setRate(numStr(d.usdCnyRate));
708
+ setFetchOn(!!d.fetchOfficial);
709
+ setFb({
710
+ input: numStr(d.fallback && d.fallback.input), output: numStr(d.fallback && d.fallback.output),
711
+ cacheRead: numStr(d.fallback && d.fallback.cacheRead), cacheWrite: numStr(d.fallback && d.fallback.cacheWrite),
712
+ });
713
+ setHolidaysText((d.holidays || []).join("\n"));
714
+ setRows((d.pricing || []).map(rowToDraft));
715
+ };
716
+
699
717
  useEffect(() => {
700
718
  let cancel = false;
701
719
  rpcCall("pricing", {})
702
- .then((d) => {
703
- if (cancel) return;
704
- setCfg(d);
705
- setRate(numStr(d.usdCnyRate));
706
- setFetchOn(!!d.fetchOfficial);
707
- setFb({
708
- input: numStr(d.fallback && d.fallback.input), output: numStr(d.fallback && d.fallback.output),
709
- cacheRead: numStr(d.fallback && d.fallback.cacheRead), cacheWrite: numStr(d.fallback && d.fallback.cacheWrite),
710
- });
711
- setRows((d.pricing || []).map(rowToDraft));
712
- })
720
+ .then((d) => { if (!cancel) applyCfg(d); })
713
721
  .catch((e) => { if (!cancel) setMsg({ ok: false, text: String((e && e.message) || e) }); });
714
722
  return () => { cancel = true; };
715
723
  }, []);
@@ -741,12 +749,14 @@ function PricingEditor({ onClose, onSaved, embedded }) {
741
749
  };
742
750
  const peaks = (r.peaks || []).map((s) => ({
743
751
  start: Number(s.start || 0), end: Number(s.end || 0),
752
+ days: s.days === "workday" || s.days === "nonworkday" ? s.days : "all",
744
753
  input: Number(s.input || 0), output: Number(s.output || 0),
745
754
  cacheRead: Number(s.cacheRead || 0), cacheWrite: Number(s.cacheWrite || 0),
746
755
  }));
747
756
  if (peaks.length) out.peaks = peaks;
748
757
  return out;
749
758
  });
759
+ payload.holidays = holidaysText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
750
760
  payload.fallback = {
751
761
  input: Number(fb.input || 0), output: Number(fb.output || 0),
752
762
  cacheRead: Number(fb.cacheRead || 0), cacheWrite: Number(fb.cacheWrite || 0),
@@ -754,8 +764,7 @@ function PricingEditor({ onClose, onSaved, embedded }) {
754
764
  setSaving(true); setMsg(null);
755
765
  rpcCall("setpricing", payload)
756
766
  .then((d) => {
757
- setCfg(d);
758
- setRows((d.pricing || []).map(rowToDraft));
767
+ applyCfg(d);
759
768
  setMsg({ ok: true, text: "单价已保存,费用已按新价即时重算" });
760
769
  if (onSaved) onSaved();
761
770
  })
@@ -778,9 +787,18 @@ function PricingEditor({ onClose, onSaved, embedded }) {
778
787
  );
779
788
  const segLabel = (s) => {
780
789
  const a = numStr(s.start), b = numStr(s.end);
781
- if (a === "" || b === "") return "时段";
782
- return a + "~" + b + (Number(a) === Number(b) ? "(全天)" : Number(a) > Number(b) ? "(跨零点)" : "");
790
+ const dayTag = s.days === "workday" ? "工作日" : s.days === "nonworkday" ? "周末/节假日" : "";
791
+ const time = a === "" || b === "" ? "时段"
792
+ : a + "~" + b + (Number(a) === Number(b) ? "(全天)" : Number(a) > Number(b) ? "(跨零点)" : "");
793
+ return dayTag ? dayTag + " " + time : time;
783
794
  };
795
+ const daysSelect = (value, onChange) => (
796
+ <select className="dtok-days" value={value || "all"} onChange={(e) => onChange(e.target.value)} title="该时段在哪类日期生效">
797
+ <option value="all">每天</option>
798
+ <option value="workday">仅工作日</option>
799
+ <option value="nonworkday">仅周末/节假日</option>
800
+ </select>
801
+ );
784
802
 
785
803
  return (
786
804
  <div className={embedded ? "dtok-price emb" : "dtok-price"} onClick={(e) => e.stopPropagation()}>
@@ -795,6 +813,20 @@ function PricingEditor({ onClose, onSaved, embedded }) {
795
813
  </label>
796
814
  </div>
797
815
 
816
+ <div>
817
+ <div className="dtok-section-title" style={{ margin: "0 0 4px" }}>法定节假日(供「仅工作日」分时段判定)</div>
818
+ <div className="dtok-price-field" style={{ maxWidth: 420 }}>
819
+ <label>每行一条:单日 2026-10-01,或区间 2026-10-01~2026-10-07(含两端)</label>
820
+ <textarea
821
+ value={holidaysText}
822
+ placeholder={"2026-10-01~2026-10-07\n2027-01-01"}
823
+ onChange={(e) => setHolidaysText(e.target.value)}
824
+ spellCheck={false}
825
+ />
826
+ </div>
827
+ <div className="dtok-price-hint">周末(周六/周日)与这些节假日视为非工作日;标了「仅工作日」的高峰段在这些日期回落基准价(谷价)。</div>
828
+ </div>
829
+
798
830
  <div>
799
831
  <div className="dtok-section-title" style={{ margin: "0 0 4px" }}>按模型配置单价({rows.length} 条)</div>
800
832
  {onFallback.length > 0 ? (
@@ -819,12 +851,14 @@ function PricingEditor({ onClose, onSaved, embedded }) {
819
851
  缓存写入=未命中缓存、本次新建缓存的那部分输入(Claude 等按溢价单独收建缓存费);DeepSeek 不收此项,填 0 即可。
820
852
  </div>
821
853
  <div className="dtok-price-hint" style={{ marginTop: 2 }}>
822
- 分时段价(可多段,命中哪段用哪段;时段外回落到基准价。start&gt;end 表示跨零点,如 23~7;start=end 表示全天)
854
+ 分时段价(可多段,命中哪段用哪段;时段外回落到基准价。start&gt;end 表示跨零点,如 23~7;start=end 表示全天。
855
+ 每段可选日期类型:每天 / 仅工作日(非周末且非下方节假日)/ 仅周末或节假日——如 DeepSeek「工作日 9~12、14~18 双倍,周末与节假日全天谷价」)
823
856
  </div>
824
857
  {r.peaks.length === 0 ? <div className="dtok-price-hint">(未设置分时段,按基准价计费)</div> : null}
825
858
  {r.peaks.map((s, k) => (
826
859
  <div className="dtok-pseg" key={k}>
827
860
  <span className="dtok-pseg-name">{segLabel(s)}</span>
861
+ {daysSelect(s.days, (v) => patchSeg(i, k, { days: v }))}
828
862
  {num(s.start, (v) => patchSeg(i, k, { start: v }), { width: 56 })}
829
863
  <span className="dtok-pseg-tilde">~</span>
830
864
  {num(s.end, (v) => patchSeg(i, k, { end: v }), { width: 56 })}
@@ -836,7 +870,7 @@ function PricingEditor({ onClose, onSaved, embedded }) {
836
870
  <button className="dtok-btn tiny" onClick={() => delSeg(i, k)}>删除时段</button>
837
871
  </div>
838
872
  ))}
839
- <div className="dtok-price-hint" style={{ opacity: .85 }}>时段价从左到右:输入(命中)/ 输入(未命中)/ 输出 / 缓存写入</div>
873
+ <div className="dtok-price-hint" style={{ opacity: .85 }}>时段价从左到右:日期类型 / 起~止时 / 输入(命中)/ 输入(未命中)/ 输出 / 缓存写入</div>
840
874
  <button className="dtok-btn tiny" onClick={() => addSeg(i)}>+ 添加时段</button>
841
875
  </div>
842
876
  ))}
@@ -871,7 +905,7 @@ function PricingEditor({ onClose, onSaved, embedded }) {
871
905
  {cfg.builtin.map((b) => (
872
906
  <tr key={b.match}>
873
907
  <td>{b.match}</td><td>{b.cacheRead}</td><td>{b.input}</td><td>{b.output}</td><td>{b.cacheWrite}</td>
874
- <td>{b.peaks && b.peaks.length ? b.peaks.map((s) => s.start + "~" + s.end).join("、") + "时" : "—"}</td>
908
+ <td>{b.peaks && b.peaks.length ? b.peaks.map((s) => (s.days === "workday" ? "工作日" : s.days === "nonworkday" ? "周末/节假" : "") + s.start + "~" + s.end).join("、") + "时" : "—"}</td>
875
909
  </tr>
876
910
  ))}
877
911
  </tbody>
@@ -1374,7 +1408,7 @@ export const feature = {
1374
1408
  name: "用量记录",
1375
1409
  order: 110,
1376
1410
  accent: "#fbbf24",
1377
- description: "记录全部 LLM API 调用:秒级时间筛选、Token/费用统计(可配置各模型单价,持久保存并按自填单价计费;内置峰谷计价+官网价目自动同步作兜底)、分组汇总、明细检索与 CSV 导出",
1411
+ description: "记录全部 LLM API 调用:秒级时间筛选、Token/费用统计(可配置各模型单价,持久保存并按自填单价计费;多段分时价可限定工作日/周末节假日,内置峰谷计价+官网价目自动同步作兜底)、分组汇总、明细检索与 CSV 导出",
1378
1412
  css,
1379
1413
  View: TokenLogView,
1380
1414
  HomeStat: TokenLogHomeStat,
package/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // - notify 任务通知:完成/异常/需确认通知 + 提示音/系统通知/钉钉飞书推送(从任务动画拆出)
17
17
  // - runstate 运行状态:进行中任务与最近完成一览(从任务动画拆出;只读,无配置段)
18
18
  // - mobile-relay 手机接力(未发布):扫码反向代理接力 + 局域网电脑直连(0.0.0.0)
19
- import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
19
+ import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
20
20
  import { feature as fModels } from './features/modelconfig/host.js'
21
21
  import { feature as fVisionProxy } from './features/visionproxy/host.js'
22
22
  import { feature as fBalance } from './features/balance/host.js'
@@ -132,6 +132,10 @@ export function apply(ctx, config) {
132
132
  ctx.inject(['settings'], (sctx) => {
133
133
  // 0.1.7-alpha.1 起 settings.register 已移除——不再调用;导出 Config 即注册。
134
134
  Promise.resolve()
135
+ // settings.yaml.imported 整段恢复(账号 remoteAuth / notify / animation /
136
+ // visionProxy——Config 导出前导入失败停在 imported 文件里的那批)。必须最先跑:
137
+ // 后面的迁移与功能启用都要读到恢复后的配置(远程访问账号丢了网关就开不起来)。
138
+ .then(() => migrateImportedSections(sctx))
135
139
  // 通知配置从 animation 段搬到 notify 段(【任务通知】独立成模块)。
136
140
  // 必须在任何面板保存动作之前跑——animation 模块保存时整段写回,旧字段会被覆盖丢失。
137
141
  .then(() => migrateNotifyConfig(sctx))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.11.6",
4
- "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价与多段分时价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
3
+ "version": "0.13.0",
4
+ "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "build:client": "node scripts/build-client.mjs",
45
45
  "test:client": "node scripts/test-client-views.mjs",
46
- "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs",
46
+ "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-relay-host.mjs && node scripts/test-mobile-relay.mjs",
47
47
  "dev:client": "node scripts/watch-client.mjs",
48
48
  "dev:link": "node scripts/dev-link-deps.mjs"
49
49
  },
package/src/client.jsx CHANGED
@@ -28,12 +28,13 @@ import { feature as fNotify } from "../features/notify/view.jsx";
28
28
  import { feature as fRunState } from "../features/runstate/view.jsx";
29
29
  import { feature as fGames } from "../features/games/view.jsx";
30
30
  import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
31
+ import { feature as fMobile } from "../features/mobile/view.js";
31
32
 
32
33
  const name = "dsh-dock";
33
- const DOCK_VERSION = "0.11.6";
34
+ const DOCK_VERSION = "0.13.0";
34
35
 
35
36
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
36
- const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay];
37
+ const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay, fMobile];
37
38
  // 规划占位(路线图):接入后移除并建 features/<id>/ 模块
38
39
  const PLANNED_FEATURES = [];
39
40
  const PLANNED_NOTES = {};
package/src/host-core.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, existsSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { homedir } from 'node:os'
6
+ import yaml from 'js-yaml'
6
7
  import z from '@deepseek-ai/schemastery'
7
8
 
8
9
  /** dsh-dock 自有 settings 命名空间 = profile 条目 id(cordis.patch.yml 的 id)。 */
@@ -26,6 +27,15 @@ export const DockConfig = z.object({
26
27
  passwordHash: z.string().default(''),
27
28
  salt: z.string().default(''),
28
29
  }).default({}).volatile(),
30
+ /** 远程访问网关的持久化状态:enabled=上次是否在跑;port=自选端口(0=主端口+1)。
31
+ * dsh web 重启后按此自动拉起网关(账号存在才拉),免得每次重启都要进面板点一次。 */
32
+ remoteGateway: z.object({
33
+ enabled: z.boolean().default(false),
34
+ /** 网关端口(0 = 跟随默认的主端口+1);面板「开启远程访问」时回写。 */
35
+ port: z.natural().default(0),
36
+ }).default({}).volatile(),
37
+ /** settings.yaml.imported 全量恢复(migrateImportedSections)是否已执行过的一次性标记。 */
38
+ importedRestored: z.boolean().default(false).volatile(),
29
39
  visionProxy: z.object({
30
40
  enabled: z.boolean().default(false),
31
41
  provider: z.string().default(''),
@@ -51,7 +61,9 @@ export const DockConfig = z.object({
51
61
  cacheRead: z.number().default(0),
52
62
  cacheWrite: z.number().default(0),
53
63
  /** 分时段价(可多段,如 DeepSeek 的高峰/优惠时段):命中某段用该段价,否则用上面的基准价。
54
- * start/end 为本地小时数 0~24;start>end 表示跨零点;单段等价旧的 peak。 */
64
+ * start/end 为本地小时数 0~24;start>end 表示跨零点;单段等价旧的 peak。
65
+ * days:日期类型——all=每天(默认);workday=仅工作日(非周末且非下方 holidays);
66
+ * nonworkday=仅周末或节假日。用于「周末/节假日谷价、仅工作日高峰」这类计价。 */
55
67
  peaks: z.array(z.object({
56
68
  start: z.number().default(0),
57
69
  end: z.number().default(0),
@@ -59,8 +71,12 @@ export const DockConfig = z.object({
59
71
  output: z.number().default(0),
60
72
  cacheRead: z.number().default(0),
61
73
  cacheWrite: z.number().default(0),
74
+ days: z.string().default('all'),
62
75
  })).default([]),
63
76
  })).default([]),
77
+ /** 法定节假日:单日 YYYY-MM-DD 或区间 YYYY-MM-DD~YYYY-MM-DD(含两端)。
78
+ * 供 peaks.days=workday/nonworkday 判断;周末(周六/周日)始终视为非工作日。 */
79
+ holidays: z.array(z.string()).default([]),
64
80
  /** 未匹配任何条目的模型所用兜底单价。 */
65
81
  fallback: z.object({
66
82
  input: z.number().default(2.16),
@@ -290,6 +306,125 @@ export async function migrateImportedFeatures(ctx) {
290
306
  }
291
307
  }
292
308
 
309
+ /** 深比较(JSON 化配置快照之间的相等判断)。 */
310
+ function sameValue(a, b) {
311
+ if (a === b) return true
312
+ if (Array.isArray(a) && Array.isArray(b)) {
313
+ return a.length === b.length && a.every((v, i) => sameValue(v, b[i]))
314
+ }
315
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
316
+ const ka = Object.keys(a)
317
+ const kb = Object.keys(b)
318
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && sameValue(a[k], b[k]))
319
+ }
320
+ return false
321
+ }
322
+
323
+ /** schema 缺省值的普通对象快照(用来判断「现网这一段用户从没写过」)。
324
+ * schemastery 没有 parse():走 Standard Schema validate 取值,volatile 读写器交给 plainify 解开。 */
325
+ function dockDefaults() {
326
+ try {
327
+ const standard = DockConfig['~standard'] || DockConfig[Symbol.for('standard.schema')]
328
+ if (!standard || typeof standard.validate !== 'function') return null
329
+ const result = standard.validate({})
330
+ if (!result || result.issues || !result.value) return null
331
+ return plainifyDockConfig(result.value)
332
+ } catch {
333
+ return null
334
+ }
335
+ }
336
+
337
+ /** 只保留 schema 认识的顶层键:imported 旧文件可能带已废弃字段(如 animation 段里的
338
+ * 旧通知字段),整段原样写会被 mutate 校验拒绝——丢字段会让整次恢复失败。
339
+ * 值原样透传(嵌套结构交 schema 处理),这里只做顶层白名单。 */
340
+ function sanitizeSection(value, template) {
341
+ if (!template || typeof template !== 'object' || Array.isArray(template)) return value
342
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
343
+ const out = {}
344
+ for (const key of Object.keys(template)) {
345
+ if (Object.hasOwn(value, key)) out[key] = value[key]
346
+ }
347
+ return out
348
+ }
349
+
350
+ /**
351
+ * 一次性全量恢复:`~/.dsh/settings.yaml` 被 dsh 升级改名为 `settings.yaml.imported`
352
+ * 并尝试导入 profile 时,dsh-dock 当时还没导出 Config → 整段导入失败,账号
353
+ * (remoteAuth)、通知、动画、图片代理等配置全部停在 imported 文件里;后来只补迁了
354
+ * features(见 migrateImportedFeatures),于是「远程访问账号丢失、网关开不起来」。
355
+ *
356
+ * 这里把整段补回来,规则保守:
357
+ * - `features`:逐键补缺(现网已有的键绝不覆盖);
358
+ * - 其余 schema 认识的段:仅当现网该段仍等于 schema 缺省值(= 用户从未在此写过)
359
+ * 才整段恢复,现网有任何改动一律以现网为准(不覆盖新配置);
360
+ * - 执行过写入或确认无 imported 文档后置 `importedRestored` 标记,仅跑一次。
361
+ */
362
+ export async function migrateImportedSections(ctx) {
363
+ try {
364
+ const root = readDockRoot(ctx)
365
+ if (root && root.importedRestored === true) return
366
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
367
+ const file = join(home, 'settings.yaml.imported')
368
+ if (!existsSync(file)) return
369
+ let data = null
370
+ try {
371
+ data = yaml.load(readFileSync(file, 'utf8'))
372
+ } catch (error) {
373
+ console.warn('[dsh-dock] settings.yaml.imported 不是合法 YAML,跳过恢复:', (error && error.message) || String(error))
374
+ return
375
+ }
376
+ const section = data && typeof data === 'object' && !Array.isArray(data) ? data[DOCK_NS] : null
377
+ if (!section || typeof section !== 'object' || Array.isArray(section)) {
378
+ await mutateDockSection(ctx, ['importedRestored'], true)
379
+ return
380
+ }
381
+ const defaults = dockDefaults()
382
+ const live = root && typeof root === 'object' ? root : {}
383
+ const writes = []
384
+ const importedFeatures = section.features && typeof section.features === 'object' ? section.features : null
385
+ if (importedFeatures) {
386
+ const merged = live.features && typeof live.features === 'object' ? { ...live.features } : {}
387
+ let added = 0
388
+ for (const [key, value] of Object.entries(importedFeatures)) {
389
+ if (typeof value === 'boolean' && typeof merged[key] !== 'boolean') {
390
+ merged[key] = value
391
+ added++
392
+ }
393
+ }
394
+ if (added > 0) writes.push(['features', merged])
395
+ }
396
+ if (defaults) {
397
+ for (const key of Object.keys(section)) {
398
+ if (key === 'features') continue
399
+ if (!Object.hasOwn(defaults, key)) continue
400
+ // 现网缺该段(= 从没写过)或仍等于 schema 缺省 → 恢复;现网有用户改动一律不覆盖。
401
+ if (live[key] !== undefined && !sameValue(live[key], defaults[key])) continue
402
+ writes.push([key, sanitizeSection(section[key], defaults[key])])
403
+ }
404
+ }
405
+ // 逐段独立落盘:一个段被拒(schema 不认/写盘瞬时失败)不拖死其余段——
406
+ // 尤其 remoteAuth 必须尽量恢复,否则远程访问账号一直起不来。
407
+ const restored = []
408
+ const failed = []
409
+ for (const [path, value] of writes) {
410
+ try {
411
+ await mutateDockSection(ctx, [path], value)
412
+ restored.push(path)
413
+ } catch (error) {
414
+ failed.push(path)
415
+ console.warn(`[dsh-dock] 恢复 ${path} 段失败:`, (error && error.message) || String(error))
416
+ }
417
+ }
418
+ // 全部落盘(或本来就无事可做)才置一次性标记;有失败则下次启动重试。
419
+ if (failed.length === 0) await mutateDockSection(ctx, ['importedRestored'], true)
420
+ if (restored.length > 0) {
421
+ console.log('[dsh-dock] restored from settings.yaml.imported:', restored.join(', '))
422
+ }
423
+ } catch (e) {
424
+ console.warn('[dsh-dock] imported section restore skipped:', (e && e.message) || String(e))
425
+ }
426
+ }
427
+
293
428
  /** 任务动画 effectMode 合法值(客户端动画模式)。 */
294
429
  export const ANIMATION_MODES = [
295
430
  'flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora', 'space',