dsh-whale-widget 0.3.0 → 0.3.1

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.
@@ -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('-')
@@ -1901,7 +1901,11 @@ function apiFmtMoney(v, cur) {
1901
1901
  function apiUsageSourceLabel(src) {
1902
1902
  var s = String(src || '')
1903
1903
  if (s === 'ledger' || s === 'balance') return '余额差记账'
1904
- if (s === 'events') return '会话事件'
1904
+ if (s === 'events') return '本地估算'
1905
+ if (s === 'balance-observed') return '已观测消费'
1906
+ if (s === 'balance-needs-review') return '待核对余额调整'
1907
+ if (s === 'balance-corrected') return '已校正消费'
1908
+ if (s === 'legacy') return '旧版记录 · 未校正'
1905
1909
  return ''
1906
1910
  }
1907
1911
  // 模型列表加载:并发合并 + 失败退避重试 + 失败态可重试。
@@ -2977,9 +2981,9 @@ function refreshUsageMain() {
2977
2981
  // 数值由“泡泡消失→下一次显示”的渲染自然采用最新 state
2978
2982
  if (d && d.ok && d.today && isFinite(Number(d.today.total))) {
2979
2983
  var recTotal = Number(d.today.total)
2980
- if (state.todayUsage === null || recTotal >= state.todayUsage) {
2981
- state.todayUsage = recTotal
2982
- }
2984
+ state.todayUsage = recTotal
2985
+ state.todayUsageCurrency = d.today.currency || 'CNY'
2986
+ state.usageLabel = d.today.label || '本地估算'
2983
2987
  }
2984
2988
  })
2985
2989
  .catch(function () { if (usageMainEl && !usageMainEl.firstChild) usageMainEl.textContent = '记录加载失败' })
@@ -2996,6 +3000,228 @@ function uSectionTitle(leftTxt, rightTxt) {
2996
3000
  h.appendChild(r)
2997
3001
  return h
2998
3002
  }
3003
+ function accountingTime(at) {
3004
+ try {
3005
+ return new Date(at).toLocaleString('zh-CN', {
3006
+ timeZone: 'Asia/Shanghai', hour12: false, year: 'numeric', month: '2-digit',
3007
+ day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
3008
+ })
3009
+ } catch (err) { return String(at || '') }
3010
+ }
3011
+ var accountingMask = null
3012
+ function openBalanceAdjustment() {
3013
+ if (accountingMask) return
3014
+ var previousFocus = document.activeElement
3015
+ var mask = document.createElement('div')
3016
+ mask.className = 'dshwv-usage-mask'
3017
+ mask.style.display = 'flex'
3018
+ mask.style.zIndex = '2147483647'
3019
+ var card = document.createElement('form')
3020
+ card.className = 'dshwv-usage-card'
3021
+ card.style.cssText = 'width:min(520px,calc(100vw - 32px));box-sizing:border-box;max-height:calc(100vh - 40px);overflow:auto;padding:22px;color:#203170;background:#fff;border-radius:18px;line-height:1.65'
3022
+ card.setAttribute('role', 'dialog')
3023
+ card.setAttribute('aria-modal', 'true')
3024
+ card.setAttribute('aria-label', '充值与余额校正')
3025
+ var title = document.createElement('h2')
3026
+ title.textContent = '充值与余额校正'
3027
+ title.style.cssText = 'font-size:20px;margin:0 0 8px'
3028
+ card.appendChild(title)
3029
+ var introduction = document.createElement('p')
3030
+ introduction.textContent = '核对统计区间内的全部到账后,重新计算本地消费。此操作不会充值,也不会改变 DeepSeek 账户余额。'
3031
+ introduction.style.cssText = 'font-size:13px;margin:0 0 14px'
3032
+ card.appendChild(introduction)
3033
+ function field(labelText, input) {
3034
+ var label = document.createElement('label')
3035
+ label.style.cssText = 'display:block;font-size:13px;margin:12px 0'
3036
+ var text = document.createElement('span')
3037
+ text.textContent = labelText
3038
+ label.appendChild(text)
3039
+ input.style.cssText = 'display:block;width:100%;box-sizing:border-box;min-height:40px;margin-top:5px;border:1px solid #b9c5e7;border-radius:8px;padding:8px;background:#fff;color:#203170;font:inherit'
3040
+ label.appendChild(input)
3041
+ card.appendChild(label)
3042
+ return text
3043
+ }
3044
+ var dates = document.createElement('select')
3045
+ field('记账日期(北京时间)', dates)
3046
+ var interval = document.createElement('div')
3047
+ interval.style.cssText = 'font-size:13px;white-space:pre-line;background:#f1f5ff;padding:12px;border-radius:10px'
3048
+ card.appendChild(interval)
3049
+ var credits = document.createElement('input')
3050
+ credits.type = 'text'
3051
+ credits.inputMode = 'decimal'
3052
+ credits.required = true
3053
+ credits.autocomplete = 'off'
3054
+ var creditsLabel = field('本统计区间累计到账金额', credits)
3055
+ var debits = document.createElement('input')
3056
+ debits.type = 'text'
3057
+ debits.inputMode = 'decimal'
3058
+ debits.autocomplete = 'off'
3059
+ var debitsLabel = field('非调用造成的余额减少(没有请填 0)', debits)
3060
+ var help = document.createElement('p')
3061
+ help.style.cssText = 'font-size:12px;margin:8px 0;color:#4c5e87'
3062
+ help.textContent = '到账包括充值、赠金等;多次到账请填合计,不要只填最后一笔。非调用减少可包含到期赠金、余额退回等。仅填写上方统计起点之后的金额;保存会替换之前的校正值。'
3063
+ card.appendChild(help)
3064
+ var preview = document.createElement('div')
3065
+ preview.style.cssText = 'font-weight:600;font-size:15px;margin:14px 0'
3066
+ preview.setAttribute('aria-live', 'polite')
3067
+ card.appendChild(preview)
3068
+ var confirmRow = document.createElement('label')
3069
+ confirmRow.style.cssText = 'display:flex;gap:8px;align-items:flex-start;font-size:13px;margin:12px 0;cursor:pointer'
3070
+ var confirm = document.createElement('input')
3071
+ confirm.type = 'checkbox'
3072
+ confirm.style.marginTop = '5px'
3073
+ confirmRow.appendChild(confirm)
3074
+ confirmRow.appendChild(document.createTextNode('我已核对本统计区间的全部到账和非调用扣减'))
3075
+ card.appendChild(confirmRow)
3076
+ var status = document.createElement('div')
3077
+ status.setAttribute('role', 'status')
3078
+ status.style.cssText = 'font-size:13px;white-space:normal;color:#b33333;margin:8px 0'
3079
+ status.textContent = '正在刷新余额…'
3080
+ card.appendChild(status)
3081
+ var actions = document.createElement('div')
3082
+ actions.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-top:16px'
3083
+ function button(text, callback) {
3084
+ var btn = document.createElement('button')
3085
+ btn.type = 'button'
3086
+ btn.textContent = text
3087
+ btn.className = 'dshwv-bubbtn dshwv-bubbtn-no'
3088
+ btn.style.cssText = 'min-height:38px;padding:8px 12px;cursor:pointer'
3089
+ if (callback) btn.addEventListener('click', callback)
3090
+ actions.appendChild(btn)
3091
+ return btn
3092
+ }
3093
+ var busy = false
3094
+ function close() {
3095
+ if (busy) return
3096
+ document.removeEventListener('keydown', keyHandler)
3097
+ mask.remove()
3098
+ accountingMask = null
3099
+ try { previousFocus.focus() } catch (err) {}
3100
+ }
3101
+ function keyHandler(e) {
3102
+ if (e.key === 'Escape') { e.preventDefault(); close() }
3103
+ if (e.key === 'Tab') {
3104
+ var focusable = Array.prototype.filter.call(card.querySelectorAll('button,input,select'), function (el) {
3105
+ return !el.disabled && el.style.display !== 'none'
3106
+ })
3107
+ var first = focusable[0], last = focusable[focusable.length - 1]
3108
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
3109
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
3110
+ }
3111
+ }
3112
+ var reload = button('重新读取', function () { load() })
3113
+ var reset = button('撤销该日校正', function () { submit('reset') })
3114
+ button('取消', close)
3115
+ var save = button('保存校正')
3116
+ save.type = 'submit'
3117
+ save.style.background = '#315de8'
3118
+ save.style.color = '#fff'
3119
+ save.style.border = '1px solid #315de8'
3120
+ card.appendChild(actions)
3121
+ var scrollBody = document.createElement('div')
3122
+ scrollBody.style.cssText = 'min-height:0;overflow:auto;flex:1 1 auto;padding-right:3px'
3123
+ while (card.firstChild && card.firstChild !== actions) scrollBody.appendChild(card.firstChild)
3124
+ card.insertBefore(scrollBody, actions)
3125
+ card.style.display = 'flex'
3126
+ card.style.flexDirection = 'column'
3127
+ card.style.overflow = 'hidden'
3128
+ actions.style.flex = '0 0 auto'
3129
+ actions.style.paddingTop = '10px'
3130
+ actions.style.borderTop = '1px solid #e2e8f5'
3131
+ mask.appendChild(card)
3132
+ mask.addEventListener('click', function (e) { if (e.target === mask) close() })
3133
+ document.body.appendChild(mask)
3134
+ accountingMask = mask
3135
+ document.addEventListener('keydown', keyHandler)
3136
+ var rows = []
3137
+ var selected = null
3138
+ function renderSelection() {
3139
+ selected = rows.find(function (row) { return row.day === dates.value }) || null
3140
+ save.disabled = !selected || busy
3141
+ reset.disabled = !selected || !selected.correctedAt || busy
3142
+ if (!selected) {
3143
+ interval.textContent = '尚无可校正的余额观测。请先配置 DeepSeek API key 并成功刷新余额。'
3144
+ return
3145
+ }
3146
+ interval.textContent = '统计起点:' + accountingTime(selected.firstObservedAt) +
3147
+ '\n最近观测:' + accountingTime(selected.lastObservedAt) +
3148
+ '\n起点余额 ' + usageMoney(selected.openingBalance, selected.currency) +
3149
+ ' → 当前余额 ' + usageMoney(selected.currentBalance, selected.currency) +
3150
+ '\n当前:' + selected.label + ' ' + usageMoney(selected.amount, selected.currency)
3151
+ creditsLabel.textContent = '本统计区间累计到账金额(' + selected.currency + ',未到账请填 0)'
3152
+ debitsLabel.textContent = '非调用造成的余额减少(' + selected.currency + ',没有请填 0)'
3153
+ credits.value = selected.credits == null ? '' : String(selected.credits)
3154
+ debits.value = selected.otherDebits == null ? '0' : String(selected.otherDebits)
3155
+ confirm.checked = false
3156
+ updatePreview()
3157
+ }
3158
+ function updatePreview() {
3159
+ if (!selected || credits.value.trim() === '' || !isFinite(Number(credits.value)) || !isFinite(Number(debits.value))) {
3160
+ preview.textContent = '填写完整金额后显示校正预览'
3161
+ return
3162
+ }
3163
+ var amount = selected.openingBalance + Number(credits.value) - Number(debits.value || 0) - selected.currentBalance
3164
+ preview.textContent = amount < -0.000000005 ? '校正后为负数,请核对金额和统计区间' : '校正后消费:' + usageMoney(Math.max(0, amount), selected.currency)
3165
+ }
3166
+ credits.addEventListener('input', updatePreview)
3167
+ debits.addEventListener('input', updatePreview)
3168
+ dates.addEventListener('change', renderSelection)
3169
+ function load() {
3170
+ if (busy) return
3171
+ busy = true
3172
+ save.disabled = true
3173
+ reset.disabled = true
3174
+ reload.disabled = true
3175
+ status.textContent = '正在刷新余额…'
3176
+ fetch('/dsh-whale/balance-adjustments.json', { cache: 'no-store' })
3177
+ .then(function (r) { return r.json() })
3178
+ .then(function (data) {
3179
+ if (!data.ok) throw new Error(data.error || '读取失败')
3180
+ rows = data.days || []
3181
+ dates.innerHTML = ''
3182
+ rows.forEach(function (row) {
3183
+ var option = document.createElement('option')
3184
+ option.value = row.day
3185
+ option.textContent = row.day + ' · ' + row.label
3186
+ dates.appendChild(option)
3187
+ })
3188
+ status.textContent = data.error ? '余额暂未刷新:' + data.error : ''
3189
+ })
3190
+ .catch(function (err) { status.textContent = err.message || '读取失败' })
3191
+ .finally(function () { busy = false; reload.disabled = false; renderSelection(); dates.focus() })
3192
+ }
3193
+ function submit(action) {
3194
+ if (busy || !selected) return
3195
+ if (action !== 'reset' && !confirm.checked) { status.textContent = '请先勾选确认,核对本统计区间的全部余额调整。'; return }
3196
+ busy = true
3197
+ save.disabled = true
3198
+ reset.disabled = true
3199
+ reload.disabled = true
3200
+ status.textContent = '正在保存…'
3201
+ fetch('/dsh-whale/balance-adjustments.json', {
3202
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
3203
+ body: JSON.stringify({
3204
+ day: selected.day, revision: selected.revision, action: action,
3205
+ credits: credits.value.trim(), otherDebits: debits.value.trim() || '0', confirmed: confirm.checked
3206
+ })
3207
+ })
3208
+ .then(function (r) { return r.json() })
3209
+ .then(function (data) {
3210
+ if (!data.ok) throw new Error(data.error || '保存失败')
3211
+ busy = false
3212
+ close()
3213
+ refresh(true)
3214
+ loadApiModels(function () {
3215
+ if (usagePanelOpen) rebuildUsageSubShell()
3216
+ }, true)
3217
+ })
3218
+ .catch(function (err) { status.textContent = err.message || '保存失败' })
3219
+ .finally(function () { busy = false; save.disabled = !selected; reset.disabled = !selected || !selected.correctedAt; reload.disabled = false })
3220
+ }
3221
+ card.addEventListener('submit', function (e) { e.preventDefault(); submit('save') })
3222
+ load()
3223
+ }
3224
+
2999
3225
  function fillUsagePanel(d) {
3000
3226
  var hostEl = usageMainEl || usagePanel
3001
3227
  hostEl.innerHTML = ''
@@ -3010,9 +3236,26 @@ function fillUsagePanel(d) {
3010
3236
  var todayModels = today.models || []
3011
3237
  var hasEvToday = todayModels.length > 0
3012
3238
  // ① 今日模型消费(独立容器+滚动);标题不带下分隔线
3013
- var tToday = uSectionTitle('今日模型消费', usageMoney(today.total))
3239
+ var tToday = uSectionTitle(today.label || '今日消费', usageMoney(today.total, today.currency))
3014
3240
  tToday.style.borderBottom = 'none'
3015
3241
  wrap.appendChild(tToday)
3242
+ var coverage = document.createElement('div')
3243
+ coverage.className = 'dshwv-usage-hint'
3244
+ coverage.style.cssText = 'line-height:1.65;white-space:normal;margin:4px 0'
3245
+ coverage.textContent = today.firstObservedAt
3246
+ ? '统计起点(北京):' + accountingTime(today.firstObservedAt) + '。起点前的消费未计入。'
3247
+ : '尚无余额观测;此处仅显示本机会话估算或旧版记录。'
3248
+ if (today.needsReview) coverage.textContent += ' 检测到余额增加,请核对本区间累计到账。'
3249
+ wrap.appendChild(coverage)
3250
+ var adjustButton = document.createElement('button')
3251
+ adjustButton.type = 'button'
3252
+ adjustButton.className = 'dshwv-usage-more'
3253
+ adjustButton.textContent = '充值 / 余额校正'
3254
+ adjustButton.addEventListener('click', function (e) { e.stopPropagation(); openBalanceAdjustment() })
3255
+ wrap.appendChild(adjustButton)
3256
+ var modelTitle = uSectionTitle('本机模型费用 · 估算', usageMoney(today.modelTotal, 'CNY'))
3257
+ modelTitle.title = '按本机 DSH 会话计算,不按账户余额比例缩放;两者覆盖范围不同。'
3258
+ wrap.appendChild(modelTitle)
3016
3259
  var todayBox = document.createElement('div')
3017
3260
  todayBox.className = 'dshwv-usage-scroll dshwv-usage-today'
3018
3261
  if (hasEvToday) {
@@ -3030,7 +3273,7 @@ function fillUsagePanel(d) {
3030
3273
  } else if ((today.total || 0) > 0) {
3031
3274
  var noM = document.createElement('div')
3032
3275
  noM.className = 'dshwv-usage-hint'
3033
- noM.textContent = '今日总额来自余额差值,暂不含模型明细(启用会话记录后将按模型展示)'
3276
+ noM.textContent = '暂无本机会话费用明细;账户余额观测仍独立记账。'
3034
3277
  todayBox.appendChild(noM)
3035
3278
  } else {
3036
3279
  var empty = document.createElement('div')
@@ -3045,7 +3288,7 @@ function fillUsagePanel(d) {
3045
3288
  sep7.style.borderTop = '1px solid rgba(32,49,112,.15)'
3046
3289
  sep7.style.margin = '6px 0'
3047
3290
  wrap.appendChild(sep7)
3048
- var t7 = uSectionTitle('近7天使用记录', usageMoney(d.total7))
3291
+ var t7 = uSectionTitle('近7天使用记录', usageMoney(d.total7, d.total7Currency))
3049
3292
  t7.style.borderBottom = 'none'
3050
3293
  wrap.appendChild(t7)
3051
3294
  var daysBox = document.createElement('div')
@@ -3055,9 +3298,10 @@ function fillUsagePanel(d) {
3055
3298
  r.className = 'dshwv-usage-row'
3056
3299
  var n = document.createElement('span')
3057
3300
  n.textContent = usageDayLabel(row.date)
3301
+ n.title = row.label || ''
3058
3302
  r.appendChild(n)
3059
3303
  var c = document.createElement('span')
3060
- c.textContent = usageMoney(row.total)
3304
+ c.textContent = usageMoney(row.total, row.currency)
3061
3305
  r.appendChild(c)
3062
3306
  daysBox.appendChild(r)
3063
3307
  })
@@ -3856,8 +4100,8 @@ function usageDrawBarChart(body, secTitle, days, opts) {
3856
4100
  g.fillStyle = '#9fb0d9'
3857
4101
  g.font = '10px sans-serif'
3858
4102
  g.textAlign = 'right'
3859
- g.fillText(usageMoney(max), padL - 4, padT + 8)
3860
- g.fillText(usageMoney(max / 2), padL - 4, padT + ih / 2 + 3)
4103
+ g.fillText(usageMoney(max, opts.currency), padL - 4, padT + 8)
4104
+ g.fillText(usageMoney(max / 2, opts.currency), padL - 4, padT + ih / 2 + 3)
3861
4105
  g.fillText('¥0', padL - 4, padT + ih + 4)
3862
4106
  }
3863
4107
  function move(ev) {
@@ -3870,7 +4114,7 @@ function usageDrawBarChart(body, secTitle, days, opts) {
3870
4114
  paint(hover)
3871
4115
  if (hover >= 0) {
3872
4116
  tip.style.display = 'block'
3873
- tip.textContent = bars[hover].day.date + ' ' + usageMoney(bars[hover].day.total)
4117
+ tip.textContent = bars[hover].day.date + ' ' + usageMoney(bars[hover].day.total, opts.currency)
3874
4118
  var wr = wrap.getBoundingClientRect()
3875
4119
  var tx = ev.clientX - wr.left + 10
3876
4120
  if (tx + 130 > wr.width) tx = ev.clientX - wr.left - 140
@@ -4006,31 +4250,37 @@ function fillUsageRecordsWindow(d) {
4006
4250
  var allDays = ((d.all && d.all.days) || []).slice().sort(function (a, b) { return a.date < b.date ? -1 : 1 })
4007
4251
  var evAll = ((d.all && d.all.events) || []).slice()
4008
4252
  // ① 概览头
4009
- var sumAll = 0
4253
+ var sumAllByCurrency = {}
4254
+ var chartCurrency = (d.today && d.today.currency) || 'CNY'
4010
4255
  var maxDay = null
4011
4256
  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]
4257
+ var dayCurrency = allDays[s1].currency || 'CNY'
4258
+ sumAllByCurrency[dayCurrency] = (sumAllByCurrency[dayCurrency] || 0) + (Number(allDays[s1].total) || 0)
4259
+ if (dayCurrency === chartCurrency && (!maxDay || Number(allDays[s1].total) > Number(maxDay.total))) maxDay = allDays[s1]
4014
4260
  }
4015
4261
  var ov = document.createElement('div')
4016
4262
  ov.className = 'dshwv-usage-oview'
4017
4263
  var ovL = document.createElement('div')
4018
- ovL.textContent = '全部消费'
4264
+ ovL.textContent = '全部记录合计'
4019
4265
  ov.appendChild(ovL)
4020
4266
  var ovN = document.createElement('div')
4021
4267
  ovN.className = 'dshwv-usage-oview-num'
4022
- ovN.textContent = usageMoney(sumAll)
4268
+ ovN.textContent = Object.keys(sumAllByCurrency).map(function (cur) { return usageMoney(sumAllByCurrency[cur], cur) }).join(' + ') || usageMoney(0, chartCurrency)
4023
4269
  ov.appendChild(ovN)
4024
4270
  var ovS = document.createElement('div')
4025
4271
  ovS.className = 'dshwv-usage-hint'
4026
- ovS.textContent = '共 ' + evAll.length + ' 笔明细' + (maxDay ? ' · 峰值 ' + maxDay.date + ' ' + usageMoney(maxDay.total) : '')
4272
+ ovS.textContent = '共 ' + evAll.length + ' 笔明细' + (maxDay ? ' · 峰值 ' + maxDay.date + ' ' + usageMoney(maxDay.total, maxDay.currency) : '')
4027
4273
  ov.appendChild(ovS)
4274
+ var sourceNote = document.createElement('div')
4275
+ sourceNote.className = 'dshwv-usage-hint'
4276
+ sourceNote.textContent = '按各日标注口径汇总;本机模型明细为估算,与账户消费覆盖范围不同。'
4277
+ ov.appendChild(sourceNote)
4028
4278
  body.appendChild(ov)
4029
4279
  var detailBox = null
4030
4280
  // ② 统计图表(默认折叠,懒渲染)
4031
4281
  usageCollapseBlock(body, '统计图表(近30天 / 模型占比)', false, function (inner) {
4032
- usageDrawBarChart(inner, '近30天消费(绿柱=今天,点柱定位到当日)', allDays.slice(-30), {
4033
- today: usageTodayKeyStr(),
4282
+ usageDrawBarChart(inner, '近30天消费 · ' + chartCurrency + '(点柱查看当日)', allDays.filter(function (row) { return (row.currency || 'CNY') === chartCurrency }).slice(-30), {
4283
+ today: usageTodayKeyStr(), currency: chartCurrency,
4034
4284
  onPick: function (date) {
4035
4285
  try {
4036
4286
  if (!detailBox) return
@@ -4047,9 +4297,9 @@ function fillUsageRecordsWindow(d) {
4047
4297
  },
4048
4298
  })
4049
4299
  var todayAgg = usageAggModels(d.today && d.today.models ? [{ models: d.today.models }] : [])
4050
- usageRatioRows(inner, '今日模型占比', todayAgg, usageMoney((d.today && d.today.total) || 0))
4300
+ usageRatioRows(inner, '今日模型费用 · 估算', todayAgg, usageMoney((d.today && d.today.modelTotal) || 0))
4051
4301
  var sevenAgg = usageAggModels(d.days7 || [])
4052
- usageRatioRows(inner, '近7天模型占比', sevenAgg, usageMoney((d.total7) || 0))
4302
+ usageRatioRows(inner, '近7天模型费用 · 估算', sevenAgg, usageMoney((d.days7 || []).reduce(function (sum, row) { return sum + (Number(row.modelTotal) || 0) }, 0)))
4053
4303
  })
4054
4304
  // ③ 每日与逐条明细(默认折叠;搜索/限量加载)
4055
4305
  detailBox = usageCollapseBlock(body, '每日与逐条明细', false, function (inner) {
@@ -4071,7 +4321,8 @@ function fillUsageRecordsWindow(d) {
4071
4321
  ;(evMap[day] = evMap[day] || []).push(ev)
4072
4322
  })
4073
4323
  var dayTot = {}
4074
- allDays.forEach(function (dx) { dayTot[dx.date] = Number(dx.total) || 0 })
4324
+ var dayMeta = {}
4325
+ allDays.forEach(function (dx) { dayTot[dx.date] = Number(dx.total) || 0; dayMeta[dx.date] = dx })
4075
4326
  var todayKeyStr2 = usageTodayKeyStr()
4076
4327
  function dayGroup(day, evs) {
4077
4328
  var row = document.createElement('div')
@@ -4091,7 +4342,8 @@ function fillUsageRecordsWindow(d) {
4091
4342
  var dayV = dayTot[day]
4092
4343
  if ((dayV === undefined || dayV === 0) && day === todayKeyStr2 && d.today && isFinite(Number(d.today.total))) dayV = Number(d.today.total)
4093
4344
  if (dayV === undefined || dayV === null) dayV = evs.reduce(function (a, x) { return a + (Number(x.cost) || 0) }, 0)
4094
- c.textContent = usageMoney(dayV)
4345
+ c.textContent = usageMoney(dayV, dayMeta[day] && dayMeta[day].currency)
4346
+ name.title = (dayMeta[day] && dayMeta[day].label) || '本地估算'
4095
4347
  row.appendChild(c)
4096
4348
  var chev = document.createElement('span')
4097
4349
  chev.className = 'dshwv-usage-chev'
@@ -4124,6 +4376,7 @@ function fillUsageRecordsWindow(d) {
4124
4376
  var c2 = document.createElement('span')
4125
4377
  c2.style.flex = '0 0 auto'
4126
4378
  c2.textContent = usageMoney(ev.cost)
4379
+ c2.title = '本地估算:' + Number(ev.cost || 0).toFixed(8) + ' CNY'
4127
4380
  r2.appendChild(c2)
4128
4381
  detail.appendChild(r2)
4129
4382
  }
@@ -5751,7 +6004,7 @@ function bubbleModuleSummary(m) {
5751
6004
  if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
5752
6005
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
5753
6006
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
5754
- if (m.type === 'plan') return '订阅额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
6007
+ if (m.type === 'plan') return bubblePlanModuleLabel(m)
5755
6008
  if (m.type === 'peak' || m.type === 'nextpeak') return bubblePeakModuleLabel(m)
5756
6009
  if (m.type === 'image') return '图片/动图'
5757
6010
  if (m.type === 'randimg') return '随机图片' + (m.imgs && m.imgs.length ? '(' + m.imgs.length + '张)' : '(空)')
@@ -5768,7 +6021,7 @@ function bubbleModuleListLabel(m) {
5768
6021
  if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
5769
6022
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
5770
6023
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
5771
- if (m.type === 'plan') return '订阅额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
6024
+ if (m.type === 'plan') return bubblePlanModuleLabel(m)
5772
6025
  if (m.type === 'peak' || m.type === 'nextpeak') return bubblePeakModuleLabel(m)
5773
6026
  if (m.type === 'image') return '图片/动图'
5774
6027
  if (m.type === 'randimg') return '随机图片' + (m.imgs && m.imgs.length ? '(' + m.imgs.length + '张)' : '(空)')
@@ -6779,8 +7032,8 @@ function openQuickModuleEditor(m, anchorBtn) {
6779
7032
  var isModelBal = bubbleIsModelMod(m) && (m.type === 'balance' || m.type === 'today')
6780
7033
  var isModelQuota = bubbleIsModelMod(m) && m.type === 'quota'
6781
7034
  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}')))))
7035
+ 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}')))))
7036
+ 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
7037
  inp.addEventListener('input', function () { m.tpl = inp.value; changed() })
6785
7038
  r.appendChild(inp)
6786
7039
  var qb2 = document.createElement('button')
@@ -6794,6 +7047,25 @@ function openQuickModuleEditor(m, anchorBtn) {
6794
7047
  box.appendChild(r)
6795
7048
  }
6796
7049
  tplRow()
7050
+ // 订阅额度模块的「显示样式」= 选时间窗口(多窗口厂商,如 OpenCode Go 的 5h / 周 / 月)
7051
+ if (m.type === 'plan' && (apiPlanMultiWin(m.modelId) || apiPlanWinList(m.modelId))) {
7052
+ var wrow = qRow()
7053
+ wrow.appendChild(qLabel('显示样式'))
7054
+ var wsel = document.createElement('select')
7055
+ for (var wi = 0; wi < BUBBLE_PLAN_WIN_OPTS.length; wi++) {
7056
+ var wo = document.createElement('option')
7057
+ wo.value = BUBBLE_PLAN_WIN_OPTS[wi][0]
7058
+ wo.textContent = BUBBLE_PLAN_WIN_OPTS[wi][1]
7059
+ wsel.appendChild(wo)
7060
+ }
7061
+ wsel.value = bubblePlanWinOf(m)
7062
+ wsel.style.flex = '1'
7063
+ wsel.style.minWidth = '0'
7064
+ wrow.appendChild(wsel)
7065
+ box.appendChild(wrow)
7066
+ dshwCustSel(wsel)
7067
+ wsel.addEventListener('change', function () { m.planWin = wsel.value || 'all'; changed() })
7068
+ }
6797
7069
  if (m.type === 'peak' || m.type === 'nextpeak') {
6798
7070
  // 显示样式(与编辑窗口一致)
6799
7071
  var srow = qRow()
@@ -6985,6 +7257,32 @@ function renderBubblePal() {
6985
7257
  if (apiModelsLoaded) {
6986
7258
  apiModels.forEach(function (am) {
6987
7259
  if (!am || !am.id || am.builtin) return
7260
+ var planSupported = apiPlanSupport(am.id)
7261
+ // 只有「一个接口返回多个窗口」的额度厂商(如 OpenCode Go)才把调色板收成一个「额度」模块;
7262
+ // 单窗口的既有额度厂商(智谱 / Kimi / MiniMax Coding)保持上游原有的三个模块不变
7263
+ if (planSupported && apiPlanMultiWin(am.id)) {
7264
+ // 订阅额度厂商(OpenCode Go / 智谱 / Kimi / MiniMax Coding 等):这类厂商本来就没有余额接口,
7265
+ // 只给一个「额度」模块 —— 时间窗口(5h / 周 / 月 / 全部)与显示内容都在模块编辑器里选,
7266
+ // 不再并列「余额 / 手动额度 / 订阅额度」三个模块。
7267
+ var key4 = 'pq:' + am.id
7268
+ var chip4 = document.createElement('div')
7269
+ chip4.className = 'dshwv-palchip'
7270
+ chip4.setAttribute('data-pal', key4)
7271
+ chip4.textContent = '额度·' + am.name
7272
+ var planWinHint = apiPlanMultiWin(am.id) ? ';「显示样式」可选时间窗口(全部 / 5h / 周 / 月)' : ''
7273
+ chip4.title = '该厂商的订阅额度(由厂商接口读取,非手动);模板变量 {plan} 额度 / {plan_reset} 刷新时间 / {plan_left} 剩余' + planWinHint
7274
+ chip4.draggable = true
7275
+ chip4.addEventListener('click', function (e) {
7276
+ e.stopPropagation()
7277
+ bubbleModuleAdd({ type: 'plan', modelId: am.id, size: 8, tpl: '{plan} · {plan_reset}', planWin: 'all' })
7278
+ })
7279
+ chip4.addEventListener('dragstart', function (e) {
7280
+ try { e.dataTransfer.setData('text/plain', key4) } catch (err) {}
7281
+ bubbleDragKey = key4
7282
+ })
7283
+ bubblePalEl.appendChild(chip4)
7284
+ return
7285
+ }
6988
7286
  var key = 'bal:' + am.id
6989
7287
  var chip2 = document.createElement('div')
6990
7288
  chip2.className = 'dshwv-palchip'
@@ -7018,25 +7316,6 @@ function renderBubblePal() {
7018
7316
  bubbleDragKey = key3
7019
7317
  })
7020
7318
  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
7319
  })
7041
7320
  } else if (apiModelsError) {
7042
7321
  var chipErr = document.createElement('div')
@@ -7286,11 +7565,11 @@ function bubblePaletteModule(key) {
7286
7565
  if (!am1) return null
7287
7566
  return { type: 'quota', modelId: am1.id, size: 8, tpl: '已用 {quota} · 剩 {quota_left}' }
7288
7567
  }
7289
- // 厂商订阅额度模块(palette key = pq:<modelId>)
7568
+ // 厂商订阅额度模块(palette key = pq:<modelId>):时间窗口由模块里的「显示样式」决定
7290
7569
  if (typeof key === 'string' && key.indexOf('pq:') === 0) {
7291
7570
  var am2 = apiModelById(key.slice(3))
7292
7571
  if (!am2) return null
7293
- return { type: 'plan', modelId: am2.id, size: 8, tpl: '额度 {plan}' }
7572
+ return { type: 'plan', modelId: am2.id, size: 8, tpl: '{plan} · {plan_reset}', planWin: 'all' }
7294
7573
  }
7295
7574
  if (key === 'random') return bubbleCloneModule(bubbleDefaultSecondModules()[0])
7296
7575
  if (typeof key === 'string' && key.indexOf('lib:') === 0) {
@@ -9738,6 +10017,8 @@ var state = {
9738
10017
  balance: null,
9739
10018
  currency: null,
9740
10019
  todayUsage: null,
10020
+ todayUsageCurrency: 'CNY',
10021
+ usageLabel: '本地估算',
9741
10022
  isPeak: false,
9742
10023
  status: 'loading',
9743
10024
  message: '',
@@ -10347,7 +10628,7 @@ function bubbleAmountText() {
10347
10628
  return fmt(v, state.currency)
10348
10629
  }
10349
10630
  function bubbleTodayText() {
10350
- return '今日已用 ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
10631
+ return (state.usageLabel || '今日已用') + ' ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
10351
10632
  }
10352
10633
  // 模块「内容」模板:占位符统一英文(便于兼容其他模型 API 时区分来源/字段):
10353
10634
  // {expense_ds} 今日已用金额 · {balance_ds} 余额 · {status} 高峰/空闲状态字 · {countdown} 倒计时
@@ -10520,36 +10801,160 @@ function apiPlanPctText(v) {
10520
10801
  if (v === null || v === undefined) return '--'
10521
10802
  return (Number(v) || 0).toFixed(1).replace(/\.0$/, '') + '%'
10522
10803
  }
10523
- function apiPlanUsedText(modelId) {
10804
+ // —— 多窗口订阅额度(如 OpenCode Go 的 rolling / weekly / monthly)——
10805
+ // 模块的「显示样式」= 选哪个时间窗口:all 全部三窗口 / rolling 5h / weekly 周 / monthly 月。
10806
+ // 选中具体窗口时输出会带上窗口标签(如 `5h 2%`),避免看不出是哪个时间段。
10807
+ var BUBBLE_PLAN_WIN_OPTS = [
10808
+ ['all', '全部(5h / 周 / 月)'],
10809
+ ['rolling', '5h'],
10810
+ ['weekly', '周'],
10811
+ ['monthly', '月'],
10812
+ ]
10813
+ function bubblePlanWinOf(m) {
10814
+ m = m || {}
10815
+ var w = String(m.planWin || 'all')
10816
+ for (var i = 0; i < BUBBLE_PLAN_WIN_OPTS.length; i++) { if (BUBBLE_PLAN_WIN_OPTS[i][0] === w) return w }
10817
+ return 'all'
10818
+ }
10819
+ function bubblePlanWinLabel(w) {
10820
+ 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] }
10821
+ return BUBBLE_PLAN_WIN_OPTS[0][1]
10822
+ }
10823
+ // 订阅额度模块在编辑列表 / 模块库里的名字(带所选窗口,便于区分同一个模型的多个模块)
10824
+ function bubblePlanModuleLabel(m) {
10825
+ m = m || {}
10826
+ var nm = (apiModelById(m.modelId) || {}).name || m.modelId
10827
+ var w = bubblePlanWinOf(m)
10828
+ return '额度·' + nm + (w === 'all' ? '' : '(' + bubblePlanWinLabel(w) + ')')
10829
+ }
10830
+ // 该模型的厂商模板是否声明了多窗口额度(决定编辑器里要不要给「显示样式」下拉)
10831
+ function apiPlanMultiWin(modelId) {
10832
+ var am = apiModelById(modelId)
10833
+ if (!am) return false
10834
+ var list = apiTemplates || []
10835
+ for (var i = 0; i < list.length; i++) {
10836
+ if (list[i] && list[i].id === am.provider) {
10837
+ var q = list[i].quota
10838
+ return !!(q && q.json && q.json.windows && q.json.windows.length)
10839
+ }
10840
+ }
10841
+ return false
10842
+ }
10843
+ function apiPlanWinList(modelId) {
10844
+ var p = apiPlanOf(modelId)
10845
+ return (p && p.ok && p.windows && p.windows.length) ? p.windows : null
10846
+ }
10847
+ // 按窗口取「已用% / 剩余%」文本;非多窗口厂商返回 null(走原有单窗口逻辑)
10848
+ function apiPlanPctWinText(modelId, win, left) {
10849
+ var list = apiPlanWinList(modelId)
10850
+ if (!list) return null
10851
+ function one(w) {
10852
+ var v = w.usedPct
10853
+ if (v === null || v === undefined) return '--'
10854
+ var pct = left ? Math.max(0, 100 - Number(v)) : Number(v)
10855
+ return (w.label ? (w.label + ' ') : '') + apiPlanPctText(pct)
10856
+ }
10857
+ if (win && win !== 'all') {
10858
+ for (var i = 0; i < list.length; i++) { if (list[i].key === win) return one(list[i]) }
10859
+ return '--'
10860
+ }
10861
+ var parts = []
10862
+ for (var j = 0; j < list.length; j++) parts.push(one(list[j]))
10863
+ return parts.join(' · ')
10864
+ }
10865
+ // 按窗口取「重置倒计时」文本;非多窗口厂商返回 null
10866
+ function apiPlanResetWinText(modelId, win) {
10867
+ var list = apiPlanWinList(modelId)
10868
+ if (!list) return null
10869
+ function one(w, short) {
10870
+ var ms = apiPlanResetMs(w.resetAt)
10871
+ if (ms === null) return '--'
10872
+ var t = short ? apiPlanCountdownShortText(ms) : apiPlanCountdownText(ms)
10873
+ return t || '--'
10874
+ }
10875
+ if (win && win !== 'all') {
10876
+ for (var i = 0; i < list.length; i++) { if (list[i].key === win) return one(list[i], true) }
10877
+ return '--'
10878
+ }
10879
+ var parts = []
10880
+ for (var j = 0; j < list.length; j++) parts.push(one(list[j], true))
10881
+ return parts.join(' · ')
10882
+ }
10883
+ function apiPlanUsedText(modelId, win) {
10884
+ var t = apiPlanPctWinText(modelId, win, false)
10885
+ if (t !== null) return t
10524
10886
  var p = apiPlanOf(modelId)
10525
10887
  if (!p || !p.ok) return '--'
10526
10888
  return apiPlanPctText(p.usedPct)
10527
10889
  }
10528
- function apiPlanLeftText(modelId) {
10890
+ function apiPlanLeftText(modelId, win) {
10891
+ var t = apiPlanPctWinText(modelId, win, true)
10892
+ if (t !== null) return t
10529
10893
  var p = apiPlanOf(modelId)
10530
10894
  if (!p || !p.ok) return '--'
10531
10895
  return apiPlanPctText(p.remainPct)
10532
10896
  }
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)
10897
+ // 重置时间归一成毫秒(秒 / 毫秒 / 日期字符串都兼容);解析不出来返回 null
10898
+ function apiPlanResetMs(t) {
10899
+ if (t === null || t === undefined || t === '') return null
10900
+ if (typeof t === 'number') return t < 1e12 ? t * 1000 : t // 秒 / 毫秒都兼容
10901
+ var pd = Date.parse(String(t))
10902
+ return isFinite(pd) ? pd : null
10903
+ }
10904
+ // 倒计时文本(长写法,单窗口用)
10905
+ function apiPlanCountdownText(ms) {
10541
10906
  var left = ms - Date.now()
10542
- if (!isFinite(left)) return String(t)
10907
+ if (!isFinite(left)) return ''
10543
10908
  if (left <= 0) return '即将重置'
10544
10909
  var h = Math.floor(left / 3600000)
10545
10910
  var d = Math.floor(h / 24)
10546
10911
  if (d > 0) return d + '天' + (h % 24) + '小时后重置'
10547
10912
  return h + '小时' + Math.floor((left % 3600000) / 60000) + '分后重置'
10548
10913
  }
10914
+ // 倒计时文本(紧凑写法,多窗口用:5d21h / 3h53m)——单位统一用 d/h/m,不掺中文
10915
+ // 不带「后重置」字样:多窗口模块里窗口标签已说明它是什么(如 `5h 2% · 3h53m`)
10916
+ function apiPlanCountdownShortText(ms) {
10917
+ var left = ms - Date.now()
10918
+ if (!isFinite(left)) return ''
10919
+ if (left <= 0) return '即将重置'
10920
+ var m = Math.floor(left / 60000)
10921
+ var h = Math.floor(m / 60)
10922
+ var d = Math.floor(h / 24)
10923
+ if (d > 0) return d + 'd' + (h % 24) + 'h'
10924
+ if (h > 0) return h + 'h' + (m % 60) + 'm'
10925
+ return m + 'm'
10926
+ }
10927
+ function apiPlanResetText(modelId, win) {
10928
+ var t = apiPlanResetWinText(modelId, win)
10929
+ if (t !== null) return t
10930
+ var p = apiPlanOf(modelId)
10931
+ if (!p || !p.ok || p.resetAt === null || p.resetAt === undefined || p.resetAt === '') return '--'
10932
+ var ms = apiPlanResetMs(p.resetAt)
10933
+ if (ms === null) return String(p.resetAt)
10934
+ var txt = apiPlanCountdownText(ms)
10935
+ return txt || String(p.resetAt)
10936
+ }
10549
10937
  function apiPlanSummary(modelId) {
10550
10938
  var p = apiPlanOf(modelId)
10551
10939
  if (!p) return '读取中…'
10552
10940
  if (!p.ok) return p.hide ? '--' : (p.error || '读取失败')
10941
+ // v0.3.1:多窗口额度(如 OpenCode Go 的 5h / 周 / 月)逐窗口展示:`5h 12.5% · 2h55m后重置 | 周 …`
10942
+ if (p.windows && p.windows.length) {
10943
+ var parts = []
10944
+ for (var i = 0; i < p.windows.length; i++) {
10945
+ var w = p.windows[i] || {}
10946
+ var seg = w.label ? (w.label + ' ') : ''
10947
+ seg += apiPlanPctText(w.usedPct)
10948
+ var ms = apiPlanResetMs(w.resetAt)
10949
+ if (ms !== null) {
10950
+ var rt = apiPlanCountdownShortText(ms)
10951
+ if (rt) seg += ' · ' + rt
10952
+ }
10953
+ parts.push(seg)
10954
+ }
10955
+ if (p.level) parts.push(p.level)
10956
+ return parts.join(' | ')
10957
+ }
10553
10958
  var s = '已用 ' + apiPlanUsedText(modelId) + ' · ' + apiPlanResetText(modelId)
10554
10959
  if (p.weeklyUsedPct !== null && p.weeklyUsedPct !== undefined) s += ' · 周 ' + apiPlanPctText(p.weeklyUsedPct)
10555
10960
  if (p.level) s += ' · ' + p.level
@@ -10573,18 +10978,19 @@ function bubbleContentTokenMap(m) {
10573
10978
  map['quota_left'] = apiQuotaLeftText(m.modelId)
10574
10979
  map['quota_total'] = apiQuotaTotalText(m.modelId)
10575
10980
  map['quota_reset'] = apiQuotaResetText(m.modelId)
10576
- // 厂商订阅额度占位符(kind='quota' 的厂商)
10577
- map['plan'] = apiPlanUsedText(m.modelId)
10981
+ // 厂商订阅额度占位符(kind='quota' 的厂商):按模块「显示样式」选的窗口取数
10982
+ var pwin = bubblePlanWinOf(m)
10983
+ map['plan'] = apiPlanUsedText(m.modelId, pwin)
10578
10984
  map['plan_used'] = map['plan']
10579
- map['plan_left'] = apiPlanLeftText(m.modelId)
10580
- map['plan_reset'] = apiPlanResetText(m.modelId)
10985
+ map['plan_left'] = apiPlanLeftText(m.modelId, pwin)
10986
+ map['plan_reset'] = apiPlanResetText(m.modelId, pwin)
10581
10987
  return map
10582
10988
  }
10583
10989
  if (m.type === 'balance') {
10584
10990
  v = bubbleAmountText()
10585
10991
  map['balance_ds'] = v
10586
10992
  } else if (m.type === 'today') {
10587
- v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
10993
+ v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
10588
10994
  map['expense_ds'] = v
10589
10995
  } else if (bubbleIsPeakCount(m)) {
10590
10996
  v = bubbleCountdownText()
@@ -10612,9 +11018,9 @@ function bubbleTplHelpItems(m) {
10612
11018
  add('quota_total', '额度总量')
10613
11019
  add('quota_reset', '额度重置倒计时')
10614
11020
  }
10615
- add('plan', '订阅额度已用百分比')
10616
- add('plan_left', '订阅额度剩余百分比')
10617
- add('plan_reset', '订阅额度重置倒计时')
11021
+ add('plan', '订阅额度已用百分比(多窗口厂商按「显示样式」所选窗口,带窗口标签)')
11022
+ add('plan_left', '订阅额度剩余百分比(同上)')
11023
+ add('plan_reset', '订阅额度刷新倒计时(同上;全部窗口时为紧凑倒计时)')
10618
11024
  return arr
10619
11025
  }
10620
11026
  if (m.type === 'balance') add('balance_ds', '余额数值')
@@ -10887,7 +11293,19 @@ function bubbleCountdownTick() {
10887
11293
  bubbleCountdownApplyStyle(x.el, x.mod, cur)
10888
11294
  } catch (err) {}
10889
11295
  }
10890
- if (!bubbleCountdownRows.length && bubbleCountdownTicker) {
11296
+ // v733:非倒计时的峰谷行共用这个 ticker(只在状态真的变了时才动 DOM,不打断跑马灯)
11297
+ bubblePeakRows = bubblePeakRows.filter(function (y) { return y && y.row && y.row.isConnected })
11298
+ if (bubblePeakRows.length) {
11299
+ var peakNow = bubbleIsPeakNow()
11300
+ for (var k = 0; k < bubblePeakRows.length; k++) {
11301
+ var y2 = bubblePeakRows[k]
11302
+ if (y2.peak !== peakNow) {
11303
+ y2.peak = peakNow
11304
+ try { bubblePeakRowApply(y2, peakNow) } catch (err) {}
11305
+ }
11306
+ }
11307
+ }
11308
+ if (!bubbleCountdownRows.length && !bubblePeakRows.length && bubbleCountdownTicker) {
10891
11309
  clearInterval(bubbleCountdownTicker)
10892
11310
  bubbleCountdownTicker = null
10893
11311
  }
@@ -10897,6 +11315,78 @@ function bubbleCountdownRegister(el, mod) {
10897
11315
  bubbleCountdownRows.push({ el: el, mod: mod })
10898
11316
  if (!bubbleCountdownTicker) bubbleCountdownTicker = setInterval(bubbleCountdownTick, 1000)
10899
11317
  }
11318
+ // —— v733:峰谷状态实时跟随 ——
11319
+ // count / nextpeak 样式由倒计时引擎逐秒刷新;其余峰谷样式(默认「高峰时段 / 空闲时段」、
11320
+ // 梁文峰谷、!?峰峰?!、简洁峰/谷)原来只在渲染那一刻取一次状态 —— 泡泡显示期间跨过峰谷切换点,
11321
+ // 文字与配色会一直停在旧状态。这里把这类行登记进同一个 1s ticker,状态变化时**原地**改写
11322
+ // 文字 / 配色 / 底色(不做整泡重绘,保持「泡泡显示期间内容稳定」的既有设计)。
11323
+ var bubblePeakRows = []
11324
+ // 文字是直接放在行上还是包一层内层 span,取决于该状态有没有底色 ——
11325
+ // 只有「高峰 / 空闲两态底色有无一致」时,DOM 结构才不随状态变化,才能原地改写
11326
+ function bubblePeakRowStable(m) {
11327
+ return !!(m.peakBg || m.peakBgRgb) === !!(m.offBg || m.offBgRgb)
11328
+ }
11329
+ function bubblePeakRowRegister(row, tx, mod, peakNow) {
11330
+ try {
11331
+ if (!mod || mod.type !== 'peak' || bubbleIsPeakCount(mod)) return
11332
+ if (!bubblePeakRowStable(mod)) return
11333
+ bubblePeakRows.push({
11334
+ row: row, tx: tx, mod: mod, peak: !!peakNow,
11335
+ gradEl: (mod.peakBg || mod.peakBgRgb) ? tx : row,
11336
+ })
11337
+ if (!bubbleCountdownTicker) bubbleCountdownTicker = setInterval(bubbleCountdownTick, 1000)
11338
+ } catch (err) {}
11339
+ }
11340
+ // 跑马灯配色方案白名单(与 blockOf() / bubbleCountdownApplyStyle 用的那套一致)
11341
+ function bubbleRgbSchemeOk(s) {
11342
+ return s === 'candy' || s === 'rouge' || s === 'bamboo' || s === 'aurora' || s === 'deepsea' ||
11343
+ s === 'sunset' || s === 'forest' || s === 'champagne' || s === 'lavender' || s === 'mint' ||
11344
+ s === 'lava' || s === 'galaxy' || s === 'ink' || s === 'indigo'
11345
+ }
11346
+ function bubblePeakRowClearClass(el, prefix) {
11347
+ try {
11348
+ var cls = Array.prototype.slice.call(el.classList || [])
11349
+ for (var i = 0; i < cls.length; i++) {
11350
+ if (String(cls[i]).indexOf(prefix) === 0) { try { el.classList.remove(cls[i]) } catch (err) {} }
11351
+ }
11352
+ } catch (err) {}
11353
+ }
11354
+ // 原地改写一行峰谷:顺序与 blockOf() 的峰谷分支一致(底色 → 文字跑马灯 → 纯色)
11355
+ function bubblePeakRowApply(x, peak) {
11356
+ var m = x.mod
11357
+ try { x.tx.textContent = bubbleContentText(m, bubblePeakText(m)) } catch (err) {}
11358
+ // ① 底色(高峰底色 / 空闲底色,跑马灯优先于纯色)
11359
+ try {
11360
+ var effBgRgb = peak ? String(m.peakBgRgb || '') : String(m.offBgRgb || '')
11361
+ var effBg = effBgRgb ? '' : (peak ? String(m.peakBg || '') : String(m.offBg || ''))
11362
+ if (effBgRgb === 'true') effBgRgb = 'macaron'
11363
+ bubblePeakRowClearClass(x.row, 'dshwv-bgrgb')
11364
+ x.row.style.background = ''
11365
+ if (effBgRgb) {
11366
+ x.row.classList.add('dshwv-bgrgb')
11367
+ if (bubbleRgbSchemeOk(effBgRgb) || effBgRgb === 'macaron') x.row.classList.add('dshwv-bgrgb-' + effBgRgb)
11368
+ x.row.style.animationDuration = bubbleMarqueeDur()
11369
+ } else if (effBg) {
11370
+ x.row.style.background = effBg
11371
+ }
11372
+ } catch (err) {}
11373
+ // ② 文字:本状态跑马灯 > 模块跑马灯 > 本状态纯色 > 模块纯色
11374
+ try {
11375
+ var marquee = (peak ? String(m.peakRgb || '') : String(m.offRgb || '')) || String(m.rgb || '')
11376
+ bubblePeakRowClearClass(x.gradEl, 'dshwv-rgb')
11377
+ x.row.style.color = ''
11378
+ if (marquee) {
11379
+ var scheme = marquee === true ? 'macaron' : String(marquee || 'macaron')
11380
+ x.gradEl.classList.add('dshwv-rgb')
11381
+ if (bubbleRgbSchemeOk(scheme)) x.gradEl.classList.add('dshwv-rgb-' + scheme)
11382
+ x.gradEl.style.animationDuration = bubbleMarqueeDur()
11383
+ } else {
11384
+ var pcol = peak ? String(m.peakColor || '') : String(m.offColor || '')
11385
+ if (pcol) x.row.style.color = pcol
11386
+ else if (m.color) x.row.style.color = String(m.color)
11387
+ }
11388
+ } catch (err) {}
11389
+ }
10900
11390
  // v209: 计算单个模块要显示的行文本(与随机选中行),每次全新计算、不跨行复用状态
10901
11391
  function bubbleRowContentOf(mod) {
10902
11392
  mod = mod || {}
@@ -10906,7 +11396,7 @@ function bubbleRowContentOf(mod) {
10906
11396
  return { txt: bubbleContentText(mod, bv), line: null }
10907
11397
  }
10908
11398
  if (mod.type === 'today') {
10909
- var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
11399
+ var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
10910
11400
  return { txt: bubbleContentText(mod, '今日已用 ' + tv2), line: null }
10911
11401
  }
10912
11402
  if (mod.type === 'quota') {
@@ -11049,7 +11539,7 @@ function bubbleRowsTo(parentEl, mods) {
11049
11539
  row.style.background = effBg
11050
11540
  }
11051
11541
  }
11052
- return { el: row, tx: tx, fSize: fSize, mod: m, peak: (m.type === 'peak' || m.type === 'nextpeak'), bg: needBg }
11542
+ return { el: row, tx: tx, fSize: fSize, mod: m, peak: (m.type === 'peak' || m.type === 'nextpeak'), bg: needBg, curPeak: curPeak }
11053
11543
  }
11054
11544
  // 超宽判定(与旧版一致):先单行渲染,测量实际超出泡泡内宽(560u)才允许该块内折行——
11055
11545
  // 预览与真实共用此逻辑,保证两者一致
@@ -11072,6 +11562,8 @@ function bubbleRowsTo(parentEl, mods) {
11072
11562
  // 倒计时块:注册文字节点(带底色时为内层 span),由每秒 ticker 刷新文案与配色
11073
11563
  function registerIfCountdown(blk) {
11074
11564
  if (bubbleIsPeakCount(blk.mod)) bubbleCountdownRegister(blk.tx, blk.mod)
11565
+ // v733:非 count 的峰谷行登记到同一个 ticker,状态切换时原地刷新(原来只在渲染时取一次)
11566
+ else if (blk.mod && blk.mod.type === 'peak') bubblePeakRowRegister(blk.el, blk.tx, blk.mod, blk.curPeak)
11075
11567
  }
11076
11568
  // 超链接模块:仅真实泡泡(textBox)内可点击,新标签页打开;预览/编辑不弹窗
11077
11569
  function enableLinkRun(blk2) {
@@ -11479,7 +11971,7 @@ function render() {
11479
11971
  hint = '加载中…'
11480
11972
  } else {
11481
11973
  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) : '--')
11974
+ hint = (state.usageLabel || '今日已用') + ' ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
11483
11975
  }
11484
11976
  amountEl.textContent = amount
11485
11977
  if (bubbleRandomActive && bubbleRandomLines) {
@@ -11508,18 +12000,43 @@ function settle() {
11508
12000
  express()
11509
12001
  return
11510
12002
  }
12003
+ // issue #102:锚点分支过去只有下限(左/顶锚甚至完全不夹),脏锚点或尺寸竞态会把挂件算到
12004
+ // 视口外 —— 症状是「启动闪一下 → 向下滑出/镜像 → 消失」,而且 saveConfig 会把负的离边距离
12005
+ // 写回 localStorage,于是刷新也恢复不了。四个锚点分支统一夹到可视区。
12006
+ var maxLFree = Math.max(0, vp.w - w - rightGap())
12007
+ var maxLAnchor = Math.max(0, vp.w - w)
12008
+ var maxT = Math.max(0, vp.h - h)
12009
+ var outOfRange = false
11511
12010
  if (state.h === 'right') {
11512
- state.left = Math.max(0, vp.w - w - state.hOff - rightGap())
12011
+ var rawR = vp.w - w - state.hOff - rightGap()
12012
+ state.left = clamp(rawR, 0, maxLAnchor)
12013
+ if (state.left !== rawR) outOfRange = true
11513
12014
  } else if (state.h === 'left') {
11514
- state.left = state.hOff
12015
+ var rawL = state.hOff
12016
+ state.left = clamp(rawL, 0, maxLAnchor)
12017
+ if (state.left !== rawL) outOfRange = true
11515
12018
  } 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)
12019
+ state.left = clamp(state.left, 0, maxLFree)
12020
+ }
12021
+ if (state.v === 'bottom') {
12022
+ var rawB = vp.h - h - state.vOff
12023
+ state.top = clamp(rawB, 0, maxT)
12024
+ if (state.top !== rawB) outOfRange = true
11519
12025
  } else if (state.v === 'top') {
11520
- state.top = state.vOff
12026
+ var rawT = state.vOff
12027
+ state.top = clamp(rawT, 0, maxT)
12028
+ if (state.top !== rawT) outOfRange = true
11521
12029
  } else {
11522
- state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
12030
+ state.top = clamp(state.top, 0, maxT)
12031
+ }
12032
+ // 偏移确实越界(脏数据 / 视口变小):先把偏移夹回合法范围再落盘,下次启动不再复现。
12033
+ // 只在真的越界时写,正常 resize 不会产生额外的 localStorage 写入。
12034
+ if (outOfRange) {
12035
+ if (state.h === 'right') state.hOff = clamp(state.hOff, 0, Math.max(0, vp.w - w - rightGap()))
12036
+ else if (state.h === 'left') state.hOff = clamp(state.hOff, 0, maxLAnchor)
12037
+ if (state.v === 'bottom') state.vOff = clamp(state.vOff, 0, maxT)
12038
+ else if (state.v === 'top') state.vOff = clamp(state.vOff, 0, maxT)
12039
+ try { saveAnchorPos() } catch (err) {}
11523
12040
  }
11524
12041
  refreshFlip()
11525
12042
  }
@@ -11602,7 +12119,7 @@ function refresh(manual) {
11602
12119
  ctrl = new AbortController()
11603
12120
  timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
11604
12121
  } catch (err) {}
11605
- fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
12122
+ fetch(BALANCE_URL + (manual ? '?refresh=1' : ''), { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
11606
12123
  .then(function (r) { return r.json() })
11607
12124
  .then(function (data) {
11608
12125
  if (data && data.ok) {
@@ -11614,6 +12131,9 @@ function refresh(manual) {
11614
12131
  state.currency = nc
11615
12132
  state.message = ''
11616
12133
  state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
12134
+ state.todayUsageCurrency = data.todayUsageCurrency || data.currency || 'CNY'
12135
+ state.usageLabel = data.usageLabel || '本地估算'
12136
+ if (data.stale) state.usageLabel += ' · 余额未刷新'
11617
12137
  state.isPeak = !!data.isPeak
11618
12138
  checkUsageAlerts(nb, state.todayUsage)
11619
12139
  if (changed && !currencyChanged) {
@@ -11669,27 +12189,90 @@ var costBubbleActive = false
11669
12189
  var scrollGapOn = false
11670
12190
  var scrollGapPx = 17
11671
12191
  var menuBtnHide = false // 主菜单开关:隐藏挂件菜单按钮,改为右键小鲸鱼唤出菜单
12192
+ // —— v734(issue #97 / #88):设置保存的「防覆盖 + 失败可见」——
12193
+ // #97 根因:首次 GET 还没落地就 PUT,会把内存里的默认值整包写进服务端(重启后设置被洗成默认值)。
12194
+ // #88 根因:这个 PUT 以前是 fire-and-forget,服务端 500 / {ok:false} 完全没人读。
12195
+ var configLoaded = false // 首次 GET 应用完成前,一律不 PUT
12196
+ var configSavePending = false // 加载期间被挡下的保存,加载完成后补一次
12197
+ var dshwvToastEl = null
12198
+ var dshwvToastTimer = null
12199
+ // 固定定位的小提示条(自动消失;同一时刻只留一条)
12200
+ function dshwvToast(msg) {
12201
+ try {
12202
+ if (!dshwvToastEl || !dshwvToastEl.parentNode) {
12203
+ var el = document.createElement('div')
12204
+ el.style.cssText = 'position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:2147483600;' +
12205
+ 'max-width:min(560px,calc(100vw - 32px));box-sizing:border-box;padding:10px 14px;border-radius:10px;' +
12206
+ 'background:#8a1f1f;color:#fff;font-size:13px;line-height:1.6;box-shadow:0 6px 20px rgba(0,0,0,.28);' +
12207
+ 'pointer-events:none;text-align:center'
12208
+ document.body.appendChild(el)
12209
+ dshwvToastEl = el
12210
+ }
12211
+ dshwvToastEl.innerHTML = msg
12212
+ if (dshwvToastTimer) clearTimeout(dshwvToastTimer)
12213
+ dshwvToastTimer = setTimeout(function () {
12214
+ try { if (dshwvToastEl && dshwvToastEl.parentNode) dshwvToastEl.parentNode.removeChild(dshwvToastEl) } catch (err) {}
12215
+ dshwvToastEl = null
12216
+ }, 8000)
12217
+ } catch (err) {}
12218
+ }
12219
+ function configSaveFailNotice(detail) {
12220
+ try { console.error('[dsh-whale] 设置保存失败:', detail) } catch (err) {}
12221
+ dshwvToast('⚠ 设置保存失败:' + String(detail || '').slice(0, 120) +
12222
+ '<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
12223
+ }
12224
+ function configPayload() {
12225
+ 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 })
12226
+ }
12227
+ // 真正的 PUT:读响应 → 失败(网络异常 / HTTP!=200 / {ok:false})静默重试一次 → 仍失败才提示
12228
+ function configPut(payload, retried) {
12229
+ return fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload })
12230
+ .then(function (r) {
12231
+ return r.json().catch(function () { return null }).then(function (d) { return { ok: r.ok, status: r.status, d: d } })
12232
+ })
12233
+ .then(function (x) {
12234
+ if (x.ok && (!x.d || x.d.ok !== false)) return true
12235
+ if (!retried) return new Promise(function (res) { setTimeout(function () { res(configPut(payload, true)) }, 900) })
12236
+ configSaveFailNotice((x.d && x.d.error) || ('HTTP ' + x.status))
12237
+ return false
12238
+ })
12239
+ .catch(function (err) {
12240
+ if (!retried) return new Promise(function (res) { setTimeout(function () { res(configPut(payload, true)) }, 900) })
12241
+ configSaveFailNotice((err && err.message) || err)
12242
+ return false
12243
+ })
12244
+ }
11672
12245
  function saveConfig() {
12246
+ // issue #97:加载完成前只记待办,绝不 PUT(否则把默认值整包写进服务端)
12247
+ if (!configLoaded) { configSavePending = true; return }
11673
12248
  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 }) })
12249
+ configPut(configPayload(), false)
11675
12250
  // 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
11676
- // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
12251
+ saveAnchorPos()
12252
+ } catch (err) {}
12253
+ }
12254
+ // 单独抽出:只写 localStorage 锚点(不碰尺寸设置);applyAnchorPos / settle 自愈时也要用。
12255
+ // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
12256
+ function saveAnchorPos() {
12257
+ try {
11677
12258
  var vp = viewport()
11678
12259
  var w = root.offsetWidth || root.getBoundingClientRect().width || 0
11679
12260
  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
12261
+ var leftDist = isFinite(state.left) ? state.left : 0
12262
+ var rightDist = vp.w - leftDist - w
12263
+ var topDist = isFinite(state.top) ? state.top : 0
12264
+ var bottomDist = vp.h - topDist - h
11684
12265
  var hAnchor = leftDist <= rightDist ? 'left' : 'right'
11685
- var hDistRaw = Math.round(Math.min(leftDist, rightDist))
12266
+ // issue #102:离边距离必须非负。挂件一旦被算到屏幕外,min(leftDist, rightDist) 就是负数,
12267
+ // 存进去会变成"永久坏锚点",此后每次启动都复现(刷新也恢复不了)。
12268
+ var hDistRaw = Math.max(0, Math.round(Math.min(leftDist, rightDist)))
11686
12269
  var hDist = hAnchor === 'right' && scrollGapOn ? Math.max(0, hDistRaw - rightGap()) : hDistRaw
11687
12270
  localStorage.setItem('dshw-pos', JSON.stringify({
11688
12271
  v: 2,
11689
12272
  hAnchor: hAnchor,
11690
12273
  hDist: hDist,
11691
12274
  vAnchor: topDist <= bottomDist ? 'top' : 'bottom',
11692
- vDist: Math.round(Math.min(topDist, bottomDist))
12275
+ vDist: Math.max(0, Math.round(Math.min(topDist, bottomDist)))
11693
12276
  }))
11694
12277
  } catch (err) {}
11695
12278
  }
@@ -11818,6 +12401,11 @@ var releasePlayed = false
11818
12401
  var releaseTimer = null
11819
12402
  function applySoundSet() {
11820
12403
  try {
12404
+ // v729:切音效组 / 开关音效时把本轮播放状态一并复位,
12405
+ // 避免残留 releasePlayed=true 把新组的松开音整体吃掉(与 playPress 的修复配套)
12406
+ if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12407
+ pressEnded = false
12408
+ releasePlayed = false
11821
12409
  // 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
11822
12410
  var pEmpty = audioGroupSlotEmpty(soundSet, 'press')
11823
12411
  var rEmpty = audioGroupSlotEmpty(soundSet, 'release')
@@ -11835,19 +12423,23 @@ function applySoundSet() {
11835
12423
  }
11836
12424
  function playPress() {
11837
12425
  if (!soundOn) return
12426
+ // v729 修复:**本轮状态复位必须放在「按压槽留空」分支之前**。
12427
+ // 原实现里 pressAudio 为空时直接 return,跳过了 releasePlayed = false;而 playRelease()
12428
+ // 一旦把 releasePlayed 置为 true 就再没有任何地方复位它 → 结果是只有第一次松开有声音,
12429
+ // 之后每次点击都静音(用户实测:新建音效组只填松开音时复现)。
12430
+ if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12431
+ if (releaseAudio) {
12432
+ releaseAudio.pause()
12433
+ releaseAudio.currentTime = 0
12434
+ }
12435
+ pressEnded = false
12436
+ releasePlayed = false
11838
12437
  if (!pressAudio) {
11839
- // 按压槽留空:按压事件静音,但“按压已结束”标记保持同步,松开时若松开槽有声仍会响
12438
+ // 按压槽留空:按压事件静音,但状态已复位 → 每次松开都还能正常发声
11840
12439
  pressEnded = true
11841
12440
  return
11842
12441
  }
11843
12442
  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
12443
  pressAudio.onended = function () {
11852
12444
  pressEnded = true
11853
12445
  // fallback (duration unknown): click → Ya2 right after Ya1 ends
@@ -13838,19 +14430,27 @@ function applyAnchorPos() {
13838
14430
  var vp = viewport()
13839
14431
  var w = root.offsetWidth || root.getBoundingClientRect().width || 0
13840
14432
  var h = root.offsetHeight || root.getBoundingClientRect().height || 0
14433
+ var maxOffH = Math.max(0, vp.w - w)
14434
+ var maxOffV = Math.max(0, vp.h - h)
14435
+ // issue #102 自愈:非法距离(负数 = 存进去时挂件已在屏幕外;超出视口 = 窗口变小/脏数据)
14436
+ // 一律夹回合法范围并把修正结果落盘 —— 老用户中了脏数据也能自己恢复,无需手清 localStorage。
14437
+ var hDist = isFinite(a.hDist) ? clamp(a.hDist, 0, maxOffH) : 0
14438
+ var vDist = isFinite(a.vDist) ? clamp(a.vDist, 0, maxOffV) : 0
14439
+ var healed = (hDist !== a.hDist || vDist !== a.vDist)
13841
14440
  // 与加载恢复一致:锚点存净距离,右锚点按当前避让开关叠加
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))
14441
+ var effectiveRightDist = a.hAnchor === 'right' ? hDist + (scrollGapOn ? rightGap() : 0) : hDist
14442
+ var l = a.hAnchor === 'left' ? hDist : vp.w - effectiveRightDist - w
14443
+ var t = a.vAnchor === 'top' ? vDist : vp.h - vDist - h
14444
+ state.left = clamp(l, 0, maxOffH)
14445
+ state.top = clamp(t, 0, maxOffV)
13847
14446
  state.h = a.hAnchor
13848
14447
  // 净距离直接还给 hOff/vOff:settle() 对锚定状态从偏移量重算,
13849
14448
  // 若置 0 会把刚恢复的距离覆盖成贴边(issue #43)
13850
- state.hOff = a.hDist
14449
+ state.hOff = hDist
13851
14450
  state.v = a.vAnchor
13852
- state.vOff = a.vDist
14451
+ state.vOff = vDist
13853
14452
  refreshFlip()
14453
+ if (healed) { try { saveAnchorPos() } catch (err) {} }
13854
14454
  return true
13855
14455
  } catch (err) { return false }
13856
14456
  }
@@ -13949,33 +14549,25 @@ fetch(SIZE_URL, { cache: 'no-store' })
13949
14549
  }
13950
14550
  // 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
13951
14551
  // 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
13952
- // 恢复时还原吸附状态(hAnchor/vAnchor → state.h/v),避免挂件变自由位置
13953
- // 导致避让调节不实时(settle 自由分支只 clamp 不重算位置)。
14552
+ // issue #102:这里原与 applyAnchorPos() 各写了一份恢复逻辑(两份都只做下限夹紧),
14553
+ // 现在统一走 applyAnchorPos(),避免"只修一处、另一处仍复现"。
13954
14554
  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
14555
+ if (applyAnchorPos()) {
13973
14556
  settle()
14557
+ // 恢复时 offsetWidth 可能还是 0(字体/图片尚未就绪),尺寸就绪后再夹一次(issue #102)
14558
+ setTimeout(function () { try { settle() } catch (err) {} }, 500)
13974
14559
  }
13975
14560
  } catch (err) {}
13976
14561
  refresh(false)
14562
+ // v734(issue #97):首次 GET 应用完成 —— 从这一刻起才允许 saveConfig() 落盘
14563
+ configLoaded = true
14564
+ if (configSavePending) { configSavePending = false; try { saveConfig() } catch (err) {} }
14565
+ })
14566
+ .catch(function () {
14567
+ // 读取失败:绝不能拿内存里的默认值去 PUT(那正是「设置被洗成默认值」)
14568
+ refresh(false)
14569
+ dshwvToast('⚠ 设置读取失败,已暂停保存以免覆盖你的原有设置<br>请刷新页面重试')
13977
14570
  })
13978
- .catch(function () { refresh(false) })
13979
14571
  setInterval(function () { refresh(false); try { loadApiModels(null, true) } catch (err) {} }, REFRESH_MS)
13980
14572
 
13981
14573
  // —— 每轮对话消耗检测:轮询 last-turn.json,出现新 seq 时弹消耗金额泡泡 ——