dsh-whale-widget 0.3.0 → 0.3.2

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.
@@ -293,7 +293,7 @@ var css = [
293
293
  '.dshwv-usagepanel{position:fixed;z-index:26020;background:#fff;border:1px solid rgba(32,49,112,.35);border-radius:10px;box-shadow:0 8px 22px rgba(15,23,42,.22);padding:10px 12px;color-scheme:light;max-height:70vh;overflow-y:auto}',
294
294
  // 用量作为主菜单内的子界面
295
295
  '.dshwv-menuview{display:block}',
296
- '.dshwv-usage-sub{display:none;max-height:min(70vh,560px);overflow-y:auto;padding-right:2px;width:100%;box-sizing:border-box}',
296
+ '.dshwv-usage-sub{display:none;max-height:min(70vh,560px);overflow-y:auto;padding-right:2px;padding-bottom:12px;width:100%;box-sizing:border-box}',
297
297
  '.dshwv-usage-back{border:none;background:none;color:#203170;font-size:12px;font-weight:600;cursor:pointer;padding:0 0 2px;text-align:left;width:100%}',
298
298
  '.dshwv-usage-back:hover{color:#2f4488;text-decoration:underline}',
299
299
  '.dshwv-usagebody{display:flex;flex-direction:column;gap:2px;color:#203170;font-size:12px;min-width:0;overflow-x:hidden}',
@@ -1475,7 +1475,7 @@ function hideUsageSub() {
1475
1475
  }, 230)
1476
1476
  }
1477
1477
  function closeUsagePanel() { hideUsageSub() }
1478
- function usageMoney(x) { return '¥' + (isFinite(Number(x)) ? Number(x).toFixed(2) : '0.00') }
1478
+ function usageMoney(x, currency) { return apiFmtMoney(x, currency || 'CNY') }
1479
1479
  function usageDayLabel(day) {
1480
1480
  try {
1481
1481
  var d = day.split('-')
@@ -1870,6 +1870,13 @@ function apiModelById(id) {
1870
1870
  for (var i = 0; i < apiModels.length; i++) if (apiModels[i] && apiModels[i].id === id) return apiModels[i]
1871
1871
  return null
1872
1872
  }
1873
+ // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):宿主在模型条目里下发 canAdjustBalance,
1874
+ // 前端据此决定要不要在设置菜单里给出入口 —— 手动新增的同名模型、Kimi 等其它厂商都不会有,
1875
+ // 新增模板也不会自动继承(宿主还会在路由层再校验一次,见 balance-adjustments.json)。
1876
+ function apiCanAdjustBalance(model) {
1877
+ return !!(model && model.id === 'deepseek' && model.builtin === true &&
1878
+ model.provider === 'deepseek' && model.canAdjustBalance === true)
1879
+ }
1873
1880
  // 今日已用金额自带的币种(host 下发的 todayUsageCurrency):
1874
1881
  // 会话事件金额在 host 已按自定义单价折算成人民币,余额差则是厂商币种 → 显示必须按各自的币种,
1875
1882
  // 否则 USD 单价的模型会把人民币数值渲染成 $。
@@ -1901,7 +1908,11 @@ function apiFmtMoney(v, cur) {
1901
1908
  function apiUsageSourceLabel(src) {
1902
1909
  var s = String(src || '')
1903
1910
  if (s === 'ledger' || s === 'balance') return '余额差记账'
1904
- if (s === 'events') return '会话事件'
1911
+ if (s === 'events') return '本地估算'
1912
+ if (s === 'balance-observed') return '已观测消费'
1913
+ if (s === 'balance-needs-review') return '待核对余额调整'
1914
+ if (s === 'balance-corrected') return '已校正消费'
1915
+ if (s === 'legacy') return '旧版记录 · 未校正'
1905
1916
  return ''
1906
1917
  }
1907
1918
  // 模型列表加载:并发合并 + 失败退避重试 + 失败态可重试。
@@ -2796,7 +2807,7 @@ function openApiModelMenu(modelId) {
2796
2807
  else st.textContent = '余额 ' + apiFmtMoney(m.balance, m.currency) + ' · 今日已用 ' + apiFmtMoney(m.todayUsage, apiTodayCur(m))
2797
2808
  card.appendChild(st)
2798
2809
  var ms = (usageSet && usageSet.models && usageSet.models[modelId]) || {}
2799
- function rowOf(label, stateFn, onEdit) {
2810
+ function rowOf(label, stateFn, onEdit, buttonLabel) {
2800
2811
  var r = document.createElement('div')
2801
2812
  r.className = 'dshwv-audiorow'
2802
2813
  var l = document.createElement('span')
@@ -2813,8 +2824,9 @@ function openApiModelMenu(modelId) {
2813
2824
  info.style.textOverflow = 'ellipsis'
2814
2825
  info.textContent = stateFn()
2815
2826
  r.appendChild(info)
2816
- r.appendChild(apiBtn('编辑', 'dshwv-roleimport', onEdit))
2827
+ r.appendChild(apiBtn(buttonLabel || '编辑', 'dshwv-roleimport', onEdit))
2817
2828
  card.appendChild(r)
2829
+ return r
2818
2830
  }
2819
2831
  rowOf('余额预警', function () {
2820
2832
  var a = ms.alert
@@ -2871,13 +2883,15 @@ function openApiModelMenu(modelId) {
2871
2883
  card.appendChild(pr)
2872
2884
  }
2873
2885
  // 只读行(复用现有 .dshwv-audiorow / .dshwv-usage-hint,不引入新颜色与字体)
2874
- function readonlyRow(label, text) {
2886
+ function readonlyRow(label, text, hintHtml) {
2875
2887
  var r = document.createElement('div')
2876
2888
  r.className = 'dshwv-audiorow'
2877
2889
  var l = document.createElement('span')
2878
2890
  l.textContent = label
2879
2891
  l.style.flex = '0 0 auto'
2880
2892
  r.appendChild(l)
2893
+ // 需要「?」说明时,说明收进圆圈(悬停显示、点击钉住),与挂件其它说明圈同一套组件
2894
+ if (hintHtml) { try { r.appendChild(dshwvAskDot(hintHtml)) } catch (err) {} }
2881
2895
  var v = document.createElement('span')
2882
2896
  v.className = 'dshwv-usage-hint'
2883
2897
  v.style.flex = '1'
@@ -2910,6 +2924,32 @@ function openApiModelMenu(modelId) {
2910
2924
  pTxt = '未设置(沿用内置价目表)→ 在「密钥 / 接口」里填写'
2911
2925
  }
2912
2926
  readonlyRow('单价', pTxt)
2927
+ // 「已观测消费」= DeepSeek 账户口径(余额观测),只在内置项的设置里显示,与下面的「余额校正」配套。
2928
+ if (apiCanAdjustBalance(m)) {
2929
+ var acc = m.accounting || null
2930
+ var accAmt = (acc && typeof acc.amount === 'number') ? acc.amount : m.todayUsage
2931
+ var accInfo = (acc && acc.firstObservedAt)
2932
+ ? '统计起点(北京):' + accountingTime(acc.firstObservedAt) + '。起点前的消费未计入;该账户的观测包含同一个 key 在别处的消费。'
2933
+ : '尚无余额观测:先配置 DeepSeek API key 并成功刷新一次余额。'
2934
+ readonlyRow('已观测消费', ((acc && acc.label) || m.usageLabel || '已观测消费') + ' ' +
2935
+ (isFinite(Number(accAmt)) ? apiFmtMoney(accAmt, apiTodayCur(m)) : '--'), accInfo)
2936
+ // 需要用户动手的提示仍然直接显示(不藏进「?」里)
2937
+ if (acc && acc.needsReview) {
2938
+ var accWarn = document.createElement('div')
2939
+ accWarn.className = 'dshwv-usage-hint'
2940
+ accWarn.style.cssText = 'line-height:1.65;white-space:normal;margin:2px 0 4px'
2941
+ accWarn.textContent = '检测到余额增加,请用下面的「余额校正」核对本区间累计到账。'
2942
+ card.appendChild(accWarn)
2943
+ }
2944
+ }
2945
+ // 「充值 / 余额校正」入口:只给固定的 DeepSeek(内置),放在「单价」下方、沿用同一行布局与按钮样式。
2946
+ // 其它厂商、手动新增的 DeepSeek、仅改名为「DeepSeek(内置)」的模型都不会走到这里。
2947
+ if (apiCanAdjustBalance(m)) {
2948
+ var adjustmentRow = rowOf('余额校正', function () {
2949
+ return (m.accounting && m.accounting.label) || '充值与余额调整'
2950
+ }, function () { openBalanceAdjustment(modelId) }, '校正')
2951
+ adjustmentRow.lastElementChild.setAttribute('data-action', 'balance-adjustment')
2952
+ }
2913
2953
  // 币种不一致提示:今日已用按「自带币种」显示(会话事件为 CNY)。若与模型币种不同且没填汇率,
2914
2954
  // 今日预算提醒会被跳过(见 A 方案),这里给出可见的补救提示。
2915
2955
  var tuc = String((m && m.todayUsageCurrency) || '').toUpperCase()
@@ -2977,9 +3017,9 @@ function refreshUsageMain() {
2977
3017
  // 数值由“泡泡消失→下一次显示”的渲染自然采用最新 state
2978
3018
  if (d && d.ok && d.today && isFinite(Number(d.today.total))) {
2979
3019
  var recTotal = Number(d.today.total)
2980
- if (state.todayUsage === null || recTotal >= state.todayUsage) {
2981
- state.todayUsage = recTotal
2982
- }
3020
+ state.todayUsage = recTotal
3021
+ state.todayUsageCurrency = d.today.currency || 'CNY'
3022
+ state.usageLabel = d.today.label || '本地估算'
2983
3023
  }
2984
3024
  })
2985
3025
  .catch(function () { if (usageMainEl && !usageMainEl.firstChild) usageMainEl.textContent = '记录加载失败' })
@@ -2996,6 +3036,233 @@ function uSectionTitle(leftTxt, rightTxt) {
2996
3036
  h.appendChild(r)
2997
3037
  return h
2998
3038
  }
3039
+ function accountingTime(at) {
3040
+ try {
3041
+ return new Date(at).toLocaleString('zh-CN', {
3042
+ timeZone: 'Asia/Shanghai', hour12: false, year: 'numeric', month: '2-digit',
3043
+ day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
3044
+ })
3045
+ } catch (err) { return String(at || '') }
3046
+ }
3047
+ var accountingMask = null
3048
+ function openBalanceAdjustment(modelId) {
3049
+ // 只有固定的 DeepSeek(内置)能打开:入口由宿主下发的 canAdjustBalance 控制,这里再挡一次
3050
+ if (!apiCanAdjustBalance(apiModelById(modelId)) || accountingMask) return
3051
+ var adjustmentUrl = '/dsh-whale/balance-adjustments.json?modelId=' + encodeURIComponent(modelId)
3052
+ closeApiModelPanel()
3053
+ // 与「额度」「余额预警 / 今日预算」等窗口共用同一套骨架与样式:
3054
+ // dshwv-usage-mask + dshwv-usage-card + dshwv-bubtitle/bubhint + apiPanelRow/apiTextInput + dshwv-bubbtns
3055
+ var mask = document.createElement('div')
3056
+ mask.className = 'dshwv-usage-mask'
3057
+ mask.style.zIndex = '30000'
3058
+ var card = document.createElement('form')
3059
+ card.className = 'dshwv-usage-card'
3060
+ card.style.width = 'min(430px,94vw)'
3061
+ card.style.padding = '14px 16px'
3062
+ card.style.boxSizing = 'border-box'
3063
+ card.style.textAlign = 'left'
3064
+ card.setAttribute('role', 'dialog')
3065
+ card.setAttribute('aria-modal', 'true')
3066
+ card.setAttribute('aria-label', 'DeepSeek(内置)余额校正')
3067
+ // 内容可能比 82vh 高:中间这段自己滚,按钮行固定在底部(与 .dshwv-reswrap 同一做法)
3068
+ var body = document.createElement('div')
3069
+ body.style.cssText = 'min-height:0;overflow-y:auto;flex:1 1 auto'
3070
+ var title = document.createElement('div')
3071
+ title.className = 'dshwv-bubtitle'
3072
+ title.textContent = '余额校正 · DeepSeek(内置)'
3073
+ body.appendChild(title)
3074
+ var introduction = document.createElement('div')
3075
+ introduction.className = 'dshwv-bubhint'
3076
+ introduction.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left'
3077
+ introduction.textContent = '核对统计区间内的全部到账后,重新计算本地消费。此操作不会充值,也不会改变 DeepSeek 账户余额。'
3078
+ body.appendChild(introduction)
3079
+ var dates = apiSelectEl([], '')
3080
+ body.appendChild(apiPanelRow('日期', dates))
3081
+ var interval = document.createElement('div')
3082
+ interval.className = 'dshwv-bubhint'
3083
+ interval.style.cssText = 'margin:0 0 8px;white-space:pre-line;line-height:1.6;text-align:left'
3084
+ body.appendChild(interval)
3085
+ var credits = apiTextInput('', '未到账请填 0')
3086
+ credits.inputMode = 'decimal'
3087
+ credits.required = true
3088
+ credits.autocomplete = 'off'
3089
+ body.appendChild(apiPanelRow('累计到账', credits))
3090
+ var creditsHint = document.createElement('div')
3091
+ creditsHint.className = 'dshwv-bubhint'
3092
+ creditsHint.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left'
3093
+ body.appendChild(creditsHint)
3094
+ var debits = apiTextInput('0', '没有请填 0')
3095
+ debits.inputMode = 'decimal'
3096
+ debits.autocomplete = 'off'
3097
+ body.appendChild(apiPanelRow('非调用扣减', debits))
3098
+ var debitsHint = document.createElement('div')
3099
+ debitsHint.className = 'dshwv-bubhint'
3100
+ debitsHint.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left'
3101
+ body.appendChild(debitsHint)
3102
+ var preview = document.createElement('div')
3103
+ preview.className = 'dshwv-bubhint'
3104
+ preview.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left;font-weight:700;color:#203170'
3105
+ preview.setAttribute('aria-live', 'polite')
3106
+ body.appendChild(preview)
3107
+ var confirmRow = document.createElement('label')
3108
+ confirmRow.style.cssText = 'display:flex;gap:6px;align-items:flex-start;flex:1;min-width:0;font-size:12px;color:#203170;cursor:pointer;line-height:1.5'
3109
+ var confirm = document.createElement('input')
3110
+ confirm.type = 'checkbox'
3111
+ confirm.style.marginTop = '1px'
3112
+ confirmRow.appendChild(confirm)
3113
+ confirmRow.appendChild(document.createTextNode('我已核对本统计区间的全部到账和非调用扣减'))
3114
+ body.appendChild(apiPanelRow('确认', confirmRow))
3115
+ var status = document.createElement('div')
3116
+ status.className = 'dshwv-bubhint'
3117
+ status.setAttribute('role', 'status')
3118
+ status.style.cssText = 'margin:0 0 6px;white-space:normal;line-height:1.6;text-align:left;color:#b33333'
3119
+ status.textContent = '正在刷新余额…'
3120
+ body.appendChild(status)
3121
+ card.appendChild(body)
3122
+ var busy = false
3123
+ function close() {
3124
+ if (busy) return
3125
+ document.removeEventListener('keydown', keyHandler)
3126
+ mask.remove()
3127
+ accountingMask = null
3128
+ // 关掉校正窗口后回到同一个设置菜单,并把焦点放回「校正」按钮
3129
+ openApiModelMenu(modelId)
3130
+ try {
3131
+ var returnButton = apiModelMaskEl && apiModelMaskEl.querySelector('[data-action="balance-adjustment"]')
3132
+ if (returnButton) returnButton.focus()
3133
+ } catch (err) {}
3134
+ }
3135
+ function keyHandler(e) {
3136
+ if (e.key === 'Escape') { e.preventDefault(); close() }
3137
+ if (e.key === 'Tab') {
3138
+ var focusable = Array.prototype.filter.call(card.querySelectorAll('button,input,select'), function (el) {
3139
+ return !el.disabled && el.style.display !== 'none'
3140
+ })
3141
+ var first = focusable[0], last = focusable[focusable.length - 1]
3142
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
3143
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
3144
+ }
3145
+ }
3146
+ // 按钮行与其它窗口完全一致:.dshwv-bubbtns + apiBtn 的标准按钮样式(不再自绘尺寸/配色)
3147
+ var actions = document.createElement('div')
3148
+ actions.className = 'dshwv-bubbtns'
3149
+ var reload = apiBtn('重新读取', 'dshwv-bubbtn dshwv-bubbtn-no', function () { load() })
3150
+ var reset = apiBtn('撤销该日校正', 'dshwv-bubbtn dshwv-bubbtn-no', function () { submit('reset') })
3151
+ var save = apiBtn('保存校正', 'dshwv-bubbtn dshwv-bubbtn-ok', function () { submit('save') })
3152
+ actions.appendChild(reload)
3153
+ actions.appendChild(reset)
3154
+ actions.appendChild(apiBtn('取消', 'dshwv-bubbtn dshwv-bubbtn-no', function () { close() }))
3155
+ actions.appendChild(save)
3156
+ card.appendChild(actions)
3157
+ mask.appendChild(card)
3158
+ mask.addEventListener('click', function (e) { if (e.target === mask) close() })
3159
+ document.body.appendChild(mask)
3160
+ accountingMask = mask
3161
+ document.addEventListener('keydown', keyHandler)
3162
+ var rows = []
3163
+ var selected = null
3164
+ function renderSelection() {
3165
+ selected = rows.find(function (row) { return row.day === dates.value }) || null
3166
+ save.disabled = !selected || busy
3167
+ reset.disabled = !selected || !selected.correctedAt || busy
3168
+ if (!selected) {
3169
+ interval.textContent = '尚无可校正的余额观测。请先配置 DeepSeek API key 并成功刷新余额。'
3170
+ creditsHint.textContent = ''
3171
+ debitsHint.textContent = ''
3172
+ return
3173
+ }
3174
+ interval.textContent = '统计起点:' + accountingTime(selected.firstObservedAt) +
3175
+ '\n最近观测:' + accountingTime(selected.lastObservedAt) +
3176
+ '\n起点余额 ' + usageMoney(selected.openingBalance, selected.currency) +
3177
+ ' → 当前余额 ' + usageMoney(selected.currentBalance, selected.currency) +
3178
+ '\n当前:' + selected.label + ' ' + usageMoney(selected.amount, selected.currency)
3179
+ creditsHint.textContent = '本统计区间累计到账金额(' + selected.currency + ',未到账请填 0):包括充值、赠金等;多次到账请填合计,不要只填最后一笔。'
3180
+ debitsHint.textContent = '非调用造成的余额减少(' + selected.currency + ',没有请填 0):到期赠金、余额退回等。仅填写统计起点之后的金额;保存会替换之前的校正值。'
3181
+ credits.value = selected.credits == null ? '' : String(selected.credits)
3182
+ debits.value = selected.otherDebits == null ? '0' : String(selected.otherDebits)
3183
+ confirm.checked = false
3184
+ updatePreview()
3185
+ }
3186
+ function updatePreview() {
3187
+ if (!selected || credits.value.trim() === '' || !isFinite(Number(credits.value)) || !isFinite(Number(debits.value))) {
3188
+ preview.textContent = '填写完整金额后显示校正预览'
3189
+ return
3190
+ }
3191
+ var amount = selected.openingBalance + Number(credits.value) - Number(debits.value || 0) - selected.currentBalance
3192
+ preview.textContent = amount < -0.000000005 ? '校正后为负数,请核对金额和统计区间' : '校正后消费:' + usageMoney(Math.max(0, amount), selected.currency)
3193
+ }
3194
+ credits.addEventListener('input', updatePreview)
3195
+ debits.addEventListener('input', updatePreview)
3196
+ dates.addEventListener('change', renderSelection)
3197
+ function load() {
3198
+ if (busy) return
3199
+ busy = true
3200
+ save.disabled = true
3201
+ reset.disabled = true
3202
+ reload.disabled = true
3203
+ status.textContent = '正在刷新余额…'
3204
+ fetch(adjustmentUrl, { cache: 'no-store' })
3205
+ .then(function (r) { return r.json() })
3206
+ .then(function (data) {
3207
+ if (!data.ok) throw new Error(data.error || '读取失败')
3208
+ rows = data.days || []
3209
+ dates.innerHTML = ''
3210
+ rows.forEach(function (row) {
3211
+ var option = document.createElement('option')
3212
+ option.value = row.day
3213
+ option.textContent = row.day + ' · ' + row.label
3214
+ dates.appendChild(option)
3215
+ })
3216
+ status.textContent = data.error ? '余额暂未刷新:' + data.error : ''
3217
+ })
3218
+ .catch(function (err) { status.textContent = err.message || '读取失败' })
3219
+ .finally(function () { busy = false; reload.disabled = false; renderSelection(); dates.focus() })
3220
+ }
3221
+ function submit(action) {
3222
+ if (busy || !selected) return
3223
+ if (action !== 'reset' && !confirm.checked) { status.textContent = '请先勾选确认,核对本统计区间的全部余额调整。'; return }
3224
+ busy = true
3225
+ save.disabled = true
3226
+ reset.disabled = true
3227
+ reload.disabled = true
3228
+ status.textContent = '正在保存…'
3229
+ fetch(adjustmentUrl, {
3230
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
3231
+ body: JSON.stringify({
3232
+ modelId: modelId, day: selected.day, revision: selected.revision, action: action,
3233
+ credits: credits.value.trim(), otherDebits: debits.value.trim() || '0', confirmed: confirm.checked
3234
+ })
3235
+ })
3236
+ .then(function (r) { return r.json() })
3237
+ .then(function (data) {
3238
+ if (!data.ok) throw new Error(data.error || '保存失败')
3239
+ // 保存后立刻用返回的摘要刷新该模型条目:关窗回到设置菜单时显示的就是新金额
3240
+ var model = apiModelById(modelId)
3241
+ var currentDay = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
3242
+ if (apiCanAdjustBalance(model) && data.summary && data.summary.day === currentDay) {
3243
+ model.balance = data.summary.currentBalance
3244
+ model.currency = data.summary.currency
3245
+ model.todayUsage = data.summary.amount
3246
+ model.todayUsageCurrency = data.summary.currency
3247
+ model.usageSource = data.summary.source
3248
+ model.usageLabel = data.summary.label
3249
+ model.accounting = data.summary
3250
+ model.error = null
3251
+ }
3252
+ busy = false
3253
+ close()
3254
+ refresh(true)
3255
+ loadApiModels(function () {
3256
+ if (usagePanelOpen) rebuildUsageSubShell()
3257
+ }, true)
3258
+ })
3259
+ .catch(function (err) { status.textContent = err.message || '保存失败' })
3260
+ .finally(function () { busy = false; save.disabled = !selected; reset.disabled = !selected || !selected.correctedAt; reload.disabled = false })
3261
+ }
3262
+ card.addEventListener('submit', function (e) { e.preventDefault(); submit('save') })
3263
+ load()
3264
+ }
3265
+
2999
3266
  function fillUsagePanel(d) {
3000
3267
  var hostEl = usageMainEl || usagePanel
3001
3268
  hostEl.innerHTML = ''
@@ -3009,10 +3276,14 @@ function fillUsagePanel(d) {
3009
3276
  var today = d.today || {}
3010
3277
  var todayModels = today.models || []
3011
3278
  var hasEvToday = todayModels.length > 0
3012
- // ① 今日模型消费(独立容器+滚动);标题不带下分隔线
3013
- var tToday = uSectionTitle('今日模型消费', usageMoney(today.total))
3014
- tToday.style.borderBottom = 'none'
3015
- wrap.appendChild(tToday)
3279
+ // ① 本机模型费用(所有模型的本地会话估算)
3280
+ // 「已观测消费」是 DeepSeek 账户口径(余额观测),已移到 小鲸鱼记账 → DeepSeek(内置)→ 设置 里,
3281
+ // 与「余额校正」放在一起,避免在这里被误读成"全模型合计"。
3282
+ var modelTitle = uSectionTitle('本机模型费用', usageMoney(today.modelTotal, 'CNY'))
3283
+ // 标题与下方模型列表之间不再画分隔线(列表本身有边框,够了)
3284
+ modelTitle.style.borderBottom = 'none'
3285
+ modelTitle.title = '按本机 DSH 会话计算,不按账户余额比例缩放;两者覆盖范围不同。'
3286
+ wrap.appendChild(modelTitle)
3016
3287
  var todayBox = document.createElement('div')
3017
3288
  todayBox.className = 'dshwv-usage-scroll dshwv-usage-today'
3018
3289
  if (hasEvToday) {
@@ -3030,7 +3301,7 @@ function fillUsagePanel(d) {
3030
3301
  } else if ((today.total || 0) > 0) {
3031
3302
  var noM = document.createElement('div')
3032
3303
  noM.className = 'dshwv-usage-hint'
3033
- noM.textContent = '今日总额来自余额差值,暂不含模型明细(启用会话记录后将按模型展示)'
3304
+ noM.textContent = '暂无本机会话费用明细;账户余额观测仍独立记账。'
3034
3305
  todayBox.appendChild(noM)
3035
3306
  } else {
3036
3307
  var empty = document.createElement('div')
@@ -3045,7 +3316,7 @@ function fillUsagePanel(d) {
3045
3316
  sep7.style.borderTop = '1px solid rgba(32,49,112,.15)'
3046
3317
  sep7.style.margin = '6px 0'
3047
3318
  wrap.appendChild(sep7)
3048
- var t7 = uSectionTitle('近7天使用记录', usageMoney(d.total7))
3319
+ var t7 = uSectionTitle('近7天使用记录', usageMoney(d.total7, d.total7Currency))
3049
3320
  t7.style.borderBottom = 'none'
3050
3321
  wrap.appendChild(t7)
3051
3322
  var daysBox = document.createElement('div')
@@ -3055,9 +3326,10 @@ function fillUsagePanel(d) {
3055
3326
  r.className = 'dshwv-usage-row'
3056
3327
  var n = document.createElement('span')
3057
3328
  n.textContent = usageDayLabel(row.date)
3329
+ n.title = row.label || ''
3058
3330
  r.appendChild(n)
3059
3331
  var c = document.createElement('span')
3060
- c.textContent = usageMoney(row.total)
3332
+ c.textContent = usageMoney(row.total, row.currency)
3061
3333
  r.appendChild(c)
3062
3334
  daysBox.appendChild(r)
3063
3335
  })
@@ -3856,8 +4128,8 @@ function usageDrawBarChart(body, secTitle, days, opts) {
3856
4128
  g.fillStyle = '#9fb0d9'
3857
4129
  g.font = '10px sans-serif'
3858
4130
  g.textAlign = 'right'
3859
- g.fillText(usageMoney(max), padL - 4, padT + 8)
3860
- g.fillText(usageMoney(max / 2), padL - 4, padT + ih / 2 + 3)
4131
+ g.fillText(usageMoney(max, opts.currency), padL - 4, padT + 8)
4132
+ g.fillText(usageMoney(max / 2, opts.currency), padL - 4, padT + ih / 2 + 3)
3861
4133
  g.fillText('¥0', padL - 4, padT + ih + 4)
3862
4134
  }
3863
4135
  function move(ev) {
@@ -3870,7 +4142,7 @@ function usageDrawBarChart(body, secTitle, days, opts) {
3870
4142
  paint(hover)
3871
4143
  if (hover >= 0) {
3872
4144
  tip.style.display = 'block'
3873
- tip.textContent = bars[hover].day.date + ' ' + usageMoney(bars[hover].day.total)
4145
+ tip.textContent = bars[hover].day.date + ' ' + usageMoney(bars[hover].day.total, opts.currency)
3874
4146
  var wr = wrap.getBoundingClientRect()
3875
4147
  var tx = ev.clientX - wr.left + 10
3876
4148
  if (tx + 130 > wr.width) tx = ev.clientX - wr.left - 140
@@ -4006,31 +4278,37 @@ function fillUsageRecordsWindow(d) {
4006
4278
  var allDays = ((d.all && d.all.days) || []).slice().sort(function (a, b) { return a.date < b.date ? -1 : 1 })
4007
4279
  var evAll = ((d.all && d.all.events) || []).slice()
4008
4280
  // ① 概览头
4009
- var sumAll = 0
4281
+ var sumAllByCurrency = {}
4282
+ var chartCurrency = (d.today && d.today.currency) || 'CNY'
4010
4283
  var maxDay = null
4011
4284
  for (var s1 = 0; s1 < allDays.length; s1++) {
4012
- sumAll += Number(allDays[s1].total) || 0
4013
- if (!maxDay || Number(allDays[s1].total) > Number(maxDay.total)) maxDay = allDays[s1]
4285
+ var dayCurrency = allDays[s1].currency || 'CNY'
4286
+ sumAllByCurrency[dayCurrency] = (sumAllByCurrency[dayCurrency] || 0) + (Number(allDays[s1].total) || 0)
4287
+ if (dayCurrency === chartCurrency && (!maxDay || Number(allDays[s1].total) > Number(maxDay.total))) maxDay = allDays[s1]
4014
4288
  }
4015
4289
  var ov = document.createElement('div')
4016
4290
  ov.className = 'dshwv-usage-oview'
4017
4291
  var ovL = document.createElement('div')
4018
- ovL.textContent = '全部消费'
4292
+ ovL.textContent = '全部记录合计'
4019
4293
  ov.appendChild(ovL)
4020
4294
  var ovN = document.createElement('div')
4021
4295
  ovN.className = 'dshwv-usage-oview-num'
4022
- ovN.textContent = usageMoney(sumAll)
4296
+ ovN.textContent = Object.keys(sumAllByCurrency).map(function (cur) { return usageMoney(sumAllByCurrency[cur], cur) }).join(' + ') || usageMoney(0, chartCurrency)
4023
4297
  ov.appendChild(ovN)
4024
4298
  var ovS = document.createElement('div')
4025
4299
  ovS.className = 'dshwv-usage-hint'
4026
- ovS.textContent = '共 ' + evAll.length + ' 笔明细' + (maxDay ? ' · 峰值 ' + maxDay.date + ' ' + usageMoney(maxDay.total) : '')
4300
+ ovS.textContent = '共 ' + evAll.length + ' 笔明细' + (maxDay ? ' · 峰值 ' + maxDay.date + ' ' + usageMoney(maxDay.total, maxDay.currency) : '')
4027
4301
  ov.appendChild(ovS)
4302
+ var sourceNote = document.createElement('div')
4303
+ sourceNote.className = 'dshwv-usage-hint'
4304
+ sourceNote.textContent = '按各日标注口径汇总;本机模型明细为估算,与账户消费覆盖范围不同。'
4305
+ ov.appendChild(sourceNote)
4028
4306
  body.appendChild(ov)
4029
4307
  var detailBox = null
4030
4308
  // ② 统计图表(默认折叠,懒渲染)
4031
4309
  usageCollapseBlock(body, '统计图表(近30天 / 模型占比)', false, function (inner) {
4032
- usageDrawBarChart(inner, '近30天消费(绿柱=今天,点柱定位到当日)', allDays.slice(-30), {
4033
- today: usageTodayKeyStr(),
4310
+ usageDrawBarChart(inner, '近30天消费 · ' + chartCurrency + '(点柱查看当日)', allDays.filter(function (row) { return (row.currency || 'CNY') === chartCurrency }).slice(-30), {
4311
+ today: usageTodayKeyStr(), currency: chartCurrency,
4034
4312
  onPick: function (date) {
4035
4313
  try {
4036
4314
  if (!detailBox) return
@@ -4047,9 +4325,9 @@ function fillUsageRecordsWindow(d) {
4047
4325
  },
4048
4326
  })
4049
4327
  var todayAgg = usageAggModels(d.today && d.today.models ? [{ models: d.today.models }] : [])
4050
- usageRatioRows(inner, '今日模型占比', todayAgg, usageMoney((d.today && d.today.total) || 0))
4328
+ usageRatioRows(inner, '今日模型费用 · 估算', todayAgg, usageMoney((d.today && d.today.modelTotal) || 0))
4051
4329
  var sevenAgg = usageAggModels(d.days7 || [])
4052
- usageRatioRows(inner, '近7天模型占比', sevenAgg, usageMoney((d.total7) || 0))
4330
+ usageRatioRows(inner, '近7天模型费用 · 估算', sevenAgg, usageMoney((d.days7 || []).reduce(function (sum, row) { return sum + (Number(row.modelTotal) || 0) }, 0)))
4053
4331
  })
4054
4332
  // ③ 每日与逐条明细(默认折叠;搜索/限量加载)
4055
4333
  detailBox = usageCollapseBlock(body, '每日与逐条明细', false, function (inner) {
@@ -4071,7 +4349,8 @@ function fillUsageRecordsWindow(d) {
4071
4349
  ;(evMap[day] = evMap[day] || []).push(ev)
4072
4350
  })
4073
4351
  var dayTot = {}
4074
- allDays.forEach(function (dx) { dayTot[dx.date] = Number(dx.total) || 0 })
4352
+ var dayMeta = {}
4353
+ allDays.forEach(function (dx) { dayTot[dx.date] = Number(dx.total) || 0; dayMeta[dx.date] = dx })
4075
4354
  var todayKeyStr2 = usageTodayKeyStr()
4076
4355
  function dayGroup(day, evs) {
4077
4356
  var row = document.createElement('div')
@@ -4091,7 +4370,8 @@ function fillUsageRecordsWindow(d) {
4091
4370
  var dayV = dayTot[day]
4092
4371
  if ((dayV === undefined || dayV === 0) && day === todayKeyStr2 && d.today && isFinite(Number(d.today.total))) dayV = Number(d.today.total)
4093
4372
  if (dayV === undefined || dayV === null) dayV = evs.reduce(function (a, x) { return a + (Number(x.cost) || 0) }, 0)
4094
- c.textContent = usageMoney(dayV)
4373
+ c.textContent = usageMoney(dayV, dayMeta[day] && dayMeta[day].currency)
4374
+ name.title = (dayMeta[day] && dayMeta[day].label) || '本地估算'
4095
4375
  row.appendChild(c)
4096
4376
  var chev = document.createElement('span')
4097
4377
  chev.className = 'dshwv-usage-chev'
@@ -4124,6 +4404,7 @@ function fillUsageRecordsWindow(d) {
4124
4404
  var c2 = document.createElement('span')
4125
4405
  c2.style.flex = '0 0 auto'
4126
4406
  c2.textContent = usageMoney(ev.cost)
4407
+ c2.title = '本地估算:' + Number(ev.cost || 0).toFixed(8) + ' CNY'
4127
4408
  r2.appendChild(c2)
4128
4409
  detail.appendChild(r2)
4129
4410
  }
@@ -5751,7 +6032,7 @@ function bubbleModuleSummary(m) {
5751
6032
  if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
5752
6033
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
5753
6034
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
5754
- if (m.type === 'plan') return '订阅额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
6035
+ if (m.type === 'plan') return bubblePlanModuleLabel(m)
5755
6036
  if (m.type === 'peak' || m.type === 'nextpeak') return bubblePeakModuleLabel(m)
5756
6037
  if (m.type === 'image') return '图片/动图'
5757
6038
  if (m.type === 'randimg') return '随机图片' + (m.imgs && m.imgs.length ? '(' + m.imgs.length + '张)' : '(空)')
@@ -5768,7 +6049,7 @@ function bubbleModuleListLabel(m) {
5768
6049
  if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
5769
6050
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
5770
6051
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
5771
- if (m.type === 'plan') return '订阅额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
6052
+ if (m.type === 'plan') return bubblePlanModuleLabel(m)
5772
6053
  if (m.type === 'peak' || m.type === 'nextpeak') return bubblePeakModuleLabel(m)
5773
6054
  if (m.type === 'image') return '图片/动图'
5774
6055
  if (m.type === 'randimg') return '随机图片' + (m.imgs && m.imgs.length ? '(' + m.imgs.length + '张)' : '(空)')
@@ -6779,8 +7060,8 @@ function openQuickModuleEditor(m, anchorBtn) {
6779
7060
  var isModelBal = bubbleIsModelMod(m) && (m.type === 'balance' || m.type === 'today')
6780
7061
  var isModelQuota = bubbleIsModelMod(m) && m.type === 'quota'
6781
7062
  var isModelPlan = bubbleIsModelMod(m) && m.type === 'plan'
6782
- inp.placeholder = isModelPlan ? '例: 额度已用 {plan} · {plan_reset}' : (isModelQuota ? '例: 额度 {quota} · 剩 {quota_left}' : (isModelBal ? '例: {balance} 或 今日 {today}' : (m.type === 'balance' ? '例: {balance_ds}' : (m.type === 'today' ? '例: 今日已用 {expense_ds}' : (bubbleIsPeakCount(m) ? '例: 距空闲 {countdown}' : '例: 当前 {status}')))))
6783
- inp.title = '可用占位符(英文): ' + (isModelPlan ? '{plan} / {plan_left} / {plan_reset}' : (isModelQuota ? '{quota} / {quota_used} / {quota_left} / {quota_total} / {quota_reset}' : (isModelBal ? '{balance} / {today}' : (m.type === 'peak' || m.type === 'nextpeak' ? '{status} / {countdown}' : (m.type === 'balance' ? '{balance_ds}' : '{expense_ds}')))))
7063
+ inp.placeholder = isModelPlan ? '例: {plan} 额度 · {plan_reset} 刷新时间' : (isModelQuota ? '例: 额度 {quota} · 剩 {quota_left}' : (isModelBal ? '例: {balance} 或 今日 {today}' : (m.type === 'balance' ? '例: {balance_ds}' : (m.type === 'today' ? '例: 今日已用 {expense_ds}' : (bubbleIsPeakCount(m) ? '例: 距空闲 {countdown}' : '例: 当前 {status}')))))
7064
+ inp.title = '可用占位符(英文): ' + (isModelPlan ? '{plan} 额度 / {plan_left} 剩余 / {plan_reset} 刷新时间(多窗口时随「显示样式」所选窗口变化)' : (isModelQuota ? '{quota} / {quota_used} / {quota_left} / {quota_total} / {quota_reset}' : (isModelBal ? '{balance} / {today}' : (m.type === 'peak' || m.type === 'nextpeak' ? '{status} / {countdown}' : (m.type === 'balance' ? '{balance_ds}' : '{expense_ds}')))))
6784
7065
  inp.addEventListener('input', function () { m.tpl = inp.value; changed() })
6785
7066
  r.appendChild(inp)
6786
7067
  var qb2 = document.createElement('button')
@@ -6794,6 +7075,25 @@ function openQuickModuleEditor(m, anchorBtn) {
6794
7075
  box.appendChild(r)
6795
7076
  }
6796
7077
  tplRow()
7078
+ // 订阅额度模块的「显示样式」= 选时间窗口(多窗口厂商,如 OpenCode Go 的 5h / 周 / 月)
7079
+ if (m.type === 'plan' && (apiPlanMultiWin(m.modelId) || apiPlanWinList(m.modelId))) {
7080
+ var wrow = qRow()
7081
+ wrow.appendChild(qLabel('显示样式'))
7082
+ var wsel = document.createElement('select')
7083
+ for (var wi = 0; wi < BUBBLE_PLAN_WIN_OPTS.length; wi++) {
7084
+ var wo = document.createElement('option')
7085
+ wo.value = BUBBLE_PLAN_WIN_OPTS[wi][0]
7086
+ wo.textContent = BUBBLE_PLAN_WIN_OPTS[wi][1]
7087
+ wsel.appendChild(wo)
7088
+ }
7089
+ wsel.value = bubblePlanWinOf(m)
7090
+ wsel.style.flex = '1'
7091
+ wsel.style.minWidth = '0'
7092
+ wrow.appendChild(wsel)
7093
+ box.appendChild(wrow)
7094
+ dshwCustSel(wsel)
7095
+ wsel.addEventListener('change', function () { m.planWin = wsel.value || 'all'; changed() })
7096
+ }
6797
7097
  if (m.type === 'peak' || m.type === 'nextpeak') {
6798
7098
  // 显示样式(与编辑窗口一致)
6799
7099
  var srow = qRow()
@@ -6985,6 +7285,32 @@ function renderBubblePal() {
6985
7285
  if (apiModelsLoaded) {
6986
7286
  apiModels.forEach(function (am) {
6987
7287
  if (!am || !am.id || am.builtin) return
7288
+ var planSupported = apiPlanSupport(am.id)
7289
+ // 只有「一个接口返回多个窗口」的额度厂商(如 OpenCode Go)才把调色板收成一个「额度」模块;
7290
+ // 单窗口的既有额度厂商(智谱 / Kimi / MiniMax Coding)保持上游原有的三个模块不变
7291
+ if (planSupported && apiPlanMultiWin(am.id)) {
7292
+ // 订阅额度厂商(OpenCode Go / 智谱 / Kimi / MiniMax Coding 等):这类厂商本来就没有余额接口,
7293
+ // 只给一个「额度」模块 —— 时间窗口(5h / 周 / 月 / 全部)与显示内容都在模块编辑器里选,
7294
+ // 不再并列「余额 / 手动额度 / 订阅额度」三个模块。
7295
+ var key4 = 'pq:' + am.id
7296
+ var chip4 = document.createElement('div')
7297
+ chip4.className = 'dshwv-palchip'
7298
+ chip4.setAttribute('data-pal', key4)
7299
+ chip4.textContent = '额度·' + am.name
7300
+ var planWinHint = apiPlanMultiWin(am.id) ? ';「显示样式」可选时间窗口(全部 / 5h / 周 / 月)' : ''
7301
+ chip4.title = '该厂商的订阅额度(由厂商接口读取,非手动);模板变量 {plan} 额度 / {plan_reset} 刷新时间 / {plan_left} 剩余' + planWinHint
7302
+ chip4.draggable = true
7303
+ chip4.addEventListener('click', function (e) {
7304
+ e.stopPropagation()
7305
+ bubbleModuleAdd({ type: 'plan', modelId: am.id, size: 8, tpl: '{plan} · {plan_reset}', planWin: 'all' })
7306
+ })
7307
+ chip4.addEventListener('dragstart', function (e) {
7308
+ try { e.dataTransfer.setData('text/plain', key4) } catch (err) {}
7309
+ bubbleDragKey = key4
7310
+ })
7311
+ bubblePalEl.appendChild(chip4)
7312
+ return
7313
+ }
6988
7314
  var key = 'bal:' + am.id
6989
7315
  var chip2 = document.createElement('div')
6990
7316
  chip2.className = 'dshwv-palchip'
@@ -7018,25 +7344,6 @@ function renderBubblePal() {
7018
7344
  bubbleDragKey = key3
7019
7345
  })
7020
7346
  bubblePalEl.appendChild(chip3)
7021
- // 厂商订阅额度模块(kind='quota' 的厂商,如智谱/Kimi/MiniMax Coding):palette key = pq:<modelId>
7022
- if (apiPlanSupport(am.id)) {
7023
- var key4 = 'pq:' + am.id
7024
- var chip4 = document.createElement('div')
7025
- chip4.className = 'dshwv-palchip'
7026
- chip4.setAttribute('data-pal', key4)
7027
- chip4.textContent = '订阅额度·' + am.name
7028
- chip4.title = '该厂商的订阅额度(由厂商接口读取,非手动);模板变量 {plan} / {plan_left} / {plan_reset}'
7029
- chip4.draggable = true
7030
- chip4.addEventListener('click', function (e) {
7031
- e.stopPropagation()
7032
- bubbleModuleAdd({ type: 'plan', modelId: am.id, size: 8, tpl: '额度 {plan}' })
7033
- })
7034
- chip4.addEventListener('dragstart', function (e) {
7035
- try { e.dataTransfer.setData('text/plain', key4) } catch (err) {}
7036
- bubbleDragKey = key4
7037
- })
7038
- bubblePalEl.appendChild(chip4)
7039
- }
7040
7347
  })
7041
7348
  } else if (apiModelsError) {
7042
7349
  var chipErr = document.createElement('div')
@@ -7286,11 +7593,11 @@ function bubblePaletteModule(key) {
7286
7593
  if (!am1) return null
7287
7594
  return { type: 'quota', modelId: am1.id, size: 8, tpl: '已用 {quota} · 剩 {quota_left}' }
7288
7595
  }
7289
- // 厂商订阅额度模块(palette key = pq:<modelId>)
7596
+ // 厂商订阅额度模块(palette key = pq:<modelId>):时间窗口由模块里的「显示样式」决定
7290
7597
  if (typeof key === 'string' && key.indexOf('pq:') === 0) {
7291
7598
  var am2 = apiModelById(key.slice(3))
7292
7599
  if (!am2) return null
7293
- return { type: 'plan', modelId: am2.id, size: 8, tpl: '额度 {plan}' }
7600
+ return { type: 'plan', modelId: am2.id, size: 8, tpl: '{plan} · {plan_reset}', planWin: 'all' }
7294
7601
  }
7295
7602
  if (key === 'random') return bubbleCloneModule(bubbleDefaultSecondModules()[0])
7296
7603
  if (typeof key === 'string' && key.indexOf('lib:') === 0) {
@@ -7953,7 +8260,7 @@ var bubbleColorOpenMenu = null // 当前展开的颜色下拉(纯色/跑马灯,
7953
8260
  // —— 弹层/菜单层级统一助手(所有下拉与弹窗据此取高于当前可见层,避免互相压制) ——
7954
8261
  function visibleTopZ() {
7955
8262
  var top = 20500
7956
- var cand = [bubbleMask, bubbleItemMask, moduleMask, usageMoreMask, qeditEl, window.__dshwRemindMask]
8263
+ var cand = [bubbleMask, bubbleItemMask, moduleMask, usageMoreMask, qeditEl, window.__dshwRemindMask, apiModelMaskEl, accountingMask]
7957
8264
  function eff(el) {
7958
8265
  try {
7959
8266
  if (!el) return 0
@@ -9738,6 +10045,8 @@ var state = {
9738
10045
  balance: null,
9739
10046
  currency: null,
9740
10047
  todayUsage: null,
10048
+ todayUsageCurrency: 'CNY',
10049
+ usageLabel: '本地估算',
9741
10050
  isPeak: false,
9742
10051
  status: 'loading',
9743
10052
  message: '',
@@ -10347,7 +10656,7 @@ function bubbleAmountText() {
10347
10656
  return fmt(v, state.currency)
10348
10657
  }
10349
10658
  function bubbleTodayText() {
10350
- return '今日已用 ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
10659
+ return (state.usageLabel || '今日已用') + ' ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
10351
10660
  }
10352
10661
  // 模块「内容」模板:占位符统一英文(便于兼容其他模型 API 时区分来源/字段):
10353
10662
  // {expense_ds} 今日已用金额 · {balance_ds} 余额 · {status} 高峰/空闲状态字 · {countdown} 倒计时
@@ -10520,36 +10829,160 @@ function apiPlanPctText(v) {
10520
10829
  if (v === null || v === undefined) return '--'
10521
10830
  return (Number(v) || 0).toFixed(1).replace(/\.0$/, '') + '%'
10522
10831
  }
10523
- function apiPlanUsedText(modelId) {
10832
+ // —— 多窗口订阅额度(如 OpenCode Go 的 rolling / weekly / monthly)——
10833
+ // 模块的「显示样式」= 选哪个时间窗口:all 全部三窗口 / rolling 5h / weekly 周 / monthly 月。
10834
+ // 选中具体窗口时输出会带上窗口标签(如 `5h 2%`),避免看不出是哪个时间段。
10835
+ var BUBBLE_PLAN_WIN_OPTS = [
10836
+ ['all', '全部(5h / 周 / 月)'],
10837
+ ['rolling', '5h'],
10838
+ ['weekly', '周'],
10839
+ ['monthly', '月'],
10840
+ ]
10841
+ function bubblePlanWinOf(m) {
10842
+ m = m || {}
10843
+ var w = String(m.planWin || 'all')
10844
+ for (var i = 0; i < BUBBLE_PLAN_WIN_OPTS.length; i++) { if (BUBBLE_PLAN_WIN_OPTS[i][0] === w) return w }
10845
+ return 'all'
10846
+ }
10847
+ function bubblePlanWinLabel(w) {
10848
+ for (var i = 0; i < BUBBLE_PLAN_WIN_OPTS.length; i++) { if (BUBBLE_PLAN_WIN_OPTS[i][0] === w) return BUBBLE_PLAN_WIN_OPTS[i][1] }
10849
+ return BUBBLE_PLAN_WIN_OPTS[0][1]
10850
+ }
10851
+ // 订阅额度模块在编辑列表 / 模块库里的名字(带所选窗口,便于区分同一个模型的多个模块)
10852
+ function bubblePlanModuleLabel(m) {
10853
+ m = m || {}
10854
+ var nm = (apiModelById(m.modelId) || {}).name || m.modelId
10855
+ var w = bubblePlanWinOf(m)
10856
+ return '额度·' + nm + (w === 'all' ? '' : '(' + bubblePlanWinLabel(w) + ')')
10857
+ }
10858
+ // 该模型的厂商模板是否声明了多窗口额度(决定编辑器里要不要给「显示样式」下拉)
10859
+ function apiPlanMultiWin(modelId) {
10860
+ var am = apiModelById(modelId)
10861
+ if (!am) return false
10862
+ var list = apiTemplates || []
10863
+ for (var i = 0; i < list.length; i++) {
10864
+ if (list[i] && list[i].id === am.provider) {
10865
+ var q = list[i].quota
10866
+ return !!(q && q.json && q.json.windows && q.json.windows.length)
10867
+ }
10868
+ }
10869
+ return false
10870
+ }
10871
+ function apiPlanWinList(modelId) {
10872
+ var p = apiPlanOf(modelId)
10873
+ return (p && p.ok && p.windows && p.windows.length) ? p.windows : null
10874
+ }
10875
+ // 按窗口取「已用% / 剩余%」文本;非多窗口厂商返回 null(走原有单窗口逻辑)
10876
+ function apiPlanPctWinText(modelId, win, left) {
10877
+ var list = apiPlanWinList(modelId)
10878
+ if (!list) return null
10879
+ function one(w) {
10880
+ var v = w.usedPct
10881
+ if (v === null || v === undefined) return '--'
10882
+ var pct = left ? Math.max(0, 100 - Number(v)) : Number(v)
10883
+ return (w.label ? (w.label + ' ') : '') + apiPlanPctText(pct)
10884
+ }
10885
+ if (win && win !== 'all') {
10886
+ for (var i = 0; i < list.length; i++) { if (list[i].key === win) return one(list[i]) }
10887
+ return '--'
10888
+ }
10889
+ var parts = []
10890
+ for (var j = 0; j < list.length; j++) parts.push(one(list[j]))
10891
+ return parts.join(' · ')
10892
+ }
10893
+ // 按窗口取「重置倒计时」文本;非多窗口厂商返回 null
10894
+ function apiPlanResetWinText(modelId, win) {
10895
+ var list = apiPlanWinList(modelId)
10896
+ if (!list) return null
10897
+ function one(w, short) {
10898
+ var ms = apiPlanResetMs(w.resetAt)
10899
+ if (ms === null) return '--'
10900
+ var t = short ? apiPlanCountdownShortText(ms) : apiPlanCountdownText(ms)
10901
+ return t || '--'
10902
+ }
10903
+ if (win && win !== 'all') {
10904
+ for (var i = 0; i < list.length; i++) { if (list[i].key === win) return one(list[i], true) }
10905
+ return '--'
10906
+ }
10907
+ var parts = []
10908
+ for (var j = 0; j < list.length; j++) parts.push(one(list[j], true))
10909
+ return parts.join(' · ')
10910
+ }
10911
+ function apiPlanUsedText(modelId, win) {
10912
+ var t = apiPlanPctWinText(modelId, win, false)
10913
+ if (t !== null) return t
10524
10914
  var p = apiPlanOf(modelId)
10525
10915
  if (!p || !p.ok) return '--'
10526
10916
  return apiPlanPctText(p.usedPct)
10527
10917
  }
10528
- function apiPlanLeftText(modelId) {
10918
+ function apiPlanLeftText(modelId, win) {
10919
+ var t = apiPlanPctWinText(modelId, win, true)
10920
+ if (t !== null) return t
10529
10921
  var p = apiPlanOf(modelId)
10530
10922
  if (!p || !p.ok) return '--'
10531
10923
  return apiPlanPctText(p.remainPct)
10532
10924
  }
10533
- function apiPlanResetText(modelId) {
10534
- var p = apiPlanOf(modelId)
10535
- if (!p || !p.ok || p.resetAt === null || p.resetAt === undefined || p.resetAt === '') return '--'
10536
- var t = p.resetAt
10537
- var ms = null
10538
- if (typeof t === 'number') ms = t < 1e12 ? t * 1000 : t // 秒 / 毫秒都兼容
10539
- else { var pd = Date.parse(String(t)); if (isFinite(pd)) ms = pd }
10540
- if (ms === null) return String(t)
10925
+ // 重置时间归一成毫秒(秒 / 毫秒 / 日期字符串都兼容);解析不出来返回 null
10926
+ function apiPlanResetMs(t) {
10927
+ if (t === null || t === undefined || t === '') return null
10928
+ if (typeof t === 'number') return t < 1e12 ? t * 1000 : t // 秒 / 毫秒都兼容
10929
+ var pd = Date.parse(String(t))
10930
+ return isFinite(pd) ? pd : null
10931
+ }
10932
+ // 倒计时文本(长写法,单窗口用)
10933
+ function apiPlanCountdownText(ms) {
10541
10934
  var left = ms - Date.now()
10542
- if (!isFinite(left)) return String(t)
10935
+ if (!isFinite(left)) return ''
10543
10936
  if (left <= 0) return '即将重置'
10544
10937
  var h = Math.floor(left / 3600000)
10545
10938
  var d = Math.floor(h / 24)
10546
10939
  if (d > 0) return d + '天' + (h % 24) + '小时后重置'
10547
10940
  return h + '小时' + Math.floor((left % 3600000) / 60000) + '分后重置'
10548
10941
  }
10942
+ // 倒计时文本(紧凑写法,多窗口用:5d21h / 3h53m)——单位统一用 d/h/m,不掺中文
10943
+ // 不带「后重置」字样:多窗口模块里窗口标签已说明它是什么(如 `5h 2% · 3h53m`)
10944
+ function apiPlanCountdownShortText(ms) {
10945
+ var left = ms - Date.now()
10946
+ if (!isFinite(left)) return ''
10947
+ if (left <= 0) return '即将重置'
10948
+ var m = Math.floor(left / 60000)
10949
+ var h = Math.floor(m / 60)
10950
+ var d = Math.floor(h / 24)
10951
+ if (d > 0) return d + 'd' + (h % 24) + 'h'
10952
+ if (h > 0) return h + 'h' + (m % 60) + 'm'
10953
+ return m + 'm'
10954
+ }
10955
+ function apiPlanResetText(modelId, win) {
10956
+ var t = apiPlanResetWinText(modelId, win)
10957
+ if (t !== null) return t
10958
+ var p = apiPlanOf(modelId)
10959
+ if (!p || !p.ok || p.resetAt === null || p.resetAt === undefined || p.resetAt === '') return '--'
10960
+ var ms = apiPlanResetMs(p.resetAt)
10961
+ if (ms === null) return String(p.resetAt)
10962
+ var txt = apiPlanCountdownText(ms)
10963
+ return txt || String(p.resetAt)
10964
+ }
10549
10965
  function apiPlanSummary(modelId) {
10550
10966
  var p = apiPlanOf(modelId)
10551
10967
  if (!p) return '读取中…'
10552
10968
  if (!p.ok) return p.hide ? '--' : (p.error || '读取失败')
10969
+ // v0.3.1:多窗口额度(如 OpenCode Go 的 5h / 周 / 月)逐窗口展示:`5h 12.5% · 2h55m后重置 | 周 …`
10970
+ if (p.windows && p.windows.length) {
10971
+ var parts = []
10972
+ for (var i = 0; i < p.windows.length; i++) {
10973
+ var w = p.windows[i] || {}
10974
+ var seg = w.label ? (w.label + ' ') : ''
10975
+ seg += apiPlanPctText(w.usedPct)
10976
+ var ms = apiPlanResetMs(w.resetAt)
10977
+ if (ms !== null) {
10978
+ var rt = apiPlanCountdownShortText(ms)
10979
+ if (rt) seg += ' · ' + rt
10980
+ }
10981
+ parts.push(seg)
10982
+ }
10983
+ if (p.level) parts.push(p.level)
10984
+ return parts.join(' | ')
10985
+ }
10553
10986
  var s = '已用 ' + apiPlanUsedText(modelId) + ' · ' + apiPlanResetText(modelId)
10554
10987
  if (p.weeklyUsedPct !== null && p.weeklyUsedPct !== undefined) s += ' · 周 ' + apiPlanPctText(p.weeklyUsedPct)
10555
10988
  if (p.level) s += ' · ' + p.level
@@ -10573,18 +11006,19 @@ function bubbleContentTokenMap(m) {
10573
11006
  map['quota_left'] = apiQuotaLeftText(m.modelId)
10574
11007
  map['quota_total'] = apiQuotaTotalText(m.modelId)
10575
11008
  map['quota_reset'] = apiQuotaResetText(m.modelId)
10576
- // 厂商订阅额度占位符(kind='quota' 的厂商)
10577
- map['plan'] = apiPlanUsedText(m.modelId)
11009
+ // 厂商订阅额度占位符(kind='quota' 的厂商):按模块「显示样式」选的窗口取数
11010
+ var pwin = bubblePlanWinOf(m)
11011
+ map['plan'] = apiPlanUsedText(m.modelId, pwin)
10578
11012
  map['plan_used'] = map['plan']
10579
- map['plan_left'] = apiPlanLeftText(m.modelId)
10580
- map['plan_reset'] = apiPlanResetText(m.modelId)
11013
+ map['plan_left'] = apiPlanLeftText(m.modelId, pwin)
11014
+ map['plan_reset'] = apiPlanResetText(m.modelId, pwin)
10581
11015
  return map
10582
11016
  }
10583
11017
  if (m.type === 'balance') {
10584
11018
  v = bubbleAmountText()
10585
11019
  map['balance_ds'] = v
10586
11020
  } else if (m.type === 'today') {
10587
- v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
11021
+ v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
10588
11022
  map['expense_ds'] = v
10589
11023
  } else if (bubbleIsPeakCount(m)) {
10590
11024
  v = bubbleCountdownText()
@@ -10612,9 +11046,9 @@ function bubbleTplHelpItems(m) {
10612
11046
  add('quota_total', '额度总量')
10613
11047
  add('quota_reset', '额度重置倒计时')
10614
11048
  }
10615
- add('plan', '订阅额度已用百分比')
10616
- add('plan_left', '订阅额度剩余百分比')
10617
- add('plan_reset', '订阅额度重置倒计时')
11049
+ add('plan', '订阅额度已用百分比(多窗口厂商按「显示样式」所选窗口,带窗口标签)')
11050
+ add('plan_left', '订阅额度剩余百分比(同上)')
11051
+ add('plan_reset', '订阅额度刷新倒计时(同上;全部窗口时为紧凑倒计时)')
10618
11052
  return arr
10619
11053
  }
10620
11054
  if (m.type === 'balance') add('balance_ds', '余额数值')
@@ -10887,7 +11321,19 @@ function bubbleCountdownTick() {
10887
11321
  bubbleCountdownApplyStyle(x.el, x.mod, cur)
10888
11322
  } catch (err) {}
10889
11323
  }
10890
- if (!bubbleCountdownRows.length && bubbleCountdownTicker) {
11324
+ // v733:非倒计时的峰谷行共用这个 ticker(只在状态真的变了时才动 DOM,不打断跑马灯)
11325
+ bubblePeakRows = bubblePeakRows.filter(function (y) { return y && y.row && y.row.isConnected })
11326
+ if (bubblePeakRows.length) {
11327
+ var peakNow = bubbleIsPeakNow()
11328
+ for (var k = 0; k < bubblePeakRows.length; k++) {
11329
+ var y2 = bubblePeakRows[k]
11330
+ if (y2.peak !== peakNow) {
11331
+ y2.peak = peakNow
11332
+ try { bubblePeakRowApply(y2, peakNow) } catch (err) {}
11333
+ }
11334
+ }
11335
+ }
11336
+ if (!bubbleCountdownRows.length && !bubblePeakRows.length && bubbleCountdownTicker) {
10891
11337
  clearInterval(bubbleCountdownTicker)
10892
11338
  bubbleCountdownTicker = null
10893
11339
  }
@@ -10897,6 +11343,78 @@ function bubbleCountdownRegister(el, mod) {
10897
11343
  bubbleCountdownRows.push({ el: el, mod: mod })
10898
11344
  if (!bubbleCountdownTicker) bubbleCountdownTicker = setInterval(bubbleCountdownTick, 1000)
10899
11345
  }
11346
+ // —— v733:峰谷状态实时跟随 ——
11347
+ // count / nextpeak 样式由倒计时引擎逐秒刷新;其余峰谷样式(默认「高峰时段 / 空闲时段」、
11348
+ // 梁文峰谷、!?峰峰?!、简洁峰/谷)原来只在渲染那一刻取一次状态 —— 泡泡显示期间跨过峰谷切换点,
11349
+ // 文字与配色会一直停在旧状态。这里把这类行登记进同一个 1s ticker,状态变化时**原地**改写
11350
+ // 文字 / 配色 / 底色(不做整泡重绘,保持「泡泡显示期间内容稳定」的既有设计)。
11351
+ var bubblePeakRows = []
11352
+ // 文字是直接放在行上还是包一层内层 span,取决于该状态有没有底色 ——
11353
+ // 只有「高峰 / 空闲两态底色有无一致」时,DOM 结构才不随状态变化,才能原地改写
11354
+ function bubblePeakRowStable(m) {
11355
+ return !!(m.peakBg || m.peakBgRgb) === !!(m.offBg || m.offBgRgb)
11356
+ }
11357
+ function bubblePeakRowRegister(row, tx, mod, peakNow) {
11358
+ try {
11359
+ if (!mod || mod.type !== 'peak' || bubbleIsPeakCount(mod)) return
11360
+ if (!bubblePeakRowStable(mod)) return
11361
+ bubblePeakRows.push({
11362
+ row: row, tx: tx, mod: mod, peak: !!peakNow,
11363
+ gradEl: (mod.peakBg || mod.peakBgRgb) ? tx : row,
11364
+ })
11365
+ if (!bubbleCountdownTicker) bubbleCountdownTicker = setInterval(bubbleCountdownTick, 1000)
11366
+ } catch (err) {}
11367
+ }
11368
+ // 跑马灯配色方案白名单(与 blockOf() / bubbleCountdownApplyStyle 用的那套一致)
11369
+ function bubbleRgbSchemeOk(s) {
11370
+ return s === 'candy' || s === 'rouge' || s === 'bamboo' || s === 'aurora' || s === 'deepsea' ||
11371
+ s === 'sunset' || s === 'forest' || s === 'champagne' || s === 'lavender' || s === 'mint' ||
11372
+ s === 'lava' || s === 'galaxy' || s === 'ink' || s === 'indigo'
11373
+ }
11374
+ function bubblePeakRowClearClass(el, prefix) {
11375
+ try {
11376
+ var cls = Array.prototype.slice.call(el.classList || [])
11377
+ for (var i = 0; i < cls.length; i++) {
11378
+ if (String(cls[i]).indexOf(prefix) === 0) { try { el.classList.remove(cls[i]) } catch (err) {} }
11379
+ }
11380
+ } catch (err) {}
11381
+ }
11382
+ // 原地改写一行峰谷:顺序与 blockOf() 的峰谷分支一致(底色 → 文字跑马灯 → 纯色)
11383
+ function bubblePeakRowApply(x, peak) {
11384
+ var m = x.mod
11385
+ try { x.tx.textContent = bubbleContentText(m, bubblePeakText(m)) } catch (err) {}
11386
+ // ① 底色(高峰底色 / 空闲底色,跑马灯优先于纯色)
11387
+ try {
11388
+ var effBgRgb = peak ? String(m.peakBgRgb || '') : String(m.offBgRgb || '')
11389
+ var effBg = effBgRgb ? '' : (peak ? String(m.peakBg || '') : String(m.offBg || ''))
11390
+ if (effBgRgb === 'true') effBgRgb = 'macaron'
11391
+ bubblePeakRowClearClass(x.row, 'dshwv-bgrgb')
11392
+ x.row.style.background = ''
11393
+ if (effBgRgb) {
11394
+ x.row.classList.add('dshwv-bgrgb')
11395
+ if (bubbleRgbSchemeOk(effBgRgb) || effBgRgb === 'macaron') x.row.classList.add('dshwv-bgrgb-' + effBgRgb)
11396
+ x.row.style.animationDuration = bubbleMarqueeDur()
11397
+ } else if (effBg) {
11398
+ x.row.style.background = effBg
11399
+ }
11400
+ } catch (err) {}
11401
+ // ② 文字:本状态跑马灯 > 模块跑马灯 > 本状态纯色 > 模块纯色
11402
+ try {
11403
+ var marquee = (peak ? String(m.peakRgb || '') : String(m.offRgb || '')) || String(m.rgb || '')
11404
+ bubblePeakRowClearClass(x.gradEl, 'dshwv-rgb')
11405
+ x.row.style.color = ''
11406
+ if (marquee) {
11407
+ var scheme = marquee === true ? 'macaron' : String(marquee || 'macaron')
11408
+ x.gradEl.classList.add('dshwv-rgb')
11409
+ if (bubbleRgbSchemeOk(scheme)) x.gradEl.classList.add('dshwv-rgb-' + scheme)
11410
+ x.gradEl.style.animationDuration = bubbleMarqueeDur()
11411
+ } else {
11412
+ var pcol = peak ? String(m.peakColor || '') : String(m.offColor || '')
11413
+ if (pcol) x.row.style.color = pcol
11414
+ else if (m.color) x.row.style.color = String(m.color)
11415
+ }
11416
+ } catch (err) {}
11417
+ }
10900
11418
  // v209: 计算单个模块要显示的行文本(与随机选中行),每次全新计算、不跨行复用状态
10901
11419
  function bubbleRowContentOf(mod) {
10902
11420
  mod = mod || {}
@@ -10906,7 +11424,7 @@ function bubbleRowContentOf(mod) {
10906
11424
  return { txt: bubbleContentText(mod, bv), line: null }
10907
11425
  }
10908
11426
  if (mod.type === 'today') {
10909
- var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
11427
+ var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
10910
11428
  return { txt: bubbleContentText(mod, '今日已用 ' + tv2), line: null }
10911
11429
  }
10912
11430
  if (mod.type === 'quota') {
@@ -11049,7 +11567,7 @@ function bubbleRowsTo(parentEl, mods) {
11049
11567
  row.style.background = effBg
11050
11568
  }
11051
11569
  }
11052
- return { el: row, tx: tx, fSize: fSize, mod: m, peak: (m.type === 'peak' || m.type === 'nextpeak'), bg: needBg }
11570
+ return { el: row, tx: tx, fSize: fSize, mod: m, peak: (m.type === 'peak' || m.type === 'nextpeak'), bg: needBg, curPeak: curPeak }
11053
11571
  }
11054
11572
  // 超宽判定(与旧版一致):先单行渲染,测量实际超出泡泡内宽(560u)才允许该块内折行——
11055
11573
  // 预览与真实共用此逻辑,保证两者一致
@@ -11072,6 +11590,8 @@ function bubbleRowsTo(parentEl, mods) {
11072
11590
  // 倒计时块:注册文字节点(带底色时为内层 span),由每秒 ticker 刷新文案与配色
11073
11591
  function registerIfCountdown(blk) {
11074
11592
  if (bubbleIsPeakCount(blk.mod)) bubbleCountdownRegister(blk.tx, blk.mod)
11593
+ // v733:非 count 的峰谷行登记到同一个 ticker,状态切换时原地刷新(原来只在渲染时取一次)
11594
+ else if (blk.mod && blk.mod.type === 'peak') bubblePeakRowRegister(blk.el, blk.tx, blk.mod, blk.curPeak)
11075
11595
  }
11076
11596
  // 超链接模块:仅真实泡泡(textBox)内可点击,新标签页打开;预览/编辑不弹窗
11077
11597
  function enableLinkRun(blk2) {
@@ -11479,7 +11999,7 @@ function render() {
11479
11999
  hint = '加载中…'
11480
12000
  } else {
11481
12001
  amount = shown !== null ? fmt(shown, state.currency) : fmt(state.balance, state.currency)
11482
- hint = '今日已用 ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
12002
+ hint = (state.usageLabel || '今日已用') + ' ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
11483
12003
  }
11484
12004
  amountEl.textContent = amount
11485
12005
  if (bubbleRandomActive && bubbleRandomLines) {
@@ -11508,18 +12028,43 @@ function settle() {
11508
12028
  express()
11509
12029
  return
11510
12030
  }
12031
+ // issue #102:锚点分支过去只有下限(左/顶锚甚至完全不夹),脏锚点或尺寸竞态会把挂件算到
12032
+ // 视口外 —— 症状是「启动闪一下 → 向下滑出/镜像 → 消失」,而且 saveConfig 会把负的离边距离
12033
+ // 写回 localStorage,于是刷新也恢复不了。四个锚点分支统一夹到可视区。
12034
+ var maxLFree = Math.max(0, vp.w - w - rightGap())
12035
+ var maxLAnchor = Math.max(0, vp.w - w)
12036
+ var maxT = Math.max(0, vp.h - h)
12037
+ var outOfRange = false
11511
12038
  if (state.h === 'right') {
11512
- state.left = Math.max(0, vp.w - w - state.hOff - rightGap())
12039
+ var rawR = vp.w - w - state.hOff - rightGap()
12040
+ state.left = clamp(rawR, 0, maxLAnchor)
12041
+ if (state.left !== rawR) outOfRange = true
11513
12042
  } else if (state.h === 'left') {
11514
- state.left = state.hOff
12043
+ var rawL = state.hOff
12044
+ state.left = clamp(rawL, 0, maxLAnchor)
12045
+ if (state.left !== rawL) outOfRange = true
11515
12046
  } else {
11516
- state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
11517
- } if (state.v === 'bottom') {
11518
- state.top = Math.max(0, vp.h - h - state.vOff)
12047
+ state.left = clamp(state.left, 0, maxLFree)
12048
+ }
12049
+ if (state.v === 'bottom') {
12050
+ var rawB = vp.h - h - state.vOff
12051
+ state.top = clamp(rawB, 0, maxT)
12052
+ if (state.top !== rawB) outOfRange = true
11519
12053
  } else if (state.v === 'top') {
11520
- state.top = state.vOff
12054
+ var rawT = state.vOff
12055
+ state.top = clamp(rawT, 0, maxT)
12056
+ if (state.top !== rawT) outOfRange = true
11521
12057
  } else {
11522
- state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
12058
+ state.top = clamp(state.top, 0, maxT)
12059
+ }
12060
+ // 偏移确实越界(脏数据 / 视口变小):先把偏移夹回合法范围再落盘,下次启动不再复现。
12061
+ // 只在真的越界时写,正常 resize 不会产生额外的 localStorage 写入。
12062
+ if (outOfRange) {
12063
+ if (state.h === 'right') state.hOff = clamp(state.hOff, 0, Math.max(0, vp.w - w - rightGap()))
12064
+ else if (state.h === 'left') state.hOff = clamp(state.hOff, 0, maxLAnchor)
12065
+ if (state.v === 'bottom') state.vOff = clamp(state.vOff, 0, maxT)
12066
+ else if (state.v === 'top') state.vOff = clamp(state.vOff, 0, maxT)
12067
+ try { saveAnchorPos() } catch (err) {}
11523
12068
  }
11524
12069
  refreshFlip()
11525
12070
  }
@@ -11602,7 +12147,7 @@ function refresh(manual) {
11602
12147
  ctrl = new AbortController()
11603
12148
  timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
11604
12149
  } catch (err) {}
11605
- fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
12150
+ fetch(BALANCE_URL + (manual ? '?refresh=1' : ''), { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
11606
12151
  .then(function (r) { return r.json() })
11607
12152
  .then(function (data) {
11608
12153
  if (data && data.ok) {
@@ -11614,6 +12159,9 @@ function refresh(manual) {
11614
12159
  state.currency = nc
11615
12160
  state.message = ''
11616
12161
  state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
12162
+ state.todayUsageCurrency = data.todayUsageCurrency || data.currency || 'CNY'
12163
+ state.usageLabel = data.usageLabel || '本地估算'
12164
+ if (data.stale) state.usageLabel += ' · 余额未刷新'
11617
12165
  state.isPeak = !!data.isPeak
11618
12166
  checkUsageAlerts(nb, state.todayUsage)
11619
12167
  if (changed && !currencyChanged) {
@@ -11669,27 +12217,90 @@ var costBubbleActive = false
11669
12217
  var scrollGapOn = false
11670
12218
  var scrollGapPx = 17
11671
12219
  var menuBtnHide = false // 主菜单开关:隐藏挂件菜单按钮,改为右键小鲸鱼唤出菜单
12220
+ // —— v734(issue #97 / #88):设置保存的「防覆盖 + 失败可见」——
12221
+ // #97 根因:首次 GET 还没落地就 PUT,会把内存里的默认值整包写进服务端(重启后设置被洗成默认值)。
12222
+ // #88 根因:这个 PUT 以前是 fire-and-forget,服务端 500 / {ok:false} 完全没人读。
12223
+ var configLoaded = false // 首次 GET 应用完成前,一律不 PUT
12224
+ var configSavePending = false // 加载期间被挡下的保存,加载完成后补一次
12225
+ var dshwvToastEl = null
12226
+ var dshwvToastTimer = null
12227
+ // 固定定位的小提示条(自动消失;同一时刻只留一条)
12228
+ function dshwvToast(msg) {
12229
+ try {
12230
+ if (!dshwvToastEl || !dshwvToastEl.parentNode) {
12231
+ var el = document.createElement('div')
12232
+ el.style.cssText = 'position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:2147483600;' +
12233
+ 'max-width:min(560px,calc(100vw - 32px));box-sizing:border-box;padding:10px 14px;border-radius:10px;' +
12234
+ 'background:#8a1f1f;color:#fff;font-size:13px;line-height:1.6;box-shadow:0 6px 20px rgba(0,0,0,.28);' +
12235
+ 'pointer-events:none;text-align:center'
12236
+ document.body.appendChild(el)
12237
+ dshwvToastEl = el
12238
+ }
12239
+ dshwvToastEl.innerHTML = msg
12240
+ if (dshwvToastTimer) clearTimeout(dshwvToastTimer)
12241
+ dshwvToastTimer = setTimeout(function () {
12242
+ try { if (dshwvToastEl && dshwvToastEl.parentNode) dshwvToastEl.parentNode.removeChild(dshwvToastEl) } catch (err) {}
12243
+ dshwvToastEl = null
12244
+ }, 8000)
12245
+ } catch (err) {}
12246
+ }
12247
+ function configSaveFailNotice(detail) {
12248
+ try { console.error('[dsh-whale] 设置保存失败:', detail) } catch (err) {}
12249
+ dshwvToast('⚠ 设置保存失败:' + String(detail || '').slice(0, 120) +
12250
+ '<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
12251
+ }
12252
+ function configPayload() {
12253
+ return JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx, menuBtnHide: menuBtnHide })
12254
+ }
12255
+ // 真正的 PUT:读响应 → 失败(网络异常 / HTTP!=200 / {ok:false})静默重试一次 → 仍失败才提示
12256
+ function configPut(payload, retried) {
12257
+ return fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload })
12258
+ .then(function (r) {
12259
+ return r.json().catch(function () { return null }).then(function (d) { return { ok: r.ok, status: r.status, d: d } })
12260
+ })
12261
+ .then(function (x) {
12262
+ if (x.ok && (!x.d || x.d.ok !== false)) return true
12263
+ if (!retried) return new Promise(function (res) { setTimeout(function () { res(configPut(payload, true)) }, 900) })
12264
+ configSaveFailNotice((x.d && x.d.error) || ('HTTP ' + x.status))
12265
+ return false
12266
+ })
12267
+ .catch(function (err) {
12268
+ if (!retried) return new Promise(function (res) { setTimeout(function () { res(configPut(payload, true)) }, 900) })
12269
+ configSaveFailNotice((err && err.message) || err)
12270
+ return false
12271
+ })
12272
+ }
11672
12273
  function saveConfig() {
12274
+ // issue #97:加载完成前只记待办,绝不 PUT(否则把默认值整包写进服务端)
12275
+ if (!configLoaded) { configSavePending = true; return }
11673
12276
  try {
11674
- fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx, menuBtnHide: menuBtnHide }) })
12277
+ configPut(configPayload(), false)
11675
12278
  // 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
11676
- // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
12279
+ saveAnchorPos()
12280
+ } catch (err) {}
12281
+ }
12282
+ // 单独抽出:只写 localStorage 锚点(不碰尺寸设置);applyAnchorPos / settle 自愈时也要用。
12283
+ // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
12284
+ function saveAnchorPos() {
12285
+ try {
11677
12286
  var vp = viewport()
11678
12287
  var w = root.offsetWidth || root.getBoundingClientRect().width || 0
11679
12288
  var h = root.offsetHeight || root.getBoundingClientRect().height || 0
11680
- var leftDist = state.left
11681
- var rightDist = vp.w - state.left - w
11682
- var topDist = state.top
11683
- var bottomDist = vp.h - state.top - h
12289
+ var leftDist = isFinite(state.left) ? state.left : 0
12290
+ var rightDist = vp.w - leftDist - w
12291
+ var topDist = isFinite(state.top) ? state.top : 0
12292
+ var bottomDist = vp.h - topDist - h
11684
12293
  var hAnchor = leftDist <= rightDist ? 'left' : 'right'
11685
- var hDistRaw = Math.round(Math.min(leftDist, rightDist))
12294
+ // issue #102:离边距离必须非负。挂件一旦被算到屏幕外,min(leftDist, rightDist) 就是负数,
12295
+ // 存进去会变成"永久坏锚点",此后每次启动都复现(刷新也恢复不了)。
12296
+ var hDistRaw = Math.max(0, Math.round(Math.min(leftDist, rightDist)))
11686
12297
  var hDist = hAnchor === 'right' && scrollGapOn ? Math.max(0, hDistRaw - rightGap()) : hDistRaw
11687
12298
  localStorage.setItem('dshw-pos', JSON.stringify({
11688
12299
  v: 2,
11689
12300
  hAnchor: hAnchor,
11690
12301
  hDist: hDist,
11691
12302
  vAnchor: topDist <= bottomDist ? 'top' : 'bottom',
11692
- vDist: Math.round(Math.min(topDist, bottomDist))
12303
+ vDist: Math.max(0, Math.round(Math.min(topDist, bottomDist)))
11693
12304
  }))
11694
12305
  } catch (err) {}
11695
12306
  }
@@ -11818,6 +12429,11 @@ var releasePlayed = false
11818
12429
  var releaseTimer = null
11819
12430
  function applySoundSet() {
11820
12431
  try {
12432
+ // v729:切音效组 / 开关音效时把本轮播放状态一并复位,
12433
+ // 避免残留 releasePlayed=true 把新组的松开音整体吃掉(与 playPress 的修复配套)
12434
+ if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12435
+ pressEnded = false
12436
+ releasePlayed = false
11821
12437
  // 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
11822
12438
  var pEmpty = audioGroupSlotEmpty(soundSet, 'press')
11823
12439
  var rEmpty = audioGroupSlotEmpty(soundSet, 'release')
@@ -11835,19 +12451,23 @@ function applySoundSet() {
11835
12451
  }
11836
12452
  function playPress() {
11837
12453
  if (!soundOn) return
12454
+ // v729 修复:**本轮状态复位必须放在「按压槽留空」分支之前**。
12455
+ // 原实现里 pressAudio 为空时直接 return,跳过了 releasePlayed = false;而 playRelease()
12456
+ // 一旦把 releasePlayed 置为 true 就再没有任何地方复位它 → 结果是只有第一次松开有声音,
12457
+ // 之后每次点击都静音(用户实测:新建音效组只填松开音时复现)。
12458
+ if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12459
+ if (releaseAudio) {
12460
+ releaseAudio.pause()
12461
+ releaseAudio.currentTime = 0
12462
+ }
12463
+ pressEnded = false
12464
+ releasePlayed = false
11838
12465
  if (!pressAudio) {
11839
- // 按压槽留空:按压事件静音,但“按压已结束”标记保持同步,松开时若松开槽有声仍会响
12466
+ // 按压槽留空:按压事件静音,但状态已复位 → 每次松开都还能正常发声
11840
12467
  pressEnded = true
11841
12468
  return
11842
12469
  }
11843
12470
  try {
11844
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
11845
- if (releaseAudio) {
11846
- releaseAudio.pause()
11847
- releaseAudio.currentTime = 0
11848
- }
11849
- pressEnded = false
11850
- releasePlayed = false
11851
12471
  pressAudio.onended = function () {
11852
12472
  pressEnded = true
11853
12473
  // fallback (duration unknown): click → Ya2 right after Ya1 ends
@@ -13838,19 +14458,27 @@ function applyAnchorPos() {
13838
14458
  var vp = viewport()
13839
14459
  var w = root.offsetWidth || root.getBoundingClientRect().width || 0
13840
14460
  var h = root.offsetHeight || root.getBoundingClientRect().height || 0
14461
+ var maxOffH = Math.max(0, vp.w - w)
14462
+ var maxOffV = Math.max(0, vp.h - h)
14463
+ // issue #102 自愈:非法距离(负数 = 存进去时挂件已在屏幕外;超出视口 = 窗口变小/脏数据)
14464
+ // 一律夹回合法范围并把修正结果落盘 —— 老用户中了脏数据也能自己恢复,无需手清 localStorage。
14465
+ var hDist = isFinite(a.hDist) ? clamp(a.hDist, 0, maxOffH) : 0
14466
+ var vDist = isFinite(a.vDist) ? clamp(a.vDist, 0, maxOffV) : 0
14467
+ var healed = (hDist !== a.hDist || vDist !== a.vDist)
13841
14468
  // 与加载恢复一致:锚点存净距离,右锚点按当前避让开关叠加
13842
- var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
13843
- var l = a.hAnchor === 'left' ? a.hDist : vp.w - effectiveRightDist - w
13844
- var t = a.vAnchor === 'top' ? a.vDist : vp.h - a.vDist - h
13845
- state.left = clamp(l, 0, Math.max(0, vp.w - w))
13846
- state.top = clamp(t, 0, Math.max(0, vp.h - h))
14469
+ var effectiveRightDist = a.hAnchor === 'right' ? hDist + (scrollGapOn ? rightGap() : 0) : hDist
14470
+ var l = a.hAnchor === 'left' ? hDist : vp.w - effectiveRightDist - w
14471
+ var t = a.vAnchor === 'top' ? vDist : vp.h - vDist - h
14472
+ state.left = clamp(l, 0, maxOffH)
14473
+ state.top = clamp(t, 0, maxOffV)
13847
14474
  state.h = a.hAnchor
13848
14475
  // 净距离直接还给 hOff/vOff:settle() 对锚定状态从偏移量重算,
13849
14476
  // 若置 0 会把刚恢复的距离覆盖成贴边(issue #43)
13850
- state.hOff = a.hDist
14477
+ state.hOff = hDist
13851
14478
  state.v = a.vAnchor
13852
- state.vOff = a.vDist
14479
+ state.vOff = vDist
13853
14480
  refreshFlip()
14481
+ if (healed) { try { saveAnchorPos() } catch (err) {} }
13854
14482
  return true
13855
14483
  } catch (err) { return false }
13856
14484
  }
@@ -13949,33 +14577,25 @@ fetch(SIZE_URL, { cache: 'no-store' })
13949
14577
  }
13950
14578
  // 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
13951
14579
  // 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
13952
- // 恢复时还原吸附状态(hAnchor/vAnchor state.h/v),避免挂件变自由位置
13953
- // 导致避让调节不实时(settle 自由分支只 clamp 不重算位置)。
14580
+ // issue #102:这里原与 applyAnchorPos() 各写了一份恢复逻辑(两份都只做下限夹紧),
14581
+ // 现在统一走 applyAnchorPos(),避免"只修一处、另一处仍复现"。
13954
14582
  try {
13955
- var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
13956
- if (a && a.v === 2 && (a.hAnchor === 'left' || a.hAnchor === 'right') && typeof a.hDist === 'number' &&
13957
- (a.vAnchor === 'top' || a.vAnchor === 'bottom') && typeof a.vDist === 'number') {
13958
- var vpA = viewport()
13959
- var wA = root.offsetWidth || root.getBoundingClientRect().width || 0
13960
- var hA = root.offsetHeight || root.getBoundingClientRect().height || 0
13961
- // 锚点存的是净距离:右锚点按当前避让开关叠加避让距离
13962
- var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
13963
- var lA = a.hAnchor === 'left' ? a.hDist : vpA.w - effectiveRightDist - wA
13964
- var tA = a.vAnchor === 'top' ? a.vDist : vpA.h - a.vDist - hA
13965
- state.left = clamp(lA, 0, Math.max(0, vpA.w - wA))
13966
- state.top = clamp(tA, 0, Math.max(0, vpA.h - hA))
13967
- // 按锚点还原吸附状态(贴边锚点 → 吸附;自由位锚点 → 自由)。
13968
- // 净距离还给 hOff/vOff,否则 settle() 按偏移量重算成贴边(issue #43)
13969
- state.h = a.hAnchor
13970
- state.hOff = a.hDist
13971
- state.v = a.vAnchor
13972
- state.vOff = a.vDist
14583
+ if (applyAnchorPos()) {
13973
14584
  settle()
14585
+ // 恢复时 offsetWidth 可能还是 0(字体/图片尚未就绪),尺寸就绪后再夹一次(issue #102)
14586
+ setTimeout(function () { try { settle() } catch (err) {} }, 500)
13974
14587
  }
13975
14588
  } catch (err) {}
13976
14589
  refresh(false)
14590
+ // v734(issue #97):首次 GET 应用完成 —— 从这一刻起才允许 saveConfig() 落盘
14591
+ configLoaded = true
14592
+ if (configSavePending) { configSavePending = false; try { saveConfig() } catch (err) {} }
14593
+ })
14594
+ .catch(function () {
14595
+ // 读取失败:绝不能拿内存里的默认值去 PUT(那正是「设置被洗成默认值」)
14596
+ refresh(false)
14597
+ dshwvToast('⚠ 设置读取失败,已暂停保存以免覆盖你的原有设置<br>请刷新页面重试')
13977
14598
  })
13978
- .catch(function () { refresh(false) })
13979
14599
  setInterval(function () { refresh(false); try { loadApiModels(null, true) } catch (err) {} }, REFRESH_MS)
13980
14600
 
13981
14601
  // —— 每轮对话消耗检测:轮询 last-turn.json,出现新 seq 时弹消耗金额泡泡 ——