@mzzsfy/dsh-usage-panel 0.4.3 → 0.4.5

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
@@ -123,10 +123,10 @@ custom 端点支持自定义请求方法(GET/POST/PUT/DELETE/PATCH)、请求头(
123
123
  - 早期版本允许无 `id` 的账号(落盘时按索引一次性补齐);当前前端保存时总携带 `id`,不再做旧数据特判
124
124
  - 账号卡片渲染未做 memo 化、趋势浮层常挂载:数据规模受账号上限(20)与悬浮窗口点数(24)双重钳制,无可感知卡顿,后置观察
125
125
 
126
- ## 开发安装(不经 npm 发布直接装仓库副本)
126
+ ## 开发安装(仓库工作副本直挂,不经 npm 发布)
127
127
 
128
128
  ```sh
129
- dsh plugin --profile web add file:./packages/dsh-usage-panel
129
+ node scripts/dev-link.mjs dsh-usage-panel # 仓库根执行:归一 profile 依赖行 + 挂 junction
130
130
  ```
131
131
 
132
- `file:` 安装指向仓库工作副本,改代码后重跑该命令即同步,无需发版。
132
+ 工作副本以 junction 挂进 profile,改代码保存即热重载(host 半区)或刷新页面生效(client 半区),无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
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.4.3",
4
+ "version": "0.4.5",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -898,7 +898,7 @@ function DetailDialog(props) {
898
898
  ranges.map((r) => h('button', {
899
899
  key: r, className: 'up-btn', disabled: r === range,
900
900
  onClick: () => setRange(r),
901
- }, r === 'all' ? '全部' : '近 ' + r)),
901
+ }, r === 'all' ? '全部' : '近 ' + ({ '7d': '7 天', '30d': '30 天' })[r])),
902
902
  h('button', { className: 'up-btn', onClick: props.onClose }, '关闭'),
903
903
  ),
904
904
  h(TrendChart, { label: props.label, points, onDetail: null }),
package/src/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from './parsers.mjs'
17
17
  import { readingToSnapshots, appendPoint, buildMonthSequence, newSequenceStore } from './history.mjs'
18
18
  import { createHistoryStore } from './historyStore.mjs'
19
- import { createBackoff, isShortWindowTier, tierIntervalSec, lastQuerySecOf, isDue } from './poller.mjs'
19
+ import { createBackoff, isShortWindowTier, tierIntervalSec, lastQuerySecOf, isDue, runLimited } from './poller.mjs'
20
20
  import {
21
21
  DEFAULT_QUOTA_THRESHOLD_PCT,
22
22
  WEBHOOK_TIMEOUT_MS,
@@ -39,6 +39,9 @@ import {
39
39
  const FETCH_TIMEOUT_MS = 20 * 1000
40
40
  const BODY_MAX_BYTES = 256 * 1024
41
41
  const MAX_ACCOUNTS = 20
42
+ // 单轮轮询的受限并发:串行执行时单账号 20s 超时即可拖满整轮(pollInFlight 互斥
43
+ // 使后续 tick 全部跳过,轮询事实停摆),并发上限同时压住上游瞬时压力
44
+ const POLL_CONCURRENCY = 4
42
45
  // 通知长轮询服务端挂起上限,与 turn-notify 同值;客户端超时须大于此值
43
46
  const LONG_POLL_WAIT_MS = 25 * 1000
44
47
  // 数据目录支持 env 注入(测试隔离);缺省落 ~/.dsh/dsh-usage-panel
@@ -756,10 +759,8 @@ export function apply(ctx) {
756
759
  pollInFlight = true
757
760
  ensureHistory()
758
761
  .then(async () => {
759
- for (const account of due) {
760
- // 单账号失败不中止本轮其余账号(broken 等持久态下尤为关键)
761
- await runQuery(account).catch(() => {})
762
- }
762
+ // 受限并发执行:单账号失败/超时不拖满整轮,后续 tick 不因互斥停摆
763
+ await runLimited(due, POLL_CONCURRENCY, (account) => runQuery(account).catch(() => {}))
763
764
  await persistConfig().catch(() => {})
764
765
  })
765
766
  .catch(() => {})
package/src/poller.mjs CHANGED
@@ -58,3 +58,16 @@ export function isDue({ lastQuerySec, nowSec, intervalSec }) {
58
58
  if (lastQuerySec === null) return true
59
59
  return nowSec - lastQuerySec >= intervalSec
60
60
  }
61
+
62
+ // 受限并发执行:worker 池共享游标逐项取号(取号同步完成,无交错),单任务
63
+ // 失败/慢速只占用一个 worker 位,不拖满整轮;每任务结果经 task 感知,失败由
64
+ // 调用方在 task 内消化(本函数恒 resolve)
65
+ export async function runLimited(items, limit, task) {
66
+ let cursor = 0
67
+ const worker = async () => {
68
+ while (cursor < items.length) {
69
+ await task(items[cursor++])
70
+ }
71
+ }
72
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
73
+ }
@@ -0,0 +1,54 @@
1
+ // client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
2
+ // 跨 bundle 同名即整脚本 SyntaxError 拒载。本包 client.js 以单条 __ModuleLoader__.load
3
+ // 语句承载全部代码(声明均在 factory 作用域内),本守卫锁定"顶层零词法声明"不变量,
4
+ // 防未来声明误置顶层。vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { dirname, join } from 'node:path'
10
+ import vm from 'node:vm'
11
+
12
+ const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
13
+ const DECLARATION_NAMES = [
14
+ ...new Set(
15
+ [...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
16
+ ),
17
+ ]
18
+ const BUNDLE_ID = '@mzzsfy/dsh-usage-panel'
19
+
20
+ test('Given 同 context 已有外部 CSS 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
21
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
22
+ vm.runInContext('const CSS = 1', ctx)
23
+ vm.runInContext(CLIENT_SOURCE, ctx)
24
+ assert.equal(vm.runInContext('CSS', ctx), 1)
25
+ })
26
+
27
+ test('Given client.js 已整源求值, When 同 context 再声明外部 CSS, Then 双源共存不拒载', () => {
28
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
29
+ vm.runInContext(CLIENT_SOURCE, ctx)
30
+ vm.runInContext('const CSS = 1', ctx)
31
+ })
32
+
33
+ test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
34
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
35
+ vm.runInContext(CLIENT_SOURCE, ctx)
36
+ const leaked = DECLARATION_NAMES.filter((name) => {
37
+ try {
38
+ vm.runInContext(`const ${name} = null`, ctx)
39
+ return false
40
+ } catch {
41
+ return true
42
+ }
43
+ })
44
+ assert.deepEqual(leaked, [])
45
+ })
46
+
47
+ test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
48
+ const registrations = []
49
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
50
+ vm.runInContext(CLIENT_SOURCE, ctx)
51
+ assert.equal(registrations.length, 1)
52
+ assert.equal(registrations[0].id, BUNDLE_ID)
53
+ assert.equal(typeof registrations[0].factory, 'function')
54
+ })
@@ -12,6 +12,7 @@ import {
12
12
  lastQuerySecOf,
13
13
  isDue,
14
14
  isShortWindowTier,
15
+ runLimited,
15
16
  } from '../src/poller.mjs'
16
17
 
17
18
  test('场景: 失败退避指数增长并封顶', () => {
@@ -86,3 +87,50 @@ test('场景: 短窗口档判定,从未成功(含最近失败)按短档,失败
86
87
  assert.equal(isShortWindowTier({ ok: false, reading: null }, readingHasShort), true, '失败不塌缩:含短窗账号保持短档节奏')
87
88
  assert.equal(isShortWindowTier({ ok: false, reading: null }, false), true, '失败的长窗账号也归短档:10 分钟节奏 + 退避压制重试')
88
89
  })
90
+
91
+ test('场景: 受限并发执行——全项被处理,慢任务不阻塞其他 worker', async () => {
92
+ // Given 6 项任务,并发 2:每个任务挂起直到放行;并发内慢任务(后放行)只占 1 个 worker 位
93
+ const processed = []
94
+ const release = []
95
+ const task = async (item) => {
96
+ await new Promise((resolve) => release.push(resolve))
97
+ processed.push(item)
98
+ }
99
+ const done = runLimited([1, 2, 3, 4, 5, 6], 2, task)
100
+ // When 恰有 2 个任务在途(worker 数 = 并发上限)
101
+ assert.equal(release.length, 2, '并发上限决定在途任务数')
102
+ // 放行在途任务使其完成并取号下一项
103
+ release.splice(0).forEach((resolve) => resolve())
104
+ await new Promise((resolve) => setTimeout(resolve, 0))
105
+ assert.equal(release.length, 2, '完成后立即补位')
106
+ release.splice(0).forEach((resolve) => resolve())
107
+ await new Promise((resolve) => setTimeout(resolve, 0))
108
+ release.splice(0).forEach((resolve) => resolve())
109
+ await done
110
+ // Then 全部 6 项被处理,无重复无遗漏
111
+ assert.equal(processed.length, 6)
112
+ assert.deepEqual([...processed].sort((a, b) => a - b), [1, 2, 3, 4, 5, 6])
113
+ })
114
+
115
+ test('场景: 受限并发执行——单任务失败不中断其余任务(失败由 task 内消化)', async () => {
116
+ // Given task 对偶数项抛错但就地消化(生产用法:runQuery().catch 口径),runLimited 恒 resolve
117
+ const processed = []
118
+ const task = async (item) => {
119
+ try {
120
+ if (item % 2 === 0) throw new Error('boom-' + item)
121
+ processed.push(item)
122
+ } catch { /* 单项失败只占自己的 worker 位 */ }
123
+ }
124
+ // When 5 项、并发 2
125
+ await runLimited([1, 2, 3, 4, 5], 2, task)
126
+ // Then 奇数项全被处理
127
+ assert.deepEqual([...processed].sort((a, b) => a - b), [1, 3, 5])
128
+ })
129
+
130
+ test('场景: 受限并发执行——空列表与并发大于任务数', async () => {
131
+ const processed = []
132
+ await runLimited([], 4, async (item) => processed.push(item))
133
+ assert.deepEqual(processed, [])
134
+ await runLimited([1, 2], 8, async (item) => processed.push(item))
135
+ assert.deepEqual(processed, [1, 2])
136
+ })