@mzzsfy/dsh-usage-panel 0.7.1 → 0.8.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.
package/README.md CHANGED
@@ -130,3 +130,7 @@ node scripts/dev-link.mjs dsh-usage-panel # 仓库根执行:归一 profile 依
130
130
  ```
131
131
 
132
132
  工作副本以 junction 挂进 profile,改代码保存即热重载(host 半区)或刷新页面生效(client 半区),无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
133
+
134
+ ## dsh 版本兼容
135
+
136
+ 三版本全部通过:用量面板渲染、激活 live。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-panel",
3
3
  "description": "多平台 LLM 账号余额与额度面板:支持 DeepSeek/OpenRouter/Kimi/智谱/MiniMax/NewApi 及自定义端点,自动定期查询,趋势图与历史快照,用量/余额阈值与窗口重置多通道通知",
4
- "version": "0.7.1",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -565,6 +565,9 @@ function AccountCard(props) {
565
565
  const armed = props.deleteArmed === true
566
566
  const reading = account.last && account.last.ok ? account.last.reading : null
567
567
  const level = reading && (reading.level || reading.membership) ? levelLabel(reading.level || reading.membership) : null
568
+ const queriedAt = account.last && typeof account.last.queriedAt === 'number' && Number.isFinite(account.last.queriedAt)
569
+ ? account.last.queriedAt
570
+ : null
568
571
 
569
572
  return h('div', { className: 'up-card' },
570
573
  h('div', { className: 'up-card__row' },
@@ -572,6 +575,7 @@ function AccountCard(props) {
572
575
  h('span', { className: 'up-badge', 'data-type': account.type }, TYPE_LABELS[account.type] || account.type),
573
576
  level ? h('span', { className: 'up-badge', 'data-type': 'custom' }, level) : null,
574
577
  h('span', { className: 'up-spacer' }),
578
+ queriedAt !== null ? h('span', { className: 'up-meta', title: fmtTimeFull(queriedAt) }, '上次更新 ' + fmtTime(queriedAt)) : null,
575
579
  h('button', { className: 'up-btn', disabled: busy, onClick: props.onRefresh }, busy ? '查询中…' : '刷新'),
576
580
  h('button', { className: 'up-btn', disabled: busy, onClick: props.onEdit }, '编辑'),
577
581
  h('button', {
package/src/index.js CHANGED
@@ -269,7 +269,7 @@ function normalizeAccounts(input) {
269
269
  ? lastSource.reading
270
270
  : null,
271
271
  error: typeof lastSource.error === 'string' ? lastSource.error : null,
272
- queriedAt: Number.isFinite(Number(lastSource.queriedAt)) ? Number(lastSource.queriedAt) : null,
272
+ queriedAt: typeof lastSource.queriedAt === 'number' && Number.isFinite(lastSource.queriedAt) ? lastSource.queriedAt : null,
273
273
  }
274
274
  : null
275
275
  const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : 'acct-' + String(index)
package/src/notify.mjs CHANGED
@@ -6,6 +6,9 @@ export const PROJECTION_CAPACITY = 20
6
6
  export const PROJECTION_TTL_MS = 60 * 1000
7
7
  export const CLAIM_LOCK_TTL_MS = 30 * 1000
8
8
  export const WEBHOOK_TIMEOUT_MS = 10 * 1000
9
+ // 轮转判定容差:远小于最短真实窗口量级,远大于服务端毫秒级抖动;
10
+ // 容差内视为同窗口静默跟随
11
+ export const RESET_DRIFT_TOLERANCE_MS = 60 * 1000
9
12
 
10
13
  export const KIND_QUOTA = 'quota'
11
14
  export const KIND_BALANCE = 'balance'
@@ -63,6 +66,13 @@ export function normalizeNotifyState(raw) {
63
66
 
64
67
  const PERCENT_BASE = 100
65
68
 
69
+ // 窗口轮转判定:resetsAt 数值差超容差才算轮转;parse 失败无法证伪,保守按轮转。
70
+ // 容差内静默跟随,峰值与武装状态不重建
71
+ function windowRotated(prevResetsAt, resetsAt) {
72
+ const driftMs = Math.abs(Date.parse(resetsAt) - Date.parse(prevResetsAt))
73
+ return !(Number.isFinite(driftMs) && driftMs < RESET_DRIFT_TOLERANCE_MS)
74
+ }
75
+
66
76
  // 沿触发评估:刷新读数越过逻辑点(阈值穿越/窗口重置)时产出事件,返回新状态不改入参。
67
77
  // 仅在 last.ok 时由 host 调用;reading 缺失按无读数处理。
68
78
  export function evaluateAccount({ account, rule, state, seq, ts }) {
@@ -88,8 +98,9 @@ function evaluateQuota({ account, rule, state, reading, events, next }) {
88
98
  const prev = state.windows[label] || null
89
99
  let armed = prev === null ? true : prev.armed !== false
90
100
  let peak = prev === null ? null : prev.peak
91
- // 窗口轮转:两侧 resetsAt 均有效且值不同;上一窗口峰值随 reset 事件上报
92
- if (prev !== null && resetsAt !== null && prev.resetsAt !== null && prev.resetsAt !== resetsAt) {
101
+ // 窗口轮转:两侧 resetsAt 均有效、字符串不等且数值差超容差;上一窗口峰值随 reset 事件上报
102
+ if (prev !== null && resetsAt !== null && prev.resetsAt !== null && prev.resetsAt !== resetsAt
103
+ && windowRotated(prev.resetsAt, resetsAt)) {
93
104
  if (rule.resetNotice !== false && prev.peak !== null) {
94
105
  events.push({
95
106
  kind: KIND_RESET,
@@ -23,6 +23,7 @@ import {
23
23
  KIND_QUOTA,
24
24
  KIND_BALANCE,
25
25
  KIND_RESET,
26
+ RESET_DRIFT_TOLERANCE_MS,
26
27
  } from '../src/notify.mjs'
27
28
 
28
29
  test('规则合并: 账号覆盖键生效, 其余继承全局', () => {
@@ -106,6 +107,50 @@ test('窗口重置: resetsAt 轮转产生 reset 事件报上一窗口峰值并
106
107
  assert.equal(again.events[0].kind, KIND_QUOTA)
107
108
  })
108
109
 
110
+ test('窗口重置: 毫秒级服务端抖动视为同窗口不发事件且峰值保留', () => {
111
+ // Given 阈值 90, 首轮 utilization 40 以 ISO 时刻建立基线
112
+ const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
113
+ let state = createNotifyState()
114
+ state = evaluateAccount({ account: quotaAccount(quotaReading(40, '2026-10-17T09:10:04.999Z')), rule, state, seq: 1, ts: 1000 }).state
115
+ // When 次轮 resetsAt 仅漂移 1ms(智谱 TIME_LIMIT 实测抖动), utilization 回落
116
+ const next = evaluateAccount({ account: quotaAccount(quotaReading(30, '2026-10-17T09:10:04.998Z')), rule, state, seq: 2, ts: 2000 })
117
+ // Then 无事件, 峰值保留 40 不误报, 基线静默跟随新值
118
+ assert.equal(next.events.length, 0)
119
+ assert.equal(next.state.windows['5小时'].peak, 40)
120
+ assert.equal(next.state.windows['5小时'].resetsAt, '2026-10-17T09:10:04.998Z')
121
+ })
122
+
123
+ test('窗口重置: 超容差的 resetsAt 变化仍判定为轮转', () => {
124
+ // Given 阈值 90, 首轮 92 建立基线
125
+ const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
126
+ let state = createNotifyState()
127
+ state = evaluateAccount({ account: quotaAccount(quotaReading(92, '2026-10-17T09:10:04.999Z')), rule, state, seq: 1, ts: 1000 }).state
128
+ // When resetsAt 前移 1 小时(真实轮转)
129
+ const next = evaluateAccount({ account: quotaAccount(quotaReading(5, '2026-10-17T10:10:04.999Z')), rule, state, seq: 2, ts: 2000 })
130
+ // Then 产生 reset 事件报上一窗口峰值 92
131
+ assert.equal(next.events.length, 1)
132
+ assert.equal(next.events[0].kind, KIND_RESET)
133
+ assert.equal(next.events[0].text, '[dsh] 账号A 5小时窗口已重置,上一窗口峰值用量 92%')
134
+ })
135
+
136
+ test('窗口重置: 漂移恰好容差内静默, 达到容差即轮转', () => {
137
+ // Given 阈值 90, 基线 resetsAt 为固定时刻
138
+ const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
139
+ const baseMs = Date.parse('2026-10-17T09:10:04.999Z')
140
+ const isoOf = (ms) => new Date(ms).toISOString()
141
+ const baselineState = evaluateAccount({ account: quotaAccount(quotaReading(40, isoOf(baseMs))), rule, state: createNotifyState(), seq: 1, ts: 1000 }).state
142
+ // When 漂移为容差减 1ms(容差内)
143
+ const inside = evaluateAccount({ account: quotaAccount(quotaReading(40, isoOf(baseMs + RESET_DRIFT_TOLERANCE_MS - 1))), rule, state: baselineState, seq: 2, ts: 2000 })
144
+ // Then 无事件, 基线跟随
145
+ assert.equal(inside.events.length, 0)
146
+ assert.equal(inside.state.windows['5小时'].resetsAt, isoOf(baseMs + RESET_DRIFT_TOLERANCE_MS - 1))
147
+ // When 漂移恰好等于容差(边界,严格小于判定不吸收,独立从原始基线评估)
148
+ const boundary = evaluateAccount({ account: quotaAccount(quotaReading(40, isoOf(baseMs + RESET_DRIFT_TOLERANCE_MS))), rule, state: baselineState, seq: 3, ts: 3000 })
149
+ // Then 判定轮转产生 reset 事件
150
+ assert.equal(boundary.events.length, 1)
151
+ assert.equal(boundary.events[0].kind, KIND_RESET)
152
+ })
153
+
109
154
  test('窗口重置: resetNotice 关闭时不产生 reset 事件但仍重建基线并重新武装', () => {
110
155
  // Given 阈值 90 已触发, 重置通知关闭
111
156
  const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: false }