dsh-whale-widget 0.3.16 → 0.3.18
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 +12 -2
- package/assets/whale-widget.js +508 -67
- package/lib/accounting.mjs +39 -2
- package/lib/index.js +286 -41
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -28,9 +28,20 @@ const IMAGE_CANDIDATES = [
|
|
|
28
28
|
path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
|
|
29
29
|
]
|
|
30
30
|
// 自定义角色图片存放目录(第一个可写的会被使用;roles.json 存角色元数据)
|
|
31
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):**用户数据的候选表一律 $DSH_HOME 优先**,开发机路径降级为兜底。
|
|
32
|
+
// 原因:桌面客户端加载的是 strip 过的发布副本(只认 $DSH_HOME),而开发版宿主原先把开发机路径排在第一位
|
|
33
|
+
// ⇒ 本机上网页端与桌面端**各写各的**(实测两端同时各写一份 .dshw-usage.json / .dshw-bubble.json),
|
|
34
|
+
// 互相看不到对方的改动 —— 表现就是"删掉的模块又冒出来""记账数对不上"。
|
|
35
|
+
// ⚠️ 这个常量必须在**所有**用到它的候选表之前定义(const 有 TDZ),所以放在最前面。
|
|
36
|
+
// ⚠️ `_strip-dev-paths.mjs` 会把发布副本里含开发机路径的行删掉,发布版只剩前两条 $DSH_HOME ✓
|
|
37
|
+
const DATA_CANDIDATES_TAIL = [
|
|
38
|
+
]
|
|
39
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):**角色图库目录也 $DSH_HOME 优先**(开发机路径降级为兜底)。
|
|
31
40
|
const ROLE_DIR_CANDIDATES = [
|
|
32
41
|
path.join(DSH_HOME, 'whale-roles'),
|
|
33
42
|
path.join(DSH_HOME, 'profiles', 'web', 'whale-roles'),
|
|
43
|
+
DATA_CANDIDATES_TAIL[0] + 'whale-roles/',
|
|
44
|
+
DATA_CANDIDATES_TAIL[1] + 'whale-roles/',
|
|
34
45
|
]
|
|
35
46
|
const ROLE_INDEX_NAME = 'roles.json'
|
|
36
47
|
const ROLE_DEFAULT_ID = 'default'
|
|
@@ -38,6 +49,8 @@ const ROLE_DEFAULT_ID = 'default'
|
|
|
38
49
|
const AUDIO_DIR_CANDIDATES = [
|
|
39
50
|
path.join(DSH_HOME, 'whale-audio'),
|
|
40
51
|
path.join(DSH_HOME, 'profiles', 'web', 'whale-audio'),
|
|
52
|
+
DATA_CANDIDATES_TAIL[0] + 'whale-audio/',
|
|
53
|
+
DATA_CANDIDATES_TAIL[1] + 'whale-audio/',
|
|
41
54
|
]
|
|
42
55
|
const AUDIO_INDEX_NAME = 'audio.json'
|
|
43
56
|
// 内置预设音效组(不可删、不可改),对应 assets 里的 Ya/D 系列
|
|
@@ -70,9 +83,12 @@ const BUILTIN_FRAGMENT_FILES = {
|
|
|
70
83
|
}
|
|
71
84
|
// —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
|
|
72
85
|
// 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
|
|
86
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):$DSH_HOME 优先(自定义 API 模型清单也属于用户数据,原先两端各存一份)。
|
|
73
87
|
const API_FILE_CANDIDATES = [
|
|
74
88
|
path.join(DSH_HOME, '.dshw-api.json'),
|
|
75
89
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
|
|
90
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-api.json',
|
|
91
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-api.json',
|
|
76
92
|
]
|
|
77
93
|
const API_BUILTIN_ID = 'deepseek'
|
|
78
94
|
// 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
|
|
@@ -163,7 +179,17 @@ const API_TEMPLATES = {
|
|
|
163
179
|
quota: {
|
|
164
180
|
url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
|
|
165
181
|
auth: '{key}', // 智谱此接口不带 Bearer
|
|
166
|
-
|
|
182
|
+
// v789(issue #180/#181 修):`data.limits[]` 是扁平条目数组,`TOKENS_LIMIT` 是条目的 `type` **值**,
|
|
183
|
+
// 且 5h 窗口不保证排在 [0](pro 账号 [0] 是 TIME_LIMIT、max 账号 [0] 才是 TOKENS_LIMIT)
|
|
184
|
+
// ⇒ 一律按 type(+unit) 选条目:unit=3 = 5 小时窗口、unit=6 = 周窗口;
|
|
185
|
+
// 新注册/credit 制账号的 type 是 CREDIT_LIMIT ⇒ 一并兼容。
|
|
186
|
+
json: {
|
|
187
|
+
level: 'data.level',
|
|
188
|
+
windows: [
|
|
189
|
+
{ key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
190
|
+
{ key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
167
193
|
},
|
|
168
194
|
probeUrl: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
|
|
169
195
|
},
|
|
@@ -346,7 +372,14 @@ const API_TEMPLATES = {
|
|
|
346
372
|
quota: {
|
|
347
373
|
url: 'https://api.z.ai/api/monitor/usage/quota/limit',
|
|
348
374
|
auth: '{key}', // 与国内站一样不带 Bearer
|
|
349
|
-
|
|
375
|
+
// v789:与国内站同一套 `data.limits[]` 结构(见 zhipu_glm_coding 的注释),一并按 type+unit 选条目
|
|
376
|
+
json: {
|
|
377
|
+
level: 'data.level',
|
|
378
|
+
windows: [
|
|
379
|
+
{ key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
380
|
+
{ key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
381
|
+
],
|
|
382
|
+
},
|
|
350
383
|
},
|
|
351
384
|
probeUrl: 'https://api.z.ai/api/monitor/usage/quota/limit',
|
|
352
385
|
},
|
|
@@ -365,20 +398,28 @@ const API_TEMPLATES = {
|
|
|
365
398
|
},
|
|
366
399
|
}
|
|
367
400
|
|
|
401
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):这些**用户数据文件一律 $DSH_HOME 优先**,开发机路径降级为只读兜底。
|
|
402
|
+
// 原因见上方 AUDIO_DIR_CANDIDATES 的说明(桌面端只认 $DSH_HOME,两端原先各写各的)。
|
|
368
403
|
// Size memory file: prefer writable DSH home locations, then legacy fallbacks.
|
|
369
404
|
const SIZE_FILE_CANDIDATES = [
|
|
370
405
|
path.join(DSH_HOME, '.dshw-size.json'),
|
|
371
406
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
|
|
407
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-size.json',
|
|
408
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-size.json',
|
|
372
409
|
]
|
|
373
410
|
// Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
|
|
374
411
|
const USAGE_FILE_CANDIDATES = [
|
|
375
412
|
path.join(DSH_HOME, '.dshw-usage.json'),
|
|
376
413
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
|
|
414
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-usage.json',
|
|
415
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-usage.json',
|
|
377
416
|
]
|
|
378
417
|
// 每轮消耗 seq 持久化文件:避免插件热重载后 seq 归零导致页面把新轮次误判为旧轮次吞掉
|
|
379
418
|
const TURN_FILE_CANDIDATES = [
|
|
380
419
|
path.join(DSH_HOME, '.dshw-turn.json'),
|
|
381
420
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-turn.json'),
|
|
421
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-turn.json',
|
|
422
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-turn.json',
|
|
382
423
|
]
|
|
383
424
|
// Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/),
|
|
384
425
|
// legacy absolute paths as fallback.
|
|
@@ -387,6 +428,8 @@ const BUBBLE_FILE_CANDIDATES = [
|
|
|
387
428
|
// 否则清完这个数组会变成空的:泡泡配置读不了也存不了(自定义泡泡整体失效)。
|
|
388
429
|
path.join(DSH_HOME, '.dshw-bubble.json'),
|
|
389
430
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-bubble.json'),
|
|
431
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-bubble.json',
|
|
432
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-bubble.json',
|
|
390
433
|
]
|
|
391
434
|
// 泡泡图库(独立于角色图库):图片文件目录 + 索引
|
|
392
435
|
const BUBBLE_IMG_DIR_CANDIDATES = [
|
|
@@ -394,6 +437,8 @@ const BUBBLE_IMG_DIR_CANDIDATES = [
|
|
|
394
437
|
// 空数组会让图库上传直接报错。
|
|
395
438
|
path.join(DSH_HOME, 'whale-bubble-imgs'),
|
|
396
439
|
path.join(DSH_HOME, 'profiles', 'web', 'whale-bubble-imgs'),
|
|
440
|
+
DATA_CANDIDATES_TAIL[0] + 'whale-bubble-imgs/',
|
|
441
|
+
DATA_CANDIDATES_TAIL[1] + 'whale-bubble-imgs/',
|
|
397
442
|
]
|
|
398
443
|
const BUBBLE_IMG_INDEX_NAME = 'bubble-imgs.json'
|
|
399
444
|
// 内置默认泡泡图(语义 id → assets 文件):面板常驻可选;全新安装无用户图库时
|
|
@@ -430,6 +475,8 @@ function soundSetFromUrl(url) {
|
|
|
430
475
|
}
|
|
431
476
|
const BALANCE_URL = 'https://api.deepseek.com/user/balance'
|
|
432
477
|
const BALANCE_TTL_MS = 25000
|
|
478
|
+
// v781:账号登录路的兜底超时(毫秒)。DSH 账号服务自身默认 30s,太长会把前端 FETCH_TIMEOUT_MS(25s) 先耗掉。
|
|
479
|
+
const ACCOUNT_FALLBACK_MS = 5000
|
|
433
480
|
const RUA_GIF_CANDIDATES = [
|
|
434
481
|
path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
|
|
435
482
|
]
|
|
@@ -1017,10 +1064,13 @@ export default {
|
|
|
1017
1064
|
// ⚠️ 必须用 `ctx.get('deepseekAccount')` 读**可选服务**,**绝不能写进 inject** —— 老宿主根本没有这个服务,
|
|
1018
1065
|
// 一旦进 inject,整个插件会永远等不到服务而**完全不 apply**(比没有这个功能严重得多)。
|
|
1019
1066
|
// ⚠️ 与 API key 路径**互不干扰**(API key 永远优先;DSH 自己的注释也是 "Neither route falls back to the other")。
|
|
1067
|
+
// v781:账号余额路的失败原因(只用于错误文案,不参与任何判定)。fetchAccountBalance 每次开头清空。
|
|
1068
|
+
let accountDiag = ''
|
|
1020
1069
|
async function fetchAccountBalance() {
|
|
1021
1070
|
let account = null
|
|
1071
|
+
accountDiag = ''
|
|
1022
1072
|
try { account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null } catch (err) { account = null }
|
|
1023
|
-
if (!account || typeof account.getBalance !== 'function') return null
|
|
1073
|
+
if (!account || typeof account.getBalance !== 'function') { accountDiag = '当前 DSH 没有账号服务'; return null }
|
|
1024
1074
|
// 账户标识:优先用账号服务给的稳定 id(不同账号 → 不同账本 scope);取不到就退回固定标识。
|
|
1025
1075
|
// 一律 sha256[:24] 十六进制 —— accounting.mjs 校验 scope 必须匹配 /^[a-zA-Z0-9_-]{1,80}$/
|
|
1026
1076
|
// (带冒号这种可读写法会直接抛「账户标识无效」,见 issue #157 报告人踩的坑 1)。
|
|
@@ -1036,25 +1086,52 @@ export default {
|
|
|
1036
1086
|
locale: String(process.env.DSH_LOCALE || '') || 'zh_CN',
|
|
1037
1087
|
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, // 东为正的整秒
|
|
1038
1088
|
})
|
|
1039
|
-
} catch (err) {
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
accountDiag = '账号余额接口调用失败: ' + String((err && err.message) || err).slice(0, 120)
|
|
1091
|
+
return null
|
|
1092
|
+
}
|
|
1040
1093
|
// 未登录 / 没有 grant / 平台失败 → 一律返回 null,让调用方退回原来的文案(**不缓存错误**)
|
|
1041
|
-
|
|
1094
|
+
// v781:失败时记下**原因**(accountDiag),供 fetchBalance 把两条路的原因一起报给界面 ——
|
|
1095
|
+
// 以前只有一个笼统的"账号余额不可用",用户/支持方分不清"没登录"和"接口失败"。
|
|
1096
|
+
if (!result) { accountDiag = '未登录 DeepSeek 账号(没有账号凭据)'; return null }
|
|
1097
|
+
if (result.status !== 'ready') { accountDiag = '账号余额接口未就绪(' + String(result.status || 'unknown') + ')'; return null }
|
|
1042
1098
|
const accNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1099
|
+
// v781:**"充值钱包为空、只有赠金"必须算成功** —— 这是"只登录客户端读不到余额"的主因。
|
|
1100
|
+
// 平台把钱包拆成 `normal_wallets`(充值)与 `bonus_wallets`(赠金);新用户/没充过值的用户
|
|
1101
|
+
// 常常**只有赠金**(甚至 normal_wallets 是空数组)⇒ 旧代码要求 `result.value` 非空,
|
|
1102
|
+
// 直接把这类账号判成"没有余额钱包",界面就只显示"读取不到余额"。
|
|
1103
|
+
// 判据对齐 DSH 自己的账号卡片(dsh-client-ui-settings-account):
|
|
1104
|
+
// `[...balance.value, ...balance.bonusWallets].some(w => new Big(w.balance).gt(0))`
|
|
1105
|
+
// —— 两个钱包一起看,不再单看充值钱包。
|
|
1106
|
+
const normRaw = Array.isArray(result.value) ? result.value : []
|
|
1107
|
+
const bonusRaw = Array.isArray(result.bonusWallets) ? result.bonusWallets : []
|
|
1108
|
+
const usable = (list) => list.filter((w) => w && accNum(w.balance) !== null)
|
|
1109
|
+
const normWallets = usable(normRaw)
|
|
1110
|
+
const bonusWallets = usable(bonusRaw)
|
|
1111
|
+
const allWallets = normWallets.concat(bonusWallets)
|
|
1112
|
+
if (allWallets.length === 0) {
|
|
1113
|
+
// 两边都没有可用钱包 = 真的没有数据(未激活/账号没有任何钱包),不是"余额 0"
|
|
1114
|
+
accountDiag = normRaw.length || bonusRaw.length ? '钱包字段不可用' : '账号没有余额钱包'
|
|
1115
|
+
return null
|
|
1116
|
+
}
|
|
1117
|
+
// 币种:优先「有余额的那个钱包」的币种(CNY 优先),其次任何 CNY 钱包,最后第一个
|
|
1118
|
+
const curOf = (w) => String((w && w.currency) || 'CNY').toUpperCase()
|
|
1119
|
+
const nonzero = allWallets.filter((w) => Number(w.balance) > 0)
|
|
1120
|
+
const curPick = nonzero.find((w) => curOf(w) === 'CNY') || nonzero[0]
|
|
1121
|
+
|| allWallets.find((w) => curOf(w) === 'CNY') || allWallets[0]
|
|
1122
|
+
const currency = curOf(curPick)
|
|
1123
|
+
const sumOf = (list) => list
|
|
1124
|
+
.filter((w) => w && curOf(w) === currency && accNum(w.balance) !== null)
|
|
1050
1125
|
.reduce((s, w) => s + Number(w.balance), 0)
|
|
1051
|
-
const recharge = sumOf(
|
|
1052
|
-
const bonus = sumOf(
|
|
1126
|
+
const recharge = sumOf(normWallets)
|
|
1127
|
+
const bonus = sumOf(bonusWallets)
|
|
1128
|
+
accountDiag = ''
|
|
1053
1129
|
return {
|
|
1054
1130
|
ok: true,
|
|
1055
1131
|
// 充值 + 赠金:与 API key 路径的 `total_balance` 同口径(那个字段本身也是两者相加),
|
|
1056
1132
|
// 否则"按余额差"推算的今日已用会偏小(issue #157 报告人的坑 2)。
|
|
1057
1133
|
totalBalance: Number((recharge + bonus).toFixed(6)),
|
|
1134
|
+
// v781:两个分量单独下发,供「赠金 / 账户余额」两个自定义模块使用(见 SPEC §25)
|
|
1058
1135
|
rechargeBalance: Number(recharge.toFixed(6)),
|
|
1059
1136
|
bonusBalance: Number(bonus.toFixed(6)),
|
|
1060
1137
|
currency,
|
|
@@ -1064,30 +1141,64 @@ export default {
|
|
|
1064
1141
|
}
|
|
1065
1142
|
}
|
|
1066
1143
|
|
|
1144
|
+
// v781:账号路的兜底(**两条路都失败才报错,并把两条路的原因都带上**)。
|
|
1145
|
+
// 为什么需要它:旧实现只在"完全没有 API key"时才试账号路 ⇒ 曾经配过 key(过期/写错/被删)
|
|
1146
|
+
// 但现在只用客户端登录的用户,余额**永远读不到**("只登录客户端读不到余额"报告里的另一大类)。
|
|
1147
|
+
// ⚠️ 超时上限:账号路走 DSH 自己的服务(默认 30s)⇒ 我们用 ACCOUNT_FALLBACK_MS 截断,
|
|
1148
|
+
// 保证"API key 两次 8s + 退避"之后仍在**前端的 25s** 之内回来,不让界面先 abort。
|
|
1149
|
+
async function accountFallback(keyErr) {
|
|
1150
|
+
let acc = null
|
|
1151
|
+
try {
|
|
1152
|
+
acc = await Promise.race([
|
|
1153
|
+
fetchAccountBalance(),
|
|
1154
|
+
new Promise((r) => setTimeout(() => { accountDiag = '账号余额请求超时'; r(null) }, ACCOUNT_FALLBACK_MS)),
|
|
1155
|
+
])
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
acc = null
|
|
1158
|
+
accountDiag = accountDiag || ('账号余额异常: ' + String((err && err.message) || err).slice(0, 120))
|
|
1159
|
+
}
|
|
1160
|
+
if (acc) return acc
|
|
1161
|
+
const keyPart = keyErr
|
|
1162
|
+
? ('API key 路:' + String((keyErr.error || keyErr.code || '失败')).slice(0, 160))
|
|
1163
|
+
: '未配置 DEEPSEEK_API_KEY'
|
|
1164
|
+
return {
|
|
1165
|
+
ok: false,
|
|
1166
|
+
code: keyErr ? 'BOTH_FAILED' : 'NO_KEY',
|
|
1167
|
+
error: keyPart + ';账号登录路:' + (accountDiag || '拿不到余额'),
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1067
1170
|
async function fetchBalance() {
|
|
1068
1171
|
// v739(用户反馈「每次新实例的第一次余额请求都失败」):
|
|
1069
1172
|
// ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
|
|
1070
1173
|
// ② 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
|
|
1071
1174
|
// 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
|
|
1072
1175
|
let cred = null
|
|
1176
|
+
let credErr = null
|
|
1073
1177
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1074
1178
|
try {
|
|
1075
1179
|
cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
|
|
1180
|
+
credErr = null
|
|
1076
1181
|
break
|
|
1077
1182
|
} catch (err) {
|
|
1183
|
+
credErr = err
|
|
1078
1184
|
if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
|
|
1079
|
-
|
|
1185
|
+
// v781:凭据服务报错时**也要试账号路**(以前直接 return,登录态用户就被这条挡住)
|
|
1186
|
+
return await accountFallback({ code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) })
|
|
1080
1187
|
}
|
|
1081
1188
|
}
|
|
1082
1189
|
if (!cred) {
|
|
1083
1190
|
// v759(issue #157):没有 API key 时退回 DSH 账号登录态;两条路互不干扰,API key 始终第一优先。
|
|
1084
|
-
|
|
1085
|
-
if (accountPayload) return accountPayload
|
|
1086
|
-
return {
|
|
1087
|
-
ok: false, code: 'NO_KEY',
|
|
1088
|
-
error: '未配置 DEEPSEEK_API_KEY,且账号余额不可用(未登录 DeepSeek 账号,或该账号没有余额钱包)',
|
|
1089
|
-
}
|
|
1191
|
+
return await accountFallback(null)
|
|
1090
1192
|
}
|
|
1193
|
+
const apiFail = await fetchApiKeyBalance(cred)
|
|
1194
|
+
if (apiFail.ok) return apiFail
|
|
1195
|
+
// v781:key 路**任何**失败都再试一次账号路(401/结构异常/超时都算)
|
|
1196
|
+
void credErr
|
|
1197
|
+
return await accountFallback(apiFail)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// API key 路(原 fetchBalance 的主体,v781 抽出来以便"失败后回退账号路")
|
|
1201
|
+
async function fetchApiKeyBalance(cred) {
|
|
1091
1202
|
let lastErr = null
|
|
1092
1203
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1093
1204
|
let res
|
|
@@ -1117,9 +1228,15 @@ export default {
|
|
|
1117
1228
|
if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
|
|
1118
1229
|
return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
|
|
1119
1230
|
}
|
|
1231
|
+
const numOr = (x) => { const n = Number(x); return Number.isFinite(n) ? n : null }
|
|
1120
1232
|
return {
|
|
1121
1233
|
ok: true,
|
|
1122
1234
|
totalBalance: Number(info.total_balance),
|
|
1235
|
+
// v781:官方 /user/balance 除 total_balance 外还给了 granted_balance(**未过期**赠金)与
|
|
1236
|
+
// topped_up_balance(充值)。取不到就是 null(界面显示 —,**不臆造 0** —— 0 会被记账
|
|
1237
|
+
// 当成"余额真的见底",那是另一种语义)。两个分量供自定义模块里的「赠金 / 账户余额」使用。
|
|
1238
|
+
bonusBalance: numOr(info.granted_balance),
|
|
1239
|
+
rechargeBalance: numOr(info.topped_up_balance),
|
|
1123
1240
|
accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
|
|
1124
1241
|
currency: String(info.currency || 'CNY'),
|
|
1125
1242
|
balanceSource: 'apikey',
|
|
@@ -1285,7 +1402,7 @@ export default {
|
|
|
1285
1402
|
const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
|
|
1286
1403
|
for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
|
|
1287
1404
|
return {
|
|
1288
|
-
ok: true, version: '0.3.
|
|
1405
|
+
ok: true, version: '0.3.18', today: todayData, days7,
|
|
1289
1406
|
total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
|
|
1290
1407
|
all: {
|
|
1291
1408
|
days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
|
|
@@ -1321,7 +1438,10 @@ export default {
|
|
|
1321
1438
|
// (只冒泡)—— 要响就把音效行的 [✓] 勾上。
|
|
1322
1439
|
// v764:turnCost **刻意不带** autoClose / ttlSec —— 自动关闭的真实来源是 .dshw-size.json 的
|
|
1323
1440
|
// turnCostCloseMs,留着这两个键会让同一个设置有两个来源(其中一个永远是假的)。
|
|
1324
|
-
|
|
1441
|
+
// v778:`volSet` = 用户**显式**调过②区「提示音量」没有(默认 false = 跟随①区按压音量)。
|
|
1442
|
+
// 没有它就无法区分"默认 100%"与"用户设了 100%",前端 soundVolumeOf() 靠它决定优先级。
|
|
1443
|
+
// ⚠️ 与前端 soundEventDefaults().turnCost **必须逐字段一致**(探针 §⑥ 跨端一致钉住)。
|
|
1444
|
+
turnCost: { vol: 1, volSet: false, bubbleOn: true },
|
|
1325
1445
|
question: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('question') },
|
|
1326
1446
|
approval: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('approval') },
|
|
1327
1447
|
}
|
|
@@ -1329,6 +1449,10 @@ export default {
|
|
|
1329
1449
|
function usageSettingsDefaults() {
|
|
1330
1450
|
return { // 任务结束音:v774 起出厂默认 = **开**(作者当前用法),默认音 = 内置 A(end_a)
|
|
1331
1451
|
taskEnd: { on: true, sel: 'frag:end_a' },
|
|
1452
|
+
// v777:「等待交互提示」的交互开关(用户 2026-09-26 要求)
|
|
1453
|
+
// charClose=false(出厂默认)= 只有**点泡泡**能收起等待提示;true = **点角色**也能收起。
|
|
1454
|
+
// 两种情况下"点掉的那条挂起"都不会再被每秒轮询自动弹回(前端 waitDismissedId)。
|
|
1455
|
+
wait: { charClose: false },
|
|
1332
1456
|
// v761:四个事件的音效/自动关闭/冒泡配置(面板「全局音效设置」)
|
|
1333
1457
|
events: soundEventsDefaults(),
|
|
1334
1458
|
// 默认固化自开发环境当前 usage.json 设置(全新安装即此体验)
|
|
@@ -1411,6 +1535,8 @@ export default {
|
|
|
1411
1535
|
if (p.budget && typeof p.budget === 'object') led.settings.budget = Object.assign({}, led.settings.budget || {}, p.budget)
|
|
1412
1536
|
// 每轮消耗提示内容(自定义提示窗口)
|
|
1413
1537
|
if (p.turnCost && typeof p.turnCost === 'object') led.settings.turnCost = Object.assign({}, led.settings.turnCost || {}, p.turnCost)
|
|
1538
|
+
// v777:「等待交互提示」的交互开关(点角色是否也能关闭等待提示气泡)
|
|
1539
|
+
if (p.wait && typeof p.wait === 'object') led.settings.wait = Object.assign({}, led.settings.wait || {}, p.wait)
|
|
1414
1540
|
// v761(全局音效设置):events 补丁逐事件合并(前端只发改动的那几个字段)
|
|
1415
1541
|
if (p.events && typeof p.events === 'object') {
|
|
1416
1542
|
led.settings.events = led.settings.events && typeof led.settings.events === 'object' ? led.settings.events : {}
|
|
@@ -1426,6 +1552,8 @@ export default {
|
|
|
1426
1552
|
// 「恢复默认」:只重置音效/提示类键(不动外观、位置、账本、角色与泡泡自定义)
|
|
1427
1553
|
if (p.resetEvents === true) {
|
|
1428
1554
|
led.settings.events = soundEventsDefaults()
|
|
1555
|
+
// v777:等待提示的交互开关也属于"提示类键" ⇒ 一起恢复出厂默认(charClose=false)
|
|
1556
|
+
led.settings.wait = { charClose: false }
|
|
1429
1557
|
// 任务结束音也属于「音效类键」:宿主重置与面板显示必须口径一致(否则面板显示与真实状态撕裂)
|
|
1430
1558
|
// v774:重置目标 = 新的出厂默认(开 + 内置 A)
|
|
1431
1559
|
led.settings.taskEnd = { on: true, sel: 'frag:end_a' }
|
|
@@ -1988,14 +2116,48 @@ export default {
|
|
|
1988
2116
|
}
|
|
1989
2117
|
return { key, code: '', error: '' }
|
|
1990
2118
|
}
|
|
1991
|
-
|
|
2119
|
+
// v789(issue #190):余额/额度接口不再局限于 GET —— 模板或模型条目可声明 method / body。
|
|
2120
|
+
// ① method 默认 GET;非 GET 时带 `Content-Type: application/json` 并把 body 发出去;
|
|
2121
|
+
// ② body 支持占位符:`{key}`(凭据)、`{base}`(Base URL)、以及 `model.params` 里的任意键(如 `{uuid}`)
|
|
2122
|
+
// —— 这样"POST /v1/enterprise/account {"uuid":"…"}" 这类自建网关不需要再额外挂一层转换服务;
|
|
2123
|
+
// ③ 安全边界不变:body 只来自配置(不接受请求方传入),目的地仍走 credentialMayGoTo() 白名单,
|
|
2124
|
+
// URL 仍过 assertSafeApiUrl()。
|
|
2125
|
+
function bodyWithPlaceholders(body, key, base, params) {
|
|
2126
|
+
let s = String(body == null ? '' : body)
|
|
2127
|
+
s = s.split('{key}').join(String(key == null ? '' : key))
|
|
2128
|
+
s = s.split('{base}').join(String(base == null ? '' : base))
|
|
2129
|
+
if (params && typeof params === 'object') {
|
|
2130
|
+
for (const k of Object.keys(params)) {
|
|
2131
|
+
const v = params[k]
|
|
2132
|
+
if (v === undefined || v === null || typeof v === 'object') continue
|
|
2133
|
+
s = s.split('{' + k + '}').join(String(v))
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
return s
|
|
2137
|
+
}
|
|
2138
|
+
function httpMethodOf(cfg) {
|
|
2139
|
+
const m = String((cfg && cfg.method) || 'GET').trim().toUpperCase()
|
|
2140
|
+
return /^[A-Z]{3,7}$/.test(m) ? m : 'GET'
|
|
2141
|
+
}
|
|
2142
|
+
async function apiFetchJson(url, auth, key, opts) {
|
|
2143
|
+
const o = opts || {}
|
|
1992
2144
|
const headers = {}
|
|
1993
2145
|
const a = String(auth == null ? 'Bearer {key}' : auth)
|
|
1994
2146
|
if (a) headers.Authorization = a.replace('{key}', key)
|
|
1995
2147
|
// v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
|
|
1996
2148
|
const u = String(url).replace('{key}', key)
|
|
1997
2149
|
assertSafeApiUrl(u)
|
|
1998
|
-
const
|
|
2150
|
+
const init = { headers, signal: AbortSignal.timeout(15000) }
|
|
2151
|
+
const method = httpMethodOf(o)
|
|
2152
|
+
if (method !== 'GET') {
|
|
2153
|
+
init.method = method
|
|
2154
|
+
const raw = o.body
|
|
2155
|
+
if (raw !== undefined && raw !== null && String(raw).length) {
|
|
2156
|
+
headers['Content-Type'] = 'application/json'
|
|
2157
|
+
init.body = bodyWithPlaceholders(raw, key, o.base, o.params)
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
const res = await fetch(u, init)
|
|
1999
2161
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
2000
2162
|
return await res.json()
|
|
2001
2163
|
}
|
|
@@ -2031,14 +2193,17 @@ export default {
|
|
|
2031
2193
|
}
|
|
2032
2194
|
const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
|
|
2033
2195
|
const base = String(model.baseUrl || '').replace(/\/+$/, '')
|
|
2034
|
-
|
|
2196
|
+
// v789(issue #190):声明了 POST 等非 GET 方法时,测试就直接打**用户配置的余额接口**(带同一个 body)——
|
|
2197
|
+
// 否则会拿 GET 去打一个只支持 POST 的地址、误报"测试失败"(probeUrl 多是 models 列表,body 不通用)。
|
|
2198
|
+
const nonGet = httpMethodOf(b) !== 'GET'
|
|
2199
|
+
const url = String(nonGet ? (b.url || tpl.probeUrl || '') : (tpl.probeUrl || b.url || (base ? base + '/v1/models' : ''))).replace('{base}', base)
|
|
2035
2200
|
if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
|
|
2036
2201
|
// v761(安全修复 S1):只在目的地可信时才带上凭据
|
|
2037
2202
|
const k = await keyForDestination(model, tpl, url)
|
|
2038
2203
|
if (!k.key) return { ok: false, code: k.code, error: k.error }
|
|
2039
2204
|
const key = k.key
|
|
2040
2205
|
try {
|
|
2041
|
-
const data = await apiFetchJson(url, b.auth, key)
|
|
2206
|
+
const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
|
|
2042
2207
|
const bizErr = apiBusinessError(data)
|
|
2043
2208
|
if (bizErr) return { ok: false, error: '测试失败: ' + bizErr }
|
|
2044
2209
|
let detail = 'HTTP 200'
|
|
@@ -2049,14 +2214,41 @@ export default {
|
|
|
2049
2214
|
return { ok: false, error: '测试失败: ' + String((err && err.message) || err).slice(0, 140) }
|
|
2050
2215
|
}
|
|
2051
2216
|
}
|
|
2217
|
+
// v789(issue #180/#181):额度接口里"多窗口 + 数组顺序不保证"的通用取数。
|
|
2218
|
+
// 智谱 `GET /api/monitor/usage/quota/limit` 返回的 `data.limits[]` 是**扁平条目数组**:
|
|
2219
|
+
// `TOKENS_LIMIT` 是每条的 `type` 字段值(不是 `limits[0]` 的子对象),5h 窗口不保证排在 `[0]`
|
|
2220
|
+
// (实测:pro 账号 `limits[0]` 是 TIME_LIMIT,max 账号 `limits[0]` 是 TOKENS_LIMIT ⇒ 按位置取数必然出错过),
|
|
2221
|
+
// 而且新注册/credit 制账号把 `type` 改成了 `CREDIT_LIMIT`。
|
|
2222
|
+
// 所以模板可以声明 `pick: { list, type/types, unit, number }`:先选中**那一条**,再把它的字段当作取数根。
|
|
2223
|
+
function pickQuotaEntry(data, pick) {
|
|
2224
|
+
try {
|
|
2225
|
+
if (!pick || !pick.list) return null
|
|
2226
|
+
const arr = pickJsonPath(data, pick.list)
|
|
2227
|
+
if (!Array.isArray(arr)) return null
|
|
2228
|
+
const types = []
|
|
2229
|
+
if (pick.type) types.push(String(pick.type))
|
|
2230
|
+
if (Array.isArray(pick.types)) for (const t of pick.types) types.push(String(t))
|
|
2231
|
+
const unit = (pick.unit === undefined || pick.unit === null) ? null : Number(pick.unit)
|
|
2232
|
+
const number = (pick.number === undefined || pick.number === null) ? null : Number(pick.number)
|
|
2233
|
+
for (const it of arr) {
|
|
2234
|
+
if (!it || typeof it !== 'object') continue
|
|
2235
|
+
if (types.length && types.indexOf(String(it.type)) < 0) continue
|
|
2236
|
+
if (unit !== null && Number(it.unit) !== unit) continue
|
|
2237
|
+
if (number !== null && Number(it.number) !== number) continue
|
|
2238
|
+
return it
|
|
2239
|
+
}
|
|
2240
|
+
return null
|
|
2241
|
+
} catch (err) { return null }
|
|
2242
|
+
}
|
|
2052
2243
|
// 订阅额度(Coding Plan)解析:各厂商字段不同,统一归一成「已用% + 重置时间 + 档位」
|
|
2053
|
-
// zhipu: data.limits[
|
|
2244
|
+
// zhipu: data.limits[] 里 type=TOKENS_LIMIT/CREDIT_LIMIT + unit=3(5h)/6(周) 的条目(v789 起按 pick 选)
|
|
2054
2245
|
// kimi: usage.remaining / usage.limit(剩余/总量)
|
|
2055
2246
|
// minimax: model_remains[0].current_interval_remaining_percent(剩余%)+ end_time(ms)
|
|
2056
2247
|
async function fetchModelQuota(model) {
|
|
2057
2248
|
if (!model) return { ok: false, error: '模型不存在' }
|
|
2058
2249
|
const tpl = apiTemplateOf(model.provider) || {}
|
|
2059
|
-
|
|
2250
|
+
// v789(issue #180 建议 3):模型条目也能覆盖额度配置 —— 厂商接口变动时用户可自救(不留空即覆盖)
|
|
2251
|
+
const q = mergeNonEmpty(tpl.quota, model.quota)
|
|
2060
2252
|
if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
|
|
2061
2253
|
const base = String(model.baseUrl || '').replace(/\/+$/, '')
|
|
2062
2254
|
const url = String(q.url).replace('{base}', base)
|
|
@@ -2065,31 +2257,33 @@ export default {
|
|
|
2065
2257
|
if (!k.key) return { ok: false, code: k.code, error: k.error }
|
|
2066
2258
|
const key = k.key
|
|
2067
2259
|
try {
|
|
2068
|
-
const data = await apiFetchJson(url, q.auth, key)
|
|
2260
|
+
const data = await apiFetchJson(url, q.auth, key, { method: q.method, body: q.body, base: base, params: model.params })
|
|
2069
2261
|
const bizErr = apiBusinessError(data)
|
|
2070
2262
|
if (bizErr) return { ok: false, code: 'BIZ', error: bizErr }
|
|
2071
2263
|
const j = q.json || {}
|
|
2072
2264
|
const num = (v) => { const n = Number(v); return isFinite(n) ? n : null }
|
|
2265
|
+
// v789:若模板声明了顶层 pick(单窗口 + 数组筛选),先把取数根换成选中的那条
|
|
2266
|
+
const jroot = j.pick ? (pickQuotaEntry(data, j.pick) || {}) : data
|
|
2073
2267
|
let usedPct = null, remainPct = null, resetAt = null, level = '', weeklyUsedPct = null
|
|
2074
|
-
if (j.percent) { const p = num(pickJsonPath(
|
|
2268
|
+
if (j.percent) { const p = num(pickJsonPath(jroot, j.percent)); if (p !== null) usedPct = p }
|
|
2075
2269
|
if (j.remainPct) {
|
|
2076
|
-
const p = num(pickJsonPath(
|
|
2270
|
+
const p = num(pickJsonPath(jroot, j.remainPct))
|
|
2077
2271
|
if (p !== null) { remainPct = p; if (usedPct === null) usedPct = Math.max(0, 100 - p) }
|
|
2078
2272
|
}
|
|
2079
2273
|
if (j.weeklyRemainPct) {
|
|
2080
|
-
const p = num(pickJsonPath(
|
|
2274
|
+
const p = num(pickJsonPath(jroot, j.weeklyRemainPct))
|
|
2081
2275
|
if (p !== null) weeklyUsedPct = Math.max(0, 100 - p)
|
|
2082
2276
|
}
|
|
2083
2277
|
if (j.remain && j.total) {
|
|
2084
|
-
const r0 = num(pickJsonPath(
|
|
2085
|
-
const t0 = num(pickJsonPath(
|
|
2278
|
+
const r0 = num(pickJsonPath(jroot, j.remain))
|
|
2279
|
+
const t0 = num(pickJsonPath(jroot, j.total))
|
|
2086
2280
|
if (r0 !== null && t0) {
|
|
2087
2281
|
remainPct = Math.max(0, Math.min(100, r0 / t0 * 100))
|
|
2088
2282
|
usedPct = Math.max(0, 100 - remainPct)
|
|
2089
2283
|
}
|
|
2090
2284
|
}
|
|
2091
|
-
if (j.resetAt) resetAt = pickJsonPath(
|
|
2092
|
-
if (j.resetAtMs) { const ms = num(pickJsonPath(
|
|
2285
|
+
if (j.resetAt) resetAt = pickJsonPath(jroot, j.resetAt)
|
|
2286
|
+
if (j.resetAtMs) { const ms = num(pickJsonPath(jroot, j.resetAtMs)); if (ms !== null) resetAt = ms }
|
|
2093
2287
|
if (j.level) level = String(pickJsonPath(data, j.level) || '')
|
|
2094
2288
|
// v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
|
|
2095
2289
|
// rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
|
|
@@ -2100,11 +2294,13 @@ export default {
|
|
|
2100
2294
|
const list = []
|
|
2101
2295
|
for (const w of j.windows) {
|
|
2102
2296
|
if (!w) continue
|
|
2103
|
-
|
|
2297
|
+
// v789:每个窗口可以各自 pick 一条(智谱 5h=unit3 / 周=unit6),选中后以该条为取数根
|
|
2298
|
+
const wroot = w.pick ? (pickQuotaEntry(data, w.pick) || {}) : data
|
|
2299
|
+
let wp = w.percent ? num(pickJsonPath(wroot, w.percent)) : null
|
|
2104
2300
|
if (wp !== null) wp = Math.max(0, Math.min(100, wp))
|
|
2105
2301
|
let wr = null
|
|
2106
2302
|
if (w.resetAt) {
|
|
2107
|
-
const rv = pickJsonPath(
|
|
2303
|
+
const rv = pickJsonPath(wroot, w.resetAt)
|
|
2108
2304
|
if (rv !== undefined && rv !== null && rv !== '') wr = rv
|
|
2109
2305
|
}
|
|
2110
2306
|
if (wp === null && wr === null) continue
|
|
@@ -2157,7 +2353,8 @@ export default {
|
|
|
2157
2353
|
if (!k.key) return { ok: false, code: k.code, error: k.error }
|
|
2158
2354
|
const key = k.key
|
|
2159
2355
|
try {
|
|
2160
|
-
|
|
2356
|
+
// v789(issue #190):把 method / body / params 一并交给取数层(默认 GET,与以前完全一致)
|
|
2357
|
+
const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
|
|
2161
2358
|
const j = b.json || {}
|
|
2162
2359
|
let remaining = null
|
|
2163
2360
|
let total = null
|
|
@@ -2396,12 +2593,56 @@ export default {
|
|
|
2396
2593
|
if (p.allowCustomHost === true) model.allowCustomHost = true
|
|
2397
2594
|
else if (p.allowCustomHost === false) delete model.allowCustomHost
|
|
2398
2595
|
if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
|
|
2596
|
+
// v789(issue #190):请求体里可用的 `{占位符}`(如 {uuid})—— 只允许标量,键名受限
|
|
2597
|
+
if (p.params && typeof p.params === 'object') {
|
|
2598
|
+
const pr = {}
|
|
2599
|
+
for (const k of Object.keys(p.params)) {
|
|
2600
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,30}$/.test(k)) continue
|
|
2601
|
+
const v = p.params[k]
|
|
2602
|
+
if (v === undefined || v === null || typeof v === 'object') continue
|
|
2603
|
+
pr[k] = String(v).slice(0, 300)
|
|
2604
|
+
}
|
|
2605
|
+
if (Object.keys(pr).length) model.params = pr
|
|
2606
|
+
else delete model.params
|
|
2607
|
+
}
|
|
2608
|
+
if (p.quota && typeof p.quota === 'object' && p.quota.json && typeof p.quota.json === 'object') {
|
|
2609
|
+
// v789(issue #180 建议 3):允许模型条目覆盖额度配置(厂商接口变动时用户可自救)。
|
|
2610
|
+
// 只收 JSON-safe 的形状,且路径字符串限长 —— 与模板同构,不做二次校验的滥用面。
|
|
2611
|
+
const qj = {}
|
|
2612
|
+
const pathOf = (v) => String(v || '').slice(0, 200)
|
|
2613
|
+
if (p.quota.url) qj.url = String(p.quota.url).slice(0, 500)
|
|
2614
|
+
if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 200)
|
|
2615
|
+
const jin = p.quota.json
|
|
2616
|
+
for (const k of ['percent', 'remainPct', 'weeklyRemainPct', 'remain', 'total', 'resetAt', 'resetAtMs', 'level']) {
|
|
2617
|
+
if (jin[k]) qj[k] = pathOf(jin[k])
|
|
2618
|
+
}
|
|
2619
|
+
if (Array.isArray(jin.windows)) {
|
|
2620
|
+
qj.windows = jin.windows.slice(0, 6).map((w) => {
|
|
2621
|
+
const o = { key: String((w && w.key) || '').slice(0, 20), label: String((w && w.label) || '').slice(0, 20) }
|
|
2622
|
+
if (w && w.percent) o.percent = pathOf(w.percent)
|
|
2623
|
+
if (w && w.resetAt) o.resetAt = pathOf(w.resetAt)
|
|
2624
|
+
if (w && w.pick && typeof w.pick === 'object') {
|
|
2625
|
+
const pk = { list: pathOf(w.pick.list) }
|
|
2626
|
+
if (w.pick.type) pk.type = String(w.pick.type).slice(0, 40)
|
|
2627
|
+
if (Array.isArray(w.pick.types)) pk.types = w.pick.types.slice(0, 4).map((t) => String(t).slice(0, 40))
|
|
2628
|
+
if (w.pick.unit !== undefined) pk.unit = Number(w.pick.unit)
|
|
2629
|
+
if (w.pick.number !== undefined) pk.number = Number(w.pick.number)
|
|
2630
|
+
o.pick = pk
|
|
2631
|
+
}
|
|
2632
|
+
return o
|
|
2633
|
+
})
|
|
2634
|
+
}
|
|
2635
|
+
if (qj.url && (Object.keys(qj).length > 1)) model.quota = qj
|
|
2636
|
+
}
|
|
2399
2637
|
if (p.balance && typeof p.balance === 'object') {
|
|
2400
2638
|
// v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
|
|
2401
2639
|
// 全部为空时直接删掉 model.balance,等于"完全用模板"
|
|
2402
2640
|
const balIn = stripEmptyDeep({
|
|
2403
2641
|
url: String(p.balance.url || '').slice(0, 500),
|
|
2404
2642
|
auth: String(p.balance.auth || '').slice(0, 200),
|
|
2643
|
+
// v789(issue #190):可选的请求方法与请求体(留空 ⇒ 沿用模板/GET)
|
|
2644
|
+
method: String(p.balance.method || '').slice(0, 8).toUpperCase(),
|
|
2645
|
+
body: String(p.balance.body || '').slice(0, 4000),
|
|
2405
2646
|
json: {
|
|
2406
2647
|
remaining: String((p.balance.json && p.balance.json.remaining) || '').slice(0, 200),
|
|
2407
2648
|
total: String((p.balance.json && p.balance.json.total) || '').slice(0, 200),
|
|
@@ -2502,6 +2743,10 @@ export default {
|
|
|
2502
2743
|
// entry.balance 是数值,不能当描述用(否则面板保存会用空值覆盖描述)
|
|
2503
2744
|
const tplE = apiTemplateOf(m.provider) || {}
|
|
2504
2745
|
entry.balanceDesc = mergeNonEmpty(tplE.balance, m.balance) // v724:空串不覆盖模板
|
|
2746
|
+
// v789(issue #189):把当前的凭据放行标志也下发 —— 前端勾选框要能**显示现状**,
|
|
2747
|
+
// 否则"看不到 → 保存时显式传 false"会把用户手改文件开的口子又关掉(反而更糟)。
|
|
2748
|
+
entry.allowCustomHost = m.allowCustomHost === true
|
|
2749
|
+
entry.params = (m.params && typeof m.params === 'object') ? m.params : null
|
|
2505
2750
|
if (m.id === API_BUILTIN_ID) {
|
|
2506
2751
|
entry.balanceMode = 'api'
|
|
2507
2752
|
entry.hasBalanceApi = true
|
|
@@ -2624,7 +2869,7 @@ export default {
|
|
|
2624
2869
|
const summary = daySummary(led, todayKey())
|
|
2625
2870
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
2626
2871
|
return {
|
|
2627
|
-
...visible, version: '0.3.
|
|
2872
|
+
...visible, version: '0.3.18', isPeak: isPeakTime(nowSec),
|
|
2628
2873
|
// 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
|
|
2629
2874
|
peakNextChangeAt: nextPeakChangeAt(nowSec),
|
|
2630
2875
|
peakHolidays: HOLIDAY_VALLEY_LIST,
|
package/package.json
CHANGED