dsh-usage-mini 0.2.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.
- package/LICENSE +9 -0
- package/README.md +136 -0
- package/README.zh-CN.md +136 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1495 -0
- package/lib/index.js +4 -0
- package/package.json +63 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-mini — floating usage mini-window for the dsh web GUI, with a
|
|
3
|
+
* control card in the DSH Settings page (显示/隐藏开关 + 立即刷新).
|
|
4
|
+
*
|
|
5
|
+
* Content rules (per user request):
|
|
6
|
+
* - Subscription models (ChatGPT/Codex, Claude via dsh-plugin-subscriptions):
|
|
7
|
+
* show each usage window's used/remaining percent and the next reset time.
|
|
8
|
+
* NEVER show balance or today's money spend here.
|
|
9
|
+
* - API-billed DeepSeek Flash (official open platform): show today's spend
|
|
10
|
+
* (money) and the account balance. Uses dsh-cost-meter's ledger + official
|
|
11
|
+
* balance cache through the Typert HTTP gateway.
|
|
12
|
+
*
|
|
13
|
+
* Data transport is plain same-origin POST (no ctx services for data):
|
|
14
|
+
* - Typert unary: POST /api/<service>/<method>
|
|
15
|
+
* - Subscriptions: POST /subscriptions-auth/<endpoint> (Connection RPC)
|
|
16
|
+
* Both answer the client-request envelope:
|
|
17
|
+
* { type:'client-request', rpcId, method, payload } →
|
|
18
|
+
* { type:'server-response', rpcId, result:{ ok:true, value } | { ok:false, error } }
|
|
19
|
+
*
|
|
20
|
+
* The DSH Settings card is registered through the standard `settings.section`
|
|
21
|
+
* slot (ctx.slots), same mechanism cost-meter/subscriptions use.
|
|
22
|
+
*/
|
|
23
|
+
window.__ModuleLoader__.load({
|
|
24
|
+
id: 'dsh-usage-mini',
|
|
25
|
+
factory: (require) => {
|
|
26
|
+
var module = { exports: {} }
|
|
27
|
+
var exports = module.exports
|
|
28
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
29
|
+
|
|
30
|
+
const React = require('react')
|
|
31
|
+
|
|
32
|
+
const UID = 'dsh-usage-mini'
|
|
33
|
+
const LS_UI = 'dsh-usage-mini:ui'
|
|
34
|
+
const AUTO_REFRESH_MS = 60 * 1000
|
|
35
|
+
const EVT_SET = 'dsh-usage-mini:set' // { visible?: boolean }
|
|
36
|
+
const EVT_REFRESH = 'dsh-usage-mini:refresh' // { force?: boolean }
|
|
37
|
+
const EVT_LOCALE = 'dsh-usage-mini:locale' // fired after the host <html lang> changes
|
|
38
|
+
|
|
39
|
+
// ── feedback (opens a pre-filled GitHub issue; no network call from here) ──
|
|
40
|
+
const PLUGIN_VERSION = '0.2.2'
|
|
41
|
+
const FEEDBACK_REPO = 'teethyachi/dsh-usage-mini'
|
|
42
|
+
const FEEDBACK_MAX_CHARS = 500
|
|
43
|
+
const FEEDBACK_COOLDOWN_MS = 60 * 1000
|
|
44
|
+
const LS_FEEDBACK = 'dsh-usage-mini:feedback'
|
|
45
|
+
|
|
46
|
+
// ── locale ───────────────────────────────────────────────────────────────
|
|
47
|
+
// The plugin follows the host: DSH Web sets <html lang> to "zh-CN" or "en"
|
|
48
|
+
// and updates it live when the user switches Settings → Language. No own
|
|
49
|
+
// preference, no own switch. Fallback chain: <html lang> → navigator.language → en.
|
|
50
|
+
|
|
51
|
+
const STR = {
|
|
52
|
+
zh: {
|
|
53
|
+
title: '用量小窗',
|
|
54
|
+
feedbackPrompt: '给点儿意见?',
|
|
55
|
+
feedbackAria: '反馈',
|
|
56
|
+
feedbackSend: '发送',
|
|
57
|
+
feedbackSendTitle: '在 GitHub 打开预填好的反馈(需 GitHub 登录)',
|
|
58
|
+
feedbackEmpty: '写一句就行,哪怕只是「这里看不懂」。',
|
|
59
|
+
feedbackWait: (s) => `慢一点,${s} 秒后再发。`,
|
|
60
|
+
feedbackOpened: '已在 GitHub 打开,点「Submit」即可。谢谢!',
|
|
61
|
+
feedbackBlocked: '弹窗被拦截:',
|
|
62
|
+
feedbackOpenHere: '点这里打开',
|
|
63
|
+
feedbackTitlePrefix: '[反馈] ',
|
|
64
|
+
rpcMissing: (s) => `RPC 通道不存在 (HTTP ${s})`,
|
|
65
|
+
rpcBadEnvelope: 'RPC 响应异常',
|
|
66
|
+
rpcFailed: 'RPC 调用失败',
|
|
67
|
+
resetNow: '即将重置',
|
|
68
|
+
resetUnderMinute: '约 1 分钟内重置',
|
|
69
|
+
resetMinutes: (m) => `约 ${m} 分钟后重置`,
|
|
70
|
+
resetHoursMinutes: (h, m) => `约 ${h} 小时 ${m} 分后重置`,
|
|
71
|
+
resetHours: (h) => `约 ${h} 小时后重置`,
|
|
72
|
+
resetDaysHours: (d, h) => `约 ${d} 天 ${h} 小时后重置`,
|
|
73
|
+
resetDays: (d) => `约 ${d} 天后重置`,
|
|
74
|
+
costMeterEmpty: 'cost-meter 返回了空状态',
|
|
75
|
+
refreshNow: '立即刷新',
|
|
76
|
+
minimize: '最小化收起',
|
|
77
|
+
headTitle: '拖拽移动 · 60 秒自动刷新',
|
|
78
|
+
secSubs: '订阅模型',
|
|
79
|
+
secSubsTag: '仅用量与重置 · 不显示余额/花费',
|
|
80
|
+
secApi: 'DeepSeek Flash(官方 API)',
|
|
81
|
+
tabTitle: '点我展开用量小窗',
|
|
82
|
+
tabExpand: '展开用量小窗',
|
|
83
|
+
tipSubsUnknown: '订阅用量未知',
|
|
84
|
+
tipSubsCritical: (p) => `订阅用量即将用尽:已用 ${p}%`,
|
|
85
|
+
tipSubsTight: (p) => `订阅额度偏紧:已用 ${p}%`,
|
|
86
|
+
tipSubsOk: (p) => `订阅用量正常:最高已用 ${p}%`,
|
|
87
|
+
tipApiToday: (v) => `DeepSeek 今日 ${v}`,
|
|
88
|
+
loading: '加载中…',
|
|
89
|
+
subsNotSignedIn: '未登录订阅账户(设置 → Subscriptions 登录 ChatGPT/Claude)',
|
|
90
|
+
subsQueryFailed: (e) => `用量查询失败:${e}`,
|
|
91
|
+
subsNoWindows: '该账户未返回用量窗口',
|
|
92
|
+
subsNoData: '暂无用量窗口数据',
|
|
93
|
+
winSession: '5 小时窗口',
|
|
94
|
+
winWeekly: '本周窗口',
|
|
95
|
+
winGeneric: '窗口',
|
|
96
|
+
used: ' 已用',
|
|
97
|
+
remaining: (p) => `剩余 ${p}%`,
|
|
98
|
+
resetAt: (d) => ` · 重置 ${d}`,
|
|
99
|
+
resetUnknown: ' · 重置时间未知',
|
|
100
|
+
balanceFailed: '余额查询失败(可点刷新重试)',
|
|
101
|
+
balanceUnconfigured: '余额未配置或未启用(今日消耗仍按账本显示)',
|
|
102
|
+
todayCallsTip: (n) => `今日官方调用 ${n} 次`,
|
|
103
|
+
flashTip: (n, v) => `deepseek-v4-flash ${n} 次 / ${v}`,
|
|
104
|
+
todaySpend: '今日消耗(官方)',
|
|
105
|
+
todayCalls: '今日调用',
|
|
106
|
+
callsUnit: (n) => `${n} 次`,
|
|
107
|
+
granted: (v) => `赠送 ${v}`,
|
|
108
|
+
toppedUp: (v) => `充值 ${v}`,
|
|
109
|
+
balanceTotal: '余额(总)',
|
|
110
|
+
updatedAt: (t) => `更新于 ${t}`,
|
|
111
|
+
autoTag: '60s 自动',
|
|
112
|
+
subsChannelMissing: '订阅用量通道不可用(未安装/未启用 dsh-plugin-subscriptions)',
|
|
113
|
+
subsFetchFailed: (e) => `订阅用量获取失败:${e}`,
|
|
114
|
+
apiChannelMissing: '费用/余额通道不可用(未安装/未启用 dsh-cost-meter)',
|
|
115
|
+
apiFetchFailed: (e) => `DeepSeek 用量获取失败:${e}`,
|
|
116
|
+
refreshing: '刷新中…',
|
|
117
|
+
settingsDesc: '右下角用量小窗:订阅模型用量与重置时间、DeepSeek Flash 今日消耗与余额。',
|
|
118
|
+
settingsDescSmall: '订阅区不显示余额与花费;窗口可拖拽/收起。开关状态记录在此浏览器。',
|
|
119
|
+
settingsShow: '显示用量小窗',
|
|
120
|
+
settingsShownHint: '窗口当前显示在右下角',
|
|
121
|
+
settingsHiddenHint: '窗口已隐藏,打开后立即刷新',
|
|
122
|
+
settingsHide: '隐藏小窗',
|
|
123
|
+
settingsShowShort: '显示小窗',
|
|
124
|
+
settingsRefreshHint: '强制重新查询订阅用量与官方余额',
|
|
125
|
+
},
|
|
126
|
+
en: {
|
|
127
|
+
title: 'Usage Window',
|
|
128
|
+
feedbackPrompt: 'Any feedback?',
|
|
129
|
+
feedbackAria: 'Feedback',
|
|
130
|
+
feedbackSend: 'Send',
|
|
131
|
+
feedbackSendTitle: 'Opens a pre-filled GitHub issue (GitHub sign-in required)',
|
|
132
|
+
feedbackEmpty: 'One line is enough, even just "this part is unclear".',
|
|
133
|
+
feedbackWait: (s) => `Easy there — try again in ${s}s.`,
|
|
134
|
+
feedbackOpened: 'Opened on GitHub. Press Submit and you’re done. Thanks!',
|
|
135
|
+
feedbackBlocked: 'Pop-up blocked: ',
|
|
136
|
+
feedbackOpenHere: 'open it here',
|
|
137
|
+
feedbackTitlePrefix: '[feedback] ',
|
|
138
|
+
rpcMissing: (s) => `RPC channel missing (HTTP ${s})`,
|
|
139
|
+
rpcBadEnvelope: 'Malformed RPC response',
|
|
140
|
+
rpcFailed: 'RPC call failed',
|
|
141
|
+
resetNow: 'resets now',
|
|
142
|
+
resetUnderMinute: 'resets in under a minute',
|
|
143
|
+
resetMinutes: (m) => `resets in ~${m} min`,
|
|
144
|
+
resetHoursMinutes: (h, m) => `resets in ~${h}h ${m}m`,
|
|
145
|
+
resetHours: (h) => `resets in ~${h}h`,
|
|
146
|
+
resetDaysHours: (d, h) => `resets in ~${d}d ${h}h`,
|
|
147
|
+
resetDays: (d) => `resets in ~${d}d`,
|
|
148
|
+
costMeterEmpty: 'cost-meter returned an empty state',
|
|
149
|
+
refreshNow: 'Refresh now',
|
|
150
|
+
minimize: 'Minimize',
|
|
151
|
+
headTitle: 'Drag to move · auto-refresh every 60s',
|
|
152
|
+
secSubs: 'Subscriptions',
|
|
153
|
+
secSubsTag: 'usage & resets only · no balance / spend',
|
|
154
|
+
secApi: 'DeepSeek Flash (official API)',
|
|
155
|
+
tabTitle: 'Click to expand the usage window',
|
|
156
|
+
tabExpand: 'Expand usage window',
|
|
157
|
+
tipSubsUnknown: 'Subscription usage unknown',
|
|
158
|
+
tipSubsCritical: (p) => `Subscription almost exhausted: ${p}% used`,
|
|
159
|
+
tipSubsTight: (p) => `Subscription running tight: ${p}% used`,
|
|
160
|
+
tipSubsOk: (p) => `Subscription OK: peak ${p}% used`,
|
|
161
|
+
tipApiToday: (v) => `DeepSeek today ${v}`,
|
|
162
|
+
loading: 'Loading…',
|
|
163
|
+
subsNotSignedIn: 'No subscription signed in (Settings → Subscriptions → sign in to ChatGPT / Claude)',
|
|
164
|
+
subsQueryFailed: (e) => `Usage query failed: ${e}`,
|
|
165
|
+
subsNoWindows: 'This account returned no usage windows',
|
|
166
|
+
subsNoData: 'No usage window data yet',
|
|
167
|
+
winSession: '5-hour window',
|
|
168
|
+
winWeekly: 'Weekly window',
|
|
169
|
+
winGeneric: 'Window',
|
|
170
|
+
used: ' used',
|
|
171
|
+
remaining: (p) => `${p}% left`,
|
|
172
|
+
resetAt: (d) => ` · resets ${d}`,
|
|
173
|
+
resetUnknown: ' · reset time unknown',
|
|
174
|
+
balanceFailed: 'Balance query failed (press refresh to retry)',
|
|
175
|
+
balanceUnconfigured: 'Balance not configured or disabled (today’s spend still comes from the ledger)',
|
|
176
|
+
todayCallsTip: (n) => `${n} official calls today`,
|
|
177
|
+
flashTip: (n, v) => `deepseek-v4-flash ${n} calls / ${v}`,
|
|
178
|
+
todaySpend: 'Today’s spend (official)',
|
|
179
|
+
todayCalls: 'Calls today',
|
|
180
|
+
callsUnit: (n) => `${n}`,
|
|
181
|
+
granted: (v) => `granted ${v}`,
|
|
182
|
+
toppedUp: (v) => `topped up ${v}`,
|
|
183
|
+
balanceTotal: 'Balance (total)',
|
|
184
|
+
updatedAt: (t) => `updated ${t}`,
|
|
185
|
+
autoTag: 'auto 60s',
|
|
186
|
+
subsChannelMissing: 'Subscription usage channel unavailable (dsh-plugin-subscriptions not installed / enabled)',
|
|
187
|
+
subsFetchFailed: (e) => `Subscription usage fetch failed: ${e}`,
|
|
188
|
+
apiChannelMissing: 'Cost / balance channel unavailable (dsh-cost-meter not installed / enabled)',
|
|
189
|
+
apiFetchFailed: (e) => `DeepSeek usage fetch failed: ${e}`,
|
|
190
|
+
refreshing: 'Refreshing…',
|
|
191
|
+
settingsDesc: 'Bottom-right usage window: subscription usage and reset times, DeepSeek Flash spend and balance for today.',
|
|
192
|
+
settingsDescSmall: 'The subscription section never shows balance or spend. Drag / collapse the window; the toggle is remembered in this browser.',
|
|
193
|
+
settingsShow: 'Show usage window',
|
|
194
|
+
settingsShownHint: 'The window is currently shown at the bottom right',
|
|
195
|
+
settingsHiddenHint: 'The window is hidden; it refreshes as soon as it opens',
|
|
196
|
+
settingsHide: 'Hide window',
|
|
197
|
+
settingsShowShort: 'Show window',
|
|
198
|
+
settingsRefreshHint: 'Force a fresh query of subscription usage and the official balance',
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function detectLocale() {
|
|
203
|
+
let tag = ''
|
|
204
|
+
try { tag = (document.documentElement && document.documentElement.lang) || '' } catch { /* noop */ }
|
|
205
|
+
if (!tag) { try { tag = (navigator && navigator.language) || '' } catch { /* noop */ } }
|
|
206
|
+
return /^zh/i.test(String(tag)) ? 'zh' : 'en'
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let locale = 'en'
|
|
210
|
+
|
|
211
|
+
function t(key, ...args) {
|
|
212
|
+
const dict = STR[locale] || STR.en
|
|
213
|
+
const v = dict[key] !== undefined ? dict[key] : STR.en[key]
|
|
214
|
+
if (typeof v === 'function') return v(...args)
|
|
215
|
+
return v === undefined ? key : v
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const PROVIDERS = [
|
|
219
|
+
{ id: 'codex', name: 'Codex (ChatGPT)' },
|
|
220
|
+
{ id: 'claude', name: 'Claude' },
|
|
221
|
+
]
|
|
222
|
+
const OFFICIAL_PROVIDERS = new Set(['deepseek', 'deepseek-official'])
|
|
223
|
+
const FLASH_MODEL = 'deepseek-v4-flash'
|
|
224
|
+
|
|
225
|
+
// Corner-dock geometry.
|
|
226
|
+
const CORNER_GAP = 14 // collapsed bar inset from the screen edge.
|
|
227
|
+
const EXPAND_GAP = 16 // expanded window inset when corner-anchored.
|
|
228
|
+
const CORNER_ID = { bl: 'bl', br: 'br', tl: 'tl', tr: 'tr' }
|
|
229
|
+
|
|
230
|
+
// ── tiny helpers ─────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const num = (v) => {
|
|
233
|
+
const n = Number(v)
|
|
234
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function uuid() {
|
|
238
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
|
239
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
240
|
+
const r = (Math.random() * 16) | 0
|
|
241
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function el(tag, props, ...children) {
|
|
246
|
+
const node = document.createElement(tag)
|
|
247
|
+
if (props) {
|
|
248
|
+
for (const key of Object.keys(props)) {
|
|
249
|
+
const value = props[key]
|
|
250
|
+
if (key === 'class') node.className = value
|
|
251
|
+
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value)
|
|
252
|
+
else if (key === 'title' || key === 'data-tip') node.title = value
|
|
253
|
+
else if (key.startsWith('on') && typeof value === 'function') {
|
|
254
|
+
node.addEventListener(key.slice(2).toLowerCase(), value)
|
|
255
|
+
} else if (value !== null && value !== undefined && value !== false) {
|
|
256
|
+
node.setAttribute(key, value === true ? '' : String(value))
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const child of children) {
|
|
261
|
+
if (child === null || child === undefined || child === false) continue
|
|
262
|
+
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child)
|
|
263
|
+
}
|
|
264
|
+
return node
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function rpcPost(url, method, payload) {
|
|
268
|
+
const rpcId = uuid()
|
|
269
|
+
const res = await fetch(url, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'content-type': 'application/json' },
|
|
272
|
+
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
|
273
|
+
})
|
|
274
|
+
// 404: no plugin registered the path. 405: the host has the path but no
|
|
275
|
+
// handler accepts POST (seen on a bare web profile). Both mean "not installed".
|
|
276
|
+
if (res.status === 404 || res.status === 405) {
|
|
277
|
+
const err = new Error(t('rpcMissing', res.status))
|
|
278
|
+
err.notFound = true
|
|
279
|
+
throw err
|
|
280
|
+
}
|
|
281
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
282
|
+
const env = await res.json()
|
|
283
|
+
if (!env || env.type !== 'server-response' || env.rpcId !== rpcId) throw new Error(t('rpcBadEnvelope'))
|
|
284
|
+
const result = env.result
|
|
285
|
+
if (!result || result.ok !== true) {
|
|
286
|
+
const msg = result && result.error && result.error.message ? result.error.message : t('rpcFailed')
|
|
287
|
+
throw new Error(msg)
|
|
288
|
+
}
|
|
289
|
+
return result.value
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function defaultConfig() {
|
|
293
|
+
return { symbol: '¥', decimals: 4, exchangeRate: 7.2, currency: 'CNY' }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Display a value already in the display/bookkeeping currency (e.g. official balance CNY). */
|
|
297
|
+
function fmtMoneyValue(value, cfg) {
|
|
298
|
+
const symbol = (cfg && typeof cfg.symbol === 'string' && cfg.symbol.length > 0) ? cfg.symbol : '¥'
|
|
299
|
+
const decimals = Math.max(0, Math.min(10, Math.floor(Number(cfg && cfg.decimals) || 2)))
|
|
300
|
+
let effective = decimals
|
|
301
|
+
if (value > 0 && value < Math.pow(10, -decimals)) effective = decimals + 2
|
|
302
|
+
let fixed = value.toFixed(effective)
|
|
303
|
+
if (fixed.includes('.')) fixed = fixed.replace(/0+$/, '').replace(/\.$/, '')
|
|
304
|
+
return symbol + fixed
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Convert an USD ledger amount to the display currency and format it. */
|
|
308
|
+
function fmtUsd(usd, cfg) {
|
|
309
|
+
const rate = Number(cfg && cfg.exchangeRate)
|
|
310
|
+
const value = usd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
|
|
311
|
+
return fmtMoneyValue(value, cfg)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Official balance is already in the bookkeeping currency — no rate conversion. */
|
|
315
|
+
function fmtBalance(value, cfg) {
|
|
316
|
+
return fmtMoneyValue(value, cfg)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function fmtReset(epochMs) {
|
|
320
|
+
if (!Number.isFinite(epochMs) || epochMs <= 0) return { date: '', rel: '' }
|
|
321
|
+
const d = new Date(epochMs)
|
|
322
|
+
const pad = (x) => String(x).padStart(2, '0')
|
|
323
|
+
const date = `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
324
|
+
const diffSec = Math.round((epochMs - Date.now()) / 1000)
|
|
325
|
+
let rel = ''
|
|
326
|
+
if (diffSec <= 0) rel = t('resetNow')
|
|
327
|
+
else if (diffSec < 60) rel = t('resetUnderMinute')
|
|
328
|
+
else if (diffSec < 3600) rel = t('resetMinutes', Math.ceil(diffSec / 60))
|
|
329
|
+
else if (diffSec < 86400) {
|
|
330
|
+
const h = Math.floor(diffSec / 3600)
|
|
331
|
+
const m = Math.round((diffSec % 3600) / 60)
|
|
332
|
+
rel = m > 0 ? t('resetHoursMinutes', h, m) : t('resetHours', h)
|
|
333
|
+
} else {
|
|
334
|
+
const days = Math.floor(diffSec / 86400)
|
|
335
|
+
const h = Math.round((diffSec % 86400) / 3600)
|
|
336
|
+
rel = h > 0 ? t('resetDaysHours', days, h) : t('resetDays', days)
|
|
337
|
+
}
|
|
338
|
+
return { date, rel }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function barColor(usedPercent) {
|
|
342
|
+
if (usedPercent >= 95) return 'var(--dsw-alias-state-error-primary, #f85149)'
|
|
343
|
+
if (usedPercent >= 80) return 'var(--dsw-alias-state-warn-primary, #f5a623)'
|
|
344
|
+
return 'var(--dsw-alias-state-success-primary, #3fb950)'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function clockText(date) {
|
|
348
|
+
const pad = (x) => String(x).padStart(2, '0')
|
|
349
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── preferences (per browser; visible default ON) ────────────────────────
|
|
353
|
+
|
|
354
|
+
let uiPrefs = { visible: true, collapsed: false, collapsedCorner: 'br', expand: { mode: 'edge', corner: 'br' }, left: null, top: null }
|
|
355
|
+
|
|
356
|
+
function loadPrefs() {
|
|
357
|
+
try {
|
|
358
|
+
const raw = window.localStorage.getItem(LS_UI)
|
|
359
|
+
if (raw) {
|
|
360
|
+
const parsed = JSON.parse(raw)
|
|
361
|
+
const exp = parsed.expand && typeof parsed.expand === 'object' ? parsed.expand : {}
|
|
362
|
+
uiPrefs = {
|
|
363
|
+
visible: typeof parsed.visible === 'boolean' ? parsed.visible : true,
|
|
364
|
+
collapsed: parsed.collapsed === true,
|
|
365
|
+
collapsedCorner: typeof parsed.collapsedCorner === 'string' && parsed.collapsedCorner in CORNER_ID
|
|
366
|
+
? parsed.collapsedCorner
|
|
367
|
+
: 'br',
|
|
368
|
+
expand: {
|
|
369
|
+
mode: exp.mode === 'custom' ? 'custom' : 'edge',
|
|
370
|
+
corner: typeof exp.corner === 'string' && exp.corner in CORNER_ID ? exp.corner : 'br',
|
|
371
|
+
left: typeof exp.left === 'number' && Number.isFinite(exp.left) ? exp.left : null,
|
|
372
|
+
top: typeof exp.top === 'number' && Number.isFinite(exp.top) ? exp.top : null,
|
|
373
|
+
},
|
|
374
|
+
left: typeof parsed.left === 'number' && Number.isFinite(parsed.left) ? parsed.left : null,
|
|
375
|
+
top: typeof parsed.top === 'number' && Number.isFinite(parsed.top) ? parsed.top : null,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch { /* storage unavailable: keep defaults */ }
|
|
379
|
+
return uiPrefs
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function savePrefs() {
|
|
383
|
+
try { window.localStorage.setItem(LS_UI, JSON.stringify(uiPrefs)) } catch { /* ignore */ }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function setVisiblePref(visible) {
|
|
387
|
+
uiPrefs.visible = visible === true
|
|
388
|
+
savePrefs()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── feedback helpers ─────────────────────────────────────────────────────
|
|
392
|
+
// Pure: builds the GitHub "new issue" URL. The only user-derived payload is
|
|
393
|
+
// the trimmed feedback text; the rest is the plugin version. Never include
|
|
394
|
+
// usage numbers, balances, account identifiers or tokens.
|
|
395
|
+
|
|
396
|
+
function buildFeedbackUrl(text, version) {
|
|
397
|
+
const body = String(text || '').trim().slice(0, FEEDBACK_MAX_CHARS)
|
|
398
|
+
if (!body) return null
|
|
399
|
+
const ver = String(version || PLUGIN_VERSION)
|
|
400
|
+
const params = new URLSearchParams()
|
|
401
|
+
params.set('template', 'feedback.yml')
|
|
402
|
+
params.set('labels', 'feedback')
|
|
403
|
+
params.set('title', t('feedbackTitlePrefix') + body.split('\n')[0].slice(0, 60))
|
|
404
|
+
params.set('feedback', body)
|
|
405
|
+
params.set('version', ver)
|
|
406
|
+
return 'https://github.com/' + FEEDBACK_REPO + '/issues/new?' + params.toString()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function loadFeedbackState() {
|
|
410
|
+
try {
|
|
411
|
+
const raw = window.localStorage.getItem(LS_FEEDBACK)
|
|
412
|
+
if (raw) {
|
|
413
|
+
const p = JSON.parse(raw)
|
|
414
|
+
return {
|
|
415
|
+
lastAt: typeof p.lastAt === 'number' ? p.lastAt : 0,
|
|
416
|
+
count: typeof p.count === 'number' ? p.count : 0,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch { /* ignore */ }
|
|
420
|
+
return { lastAt: 0, count: 0 }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function saveFeedbackState(s) {
|
|
424
|
+
try { window.localStorage.setItem(LS_FEEDBACK, JSON.stringify(s)) } catch { /* ignore */ }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── styles ────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
const css = `
|
|
430
|
+
#${UID}-root{position:fixed;right:16px;bottom:16px;z-index:2147483000;width:min(302px,calc(100vw - 24px));
|
|
431
|
+
font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--dsw-alias-label-primary,#e6e9f0);
|
|
432
|
+
background:var(--dsw-alias-bg-layer-2,rgba(22,24,30,.94));border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.09));
|
|
433
|
+
border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.35);backdrop-filter:blur(8px);user-select:none;overflow:hidden}
|
|
434
|
+
#${UID}-root *{box-sizing:border-box}
|
|
435
|
+
.um-head{display:flex;align-items:center;gap:6px;height:30px;padding:0 8px 0 10px;cursor:grab;border-bottom:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.06))}
|
|
436
|
+
.um-head:active{cursor:grabbing}
|
|
437
|
+
.um-title{font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary,#e6e9f0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
|
438
|
+
.um-title .dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary,#3fb950);margin-right:6px;vertical-align:1px}
|
|
439
|
+
.um-btn{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#c3c9d6);cursor:pointer;font-size:12px;line-height:1;padding:0}
|
|
440
|
+
.um-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary,#e6e9f0)}
|
|
441
|
+
.um-btn.spin svg{animation:um-spin 0.8s linear infinite}
|
|
442
|
+
@keyframes um-spin{to{transform:rotate(360deg)}}
|
|
443
|
+
.um-body{padding:8px 10px 6px;max-height:min(70vh,560px);overflow:auto}
|
|
444
|
+
.um-body::-webkit-scrollbar{width:6px}
|
|
445
|
+
.um-body::-webkit-scrollbar-thumb{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.1));border-radius:3px}
|
|
446
|
+
.um-sec{margin-bottom:8px}
|
|
447
|
+
.um-sec:last-child{margin-bottom:0}
|
|
448
|
+
.um-sec-title{display:flex;align-items:baseline;gap:6px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#c3c9d6);margin:2px 0 5px}
|
|
449
|
+
.um-sec-title .um-tag{font-size:10px;font-weight:400;color:var(--dsw-alias-label-tertiary,#8b93a7)}
|
|
450
|
+
.um-card{border:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.07));border-radius:9px;background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.025));padding:6px 8px;margin-bottom:6px}
|
|
451
|
+
.um-card:last-child{margin-bottom:0}
|
|
452
|
+
.um-card-head{display:flex;align-items:baseline;gap:6px;font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary,#e6e9f0);margin-bottom:4px}
|
|
453
|
+
.um-card-head .sub{font-weight:400;font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}
|
|
454
|
+
.um-row{display:flex;align-items:center;gap:6px;margin:3px 0 1px}
|
|
455
|
+
.um-row .lbl{flex:none;width:86px;color:var(--dsw-alias-label-secondary,#c3c9d6);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
456
|
+
.um-bar{flex:1;height:5px;border-radius:3px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.09));overflow:hidden}
|
|
457
|
+
.um-fill{height:100%;border-radius:3px}
|
|
458
|
+
.um-pct{flex:none;min-width:46px;text-align:right;font-size:11px;color:var(--dsw-alias-label-secondary,#c3c9d6);font-variant-numeric:tabular-nums}
|
|
459
|
+
.um-row .um-pct strong{color:var(--dsw-alias-label-primary,#e6e9f0)}
|
|
460
|
+
.um-sub{font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);margin:0 0 2px;font-variant-numeric:tabular-nums}
|
|
461
|
+
.um-sub .ok{color:var(--dsw-alias-state-success-primary,#3fb950)}
|
|
462
|
+
.um-kv{display:flex;justify-content:space-between;gap:8px;padding:2px 0;font-size:11px}
|
|
463
|
+
.um-kv .k{color:var(--dsw-alias-label-secondary,#c3c9d6)}
|
|
464
|
+
.um-kv .v{color:var(--dsw-alias-label-primary,#e6e9f0);font-variant-numeric:tabular-nums;font-weight:600}
|
|
465
|
+
.um-kv .v.dim{color:var(--dsw-alias-label-tertiary,#8b93a7);font-weight:400}
|
|
466
|
+
.um-note{font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);padding:2px 0;line-height:1.5}
|
|
467
|
+
.um-note.err{color:var(--dsw-alias-state-error-primary,#f85149)}
|
|
468
|
+
.um-note.warn{color:var(--dsw-alias-state-warn-primary,#f5a623)}
|
|
469
|
+
.um-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);padding:4px 0 0;border-top:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.05));margin-top:2px}
|
|
470
|
+
/* ---- minimized: shrink down into a slim bar (plugin name + expand key) ---- */
|
|
471
|
+
#${UID}-root{--um-tab-dot:#3fb950}
|
|
472
|
+
#${UID}-root.collapsed{left:auto;top:auto;right:auto;bottom:auto;width:auto;height:auto;background:var(--dsw-alias-bg-layer-2,rgba(22,24,30,.95));border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.10));border-radius:999px;box-shadow:0 6px 18px rgba(0,0,0,.35);backdrop-filter:blur(8px);overflow:hidden}
|
|
473
|
+
#${UID}-root.collapsed .um-head,#${UID}-root.collapsed .um-body,#${UID}-root.collapsed .um-foot{display:none}
|
|
474
|
+
#${UID}-root .um-tab{display:none}
|
|
475
|
+
#${UID}-root.collapsed .um-tab{display:flex}
|
|
476
|
+
.um-tab{align-items:center;gap:8px;height:34px;padding:0 6px 0 13px;cursor:grab;white-space:nowrap;box-sizing:border-box}
|
|
477
|
+
.um-tab:active{cursor:grabbing}
|
|
478
|
+
.um-tab-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--um-tab-dot,#3fb950);animation:um-blink 1.9s ease-in-out infinite}
|
|
479
|
+
@keyframes um-blink{0%,100%{opacity:1}50%{opacity:.3}}
|
|
480
|
+
.um-tab-name{flex:none;font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary,#e6e9f0);line-height:1}
|
|
481
|
+
.um-tab-open{flex:none;position:relative;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;border:0;color:#fff;cursor:pointer;background:radial-gradient(120% 130% at 28% 18%,rgba(255,255,255,.32),rgba(255,255,255,0) 46%),linear-gradient(155deg,var(--dsw-alias-brand-primary,#4c8dff),#2e5fd7);box-shadow:0 0 8px rgba(80,140,255,.55),0 2px 6px rgba(10,20,60,.35);transition:transform .15s ease,box-shadow .15s ease;animation:um-breathe 2.4s ease-in-out infinite}
|
|
482
|
+
.um-tab-open:hover{transform:scale(1.14);box-shadow:0 0 14px rgba(95,155,255,.95),0 2px 8px rgba(10,20,60,.4)}
|
|
483
|
+
.um-tab-open:active{transform:scale(.94)}
|
|
484
|
+
.um-tab-open svg{width:13px;height:13px}
|
|
485
|
+
@keyframes um-breathe{0%,100%{box-shadow:0 0 6px rgba(80,140,255,.4),0 2px 6px rgba(10,20,60,.3)}50%{box-shadow:0 0 15px rgba(95,155,255,.95),0 2px 7px rgba(10,20,60,.38)}}
|
|
486
|
+
/* ---- feedback bar (expanded window only) ---- */
|
|
487
|
+
.um-fb{display:flex;align-items:center;gap:6px;padding:6px 0 0;margin-top:6px;border-top:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.05))}
|
|
488
|
+
.um-fb-input{flex:1;min-width:0;height:24px;padding:0 8px;border-radius:7px;border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.10));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,#e6e9f0);font:11px/1 inherit;font-family:inherit;outline:none;user-select:text}
|
|
489
|
+
.um-fb-input::placeholder{color:var(--dsw-alias-label-tertiary,#8b93a7);font-style:italic;opacity:.8}
|
|
490
|
+
.um-fb-input:focus{border-color:var(--dsw-alias-brand-primary,#4c8dff)}
|
|
491
|
+
.um-fb-send{flex:none;height:24px;padding:0 9px;border-radius:7px;border:0;background:var(--dsw-alias-brand-primary,#4c8dff);color:#fff;font-size:11px;cursor:pointer;white-space:nowrap}
|
|
492
|
+
.um-fb-send:disabled{opacity:.45;cursor:default}
|
|
493
|
+
.um-fb-send:not(:disabled):hover{filter:brightness(1.1)}
|
|
494
|
+
.um-fb-msg{font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);padding:3px 0 0;min-height:14px;line-height:1.4}
|
|
495
|
+
.um-fb-msg.ok{color:var(--dsw-alias-state-success-primary,#3fb950)}
|
|
496
|
+
.um-fb-msg a{color:var(--dsw-alias-brand-primary,#4c8dff);text-decoration:none}
|
|
497
|
+
/* ---- settings card ---- */
|
|
498
|
+
.ums-card{font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--dsw-alias-label-primary,#e6e9f0);max-width:640px}
|
|
499
|
+
.ums-card .ums-desc{color:var(--dsw-alias-label-secondary,#c3c9d6);margin:2px 0 10px}
|
|
500
|
+
.ums-card .ums-desc small{color:var(--dsw-alias-label-tertiary,#8b93a7)}
|
|
501
|
+
.ums-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 0;border-top:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.06))}
|
|
502
|
+
.ums-row:first-of-type{border-top:0}
|
|
503
|
+
.ums-row .ums-main{min-width:0}
|
|
504
|
+
.ums-row .ums-name{font-weight:600;color:var(--dsw-alias-label-primary,#e6e9f0)}
|
|
505
|
+
.ums-row .ums-hint{font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a7)}
|
|
506
|
+
.ums-actions{display:flex;align-items:center;gap:8px;flex:none}
|
|
507
|
+
.ums-switch{position:relative;flex:none;width:34px;height:18px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.12));cursor:pointer;transition:background .15s;border:0;padding:0}
|
|
508
|
+
.ums-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary,#4c8dff)}
|
|
509
|
+
.ums-switch .knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s}
|
|
510
|
+
.ums-switch[aria-checked="true"] .knob{left:18px}
|
|
511
|
+
.ums-btn{flex:none;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:7px;border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.12));background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.04));color:var(--dsw-alias-label-primary,#e6e9f0);cursor:pointer;font-size:12px}
|
|
512
|
+
.ums-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.09))}
|
|
513
|
+
.ums-status{font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a7)}
|
|
514
|
+
`
|
|
515
|
+
|
|
516
|
+
function ensureStyle() {
|
|
517
|
+
if (!document.getElementById(`${UID}-style`)) {
|
|
518
|
+
const style = el('style', { id: `${UID}-style`, 'data-plugin': UID }, css)
|
|
519
|
+
document.head.appendChild(style)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── data fetching ────────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
const view = {
|
|
526
|
+
loading: true,
|
|
527
|
+
subsError: null,
|
|
528
|
+
apiError: null,
|
|
529
|
+
api: null,
|
|
530
|
+
subs: null,
|
|
531
|
+
lastAt: 0,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function fetchSubs(force) {
|
|
535
|
+
const status = await rpcPost('/subscriptions-auth/status', 'status', {})
|
|
536
|
+
const byProvider = (status && status.providers) || {}
|
|
537
|
+
const list = []
|
|
538
|
+
for (const meta of PROVIDERS) {
|
|
539
|
+
const entry = byProvider[meta.id]
|
|
540
|
+
const accounts = (entry && Array.isArray(entry.accounts) ? entry.accounts : [])
|
|
541
|
+
const accRows = []
|
|
542
|
+
for (const acc of accounts) {
|
|
543
|
+
const row = {
|
|
544
|
+
key: acc.key,
|
|
545
|
+
label: acc.account || acc.key,
|
|
546
|
+
plan: acc.plan || '',
|
|
547
|
+
windows: [],
|
|
548
|
+
error: null,
|
|
549
|
+
ok: false,
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const usage = await rpcPost('/subscriptions-auth/usage', 'usage', {
|
|
553
|
+
provider: meta.id,
|
|
554
|
+
account: acc.key,
|
|
555
|
+
...(force ? { force: true } : {}),
|
|
556
|
+
})
|
|
557
|
+
row.ok = Boolean(usage && usage.supported !== false)
|
|
558
|
+
row.windows = Array.isArray(usage && usage.windows) ? usage.windows : []
|
|
559
|
+
row.plan = row.plan || (usage && usage.plan) || ''
|
|
560
|
+
} catch (err) {
|
|
561
|
+
row.error = err && err.message ? err.message : String(err)
|
|
562
|
+
}
|
|
563
|
+
accRows.push(row)
|
|
564
|
+
}
|
|
565
|
+
list.push({ ...meta, accounts: accRows })
|
|
566
|
+
}
|
|
567
|
+
return list
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function fetchApi(force) {
|
|
571
|
+
let stateData = await rpcPost('/api/costMeter/getState', 'costMeter/getState', { args: {} })
|
|
572
|
+
if (!stateData || typeof stateData !== 'object') throw new Error(t('costMeterEmpty'))
|
|
573
|
+
const balanceNow = stateData.balance || {}
|
|
574
|
+
const neverFetched = balanceNow.status !== 'ok' && !(Number(balanceNow.fetchedAt) > 0)
|
|
575
|
+
// Auto-refresh only when the official balance was never fetched yet (fresh boot or
|
|
576
|
+
// cost-meter not opened); otherwise keep the cached snapshot and let the manual
|
|
577
|
+
// refresh button force a live re-check. This avoids hammering the balance API.
|
|
578
|
+
if (force || (balanceNow.status !== 'ok' && neverFetched)) {
|
|
579
|
+
try {
|
|
580
|
+
const refreshed = await rpcPost('/api/costMeter/refreshBalance', 'costMeter/refreshBalance', { args: {} })
|
|
581
|
+
if (refreshed && refreshed.state && typeof refreshed.state === 'object') {
|
|
582
|
+
stateData = refreshed.state
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
/* keep the previous snapshot; its balance.message explains the state */
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const today = stateData.today && typeof stateData.today === 'object' ? stateData.today : { cost: 0, calls: 0, byProviderModel: {} }
|
|
589
|
+
const by = (today.byProviderModel && typeof today.byProviderModel === 'object') ? today.byProviderModel : {}
|
|
590
|
+
let officialUsd = 0
|
|
591
|
+
let officialCalls = 0
|
|
592
|
+
let flashUsd = 0
|
|
593
|
+
let flashCalls = 0
|
|
594
|
+
const keys = Object.keys(by)
|
|
595
|
+
for (const key of keys) {
|
|
596
|
+
const idx = key.indexOf(':')
|
|
597
|
+
const provider = idx >= 0 ? key.slice(0, idx) : key
|
|
598
|
+
const model = idx >= 0 ? key.slice(idx + 1) : ''
|
|
599
|
+
if (!OFFICIAL_PROVIDERS.has(provider)) continue
|
|
600
|
+
const entry = by[key] || {}
|
|
601
|
+
const cost = num(entry.cost)
|
|
602
|
+
const calls = num(entry.calls)
|
|
603
|
+
officialUsd += cost
|
|
604
|
+
officialCalls += calls
|
|
605
|
+
if (model === FLASH_MODEL) {
|
|
606
|
+
flashUsd += cost
|
|
607
|
+
flashCalls += calls
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const balance = stateData.balance || { status: 'off', message: '', totalBalance: 0, grantedBalance: 0, toppedUpBalance: 0, fetchedAt: 0 }
|
|
611
|
+
return {
|
|
612
|
+
balance,
|
|
613
|
+
todayUsd: officialUsd,
|
|
614
|
+
todayCalls: officialCalls,
|
|
615
|
+
flashUsd,
|
|
616
|
+
flashCalls,
|
|
617
|
+
hasBreakdown: keys.length > 0,
|
|
618
|
+
config: stateData.config || defaultConfig(),
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ── floating window UI (vanilla DOM) ─────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
let root = null
|
|
625
|
+
let headTitle = null
|
|
626
|
+
let subsBox = null
|
|
627
|
+
let apiBox = null
|
|
628
|
+
let footEl = null
|
|
629
|
+
let refreshBtn = null
|
|
630
|
+
let autoTimer = null
|
|
631
|
+
let visibilityHandler = null
|
|
632
|
+
let resizeHandler = null
|
|
633
|
+
let tabBar = null
|
|
634
|
+
let tabNameEl = null
|
|
635
|
+
let tabOpenEl = null
|
|
636
|
+
let secSubsTitle = null
|
|
637
|
+
let secApiTitle = null
|
|
638
|
+
let fbInputEl = null
|
|
639
|
+
let fbSendEl = null
|
|
640
|
+
let headEl = null
|
|
641
|
+
let minimizeBtnEl = null
|
|
642
|
+
let suppressTabClick = false
|
|
643
|
+
let animBusy = false
|
|
644
|
+
|
|
645
|
+
function buildStructure() {
|
|
646
|
+
ensureStyle()
|
|
647
|
+
root = el('div', { id: `${UID}-root`, class: uiPrefs.collapsed ? 'collapsed' : '' })
|
|
648
|
+
|
|
649
|
+
function svgBtn(svg, title, fn) {
|
|
650
|
+
const b = el('button', { class: 'um-btn', title, type: 'button' })
|
|
651
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); fn(e) })
|
|
652
|
+
b.innerHTML = svg
|
|
653
|
+
return b
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
headTitle = el('span', { class: 'um-title' })
|
|
657
|
+
const dot = el('span', { class: 'dot' })
|
|
658
|
+
headTitle.appendChild(dot)
|
|
659
|
+
|
|
660
|
+
const refreshIcon = '<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 1.8v3.4h-3.4"/></svg>'
|
|
661
|
+
refreshBtn = svgBtn(refreshIcon, t('refreshNow'), () => { refresh(true) })
|
|
662
|
+
|
|
663
|
+
const minimizeIcon = '<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 9.5 8 4 2.5 9.5"/><path d="M3.5 12.5h9"/></svg>'
|
|
664
|
+
const minimizeBtn = svgBtn(minimizeIcon, t('minimize'), () => { setCollapsed(true) })
|
|
665
|
+
|
|
666
|
+
const head = el('div', { class: 'um-head', title: t('headTitle') })
|
|
667
|
+
headEl = head
|
|
668
|
+
minimizeBtnEl = minimizeBtn
|
|
669
|
+
head.appendChild(headTitle)
|
|
670
|
+
head.appendChild(minimizeBtn)
|
|
671
|
+
head.appendChild(refreshBtn)
|
|
672
|
+
|
|
673
|
+
const bodyEl = el('div', { class: 'um-body' })
|
|
674
|
+
const sec1 = el('div', { class: 'um-sec' },
|
|
675
|
+
(secSubsTitle = el('div', { class: 'um-sec-title' }, t('secSubs'), el('span', { class: 'um-tag' }, t('secSubsTag')))),
|
|
676
|
+
(subsBox = el('div')),
|
|
677
|
+
)
|
|
678
|
+
const sec2 = el('div', { class: 'um-sec' },
|
|
679
|
+
(secApiTitle = el('div', { class: 'um-sec-title' }, t('secApi'))),
|
|
680
|
+
(apiBox = el('div')),
|
|
681
|
+
)
|
|
682
|
+
bodyEl.appendChild(sec1)
|
|
683
|
+
bodyEl.appendChild(sec2)
|
|
684
|
+
|
|
685
|
+
footEl = el('div', { class: 'um-foot' })
|
|
686
|
+
bodyEl.appendChild(footEl)
|
|
687
|
+
bodyEl.appendChild(buildFeedbackBar())
|
|
688
|
+
root.appendChild(head)
|
|
689
|
+
root.appendChild(bodyEl)
|
|
690
|
+
|
|
691
|
+
// Minimized slim bar: keeps the plugin name and a glowing expand key.
|
|
692
|
+
const expandIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>'
|
|
693
|
+
tabBar = el('div', { class: 'um-tab', title: t('tabTitle') })
|
|
694
|
+
const tabDot = el('span', { class: 'um-tab-dot' })
|
|
695
|
+
tabNameEl = el('span', { class: 'um-tab-name' }, t('title'))
|
|
696
|
+
const tabName = tabNameEl
|
|
697
|
+
const tabOpen = el('button', { class: 'um-tab-open', type: 'button', title: t('tabExpand'), 'aria-label': t('tabExpand') })
|
|
698
|
+
tabOpenEl = tabOpen
|
|
699
|
+
tabOpen.innerHTML = expandIcon
|
|
700
|
+
tabOpen.addEventListener('click', (e) => { e.stopPropagation(); setCollapsed(false) })
|
|
701
|
+
tabBar.appendChild(tabDot)
|
|
702
|
+
tabBar.appendChild(tabName)
|
|
703
|
+
tabBar.appendChild(tabOpen)
|
|
704
|
+
root.appendChild(tabBar)
|
|
705
|
+
|
|
706
|
+
// Expand must also work when the pointer-captured click lands on the root
|
|
707
|
+
// (drag handling retargets click targets) — listen at root level, never only
|
|
708
|
+
// on the inner tab/button.
|
|
709
|
+
root.addEventListener('click', () => {
|
|
710
|
+
if (!root.classList.contains('collapsed')) return
|
|
711
|
+
if (suppressTabClick) { suppressTabClick = false; return }
|
|
712
|
+
setCollapsed(false)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
makeDraggable(root)
|
|
716
|
+
updateWindowLabel()
|
|
717
|
+
updateTabStatus()
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Feedback bar: one input + send. "Send" opens a pre-filled GitHub issue in
|
|
721
|
+
// a new tab; nothing is transmitted by this plugin itself.
|
|
722
|
+
function buildFeedbackBar() {
|
|
723
|
+
const wrap = el('div')
|
|
724
|
+
const input = el('input', {
|
|
725
|
+
class: 'um-fb-input',
|
|
726
|
+
type: 'text',
|
|
727
|
+
maxlength: String(FEEDBACK_MAX_CHARS),
|
|
728
|
+
placeholder: t('feedbackPrompt'),
|
|
729
|
+
'aria-label': t('feedbackAria'),
|
|
730
|
+
autocomplete: 'off',
|
|
731
|
+
})
|
|
732
|
+
const send = el('button', { class: 'um-fb-send', type: 'button', title: t('feedbackSendTitle') }, t('feedbackSend'))
|
|
733
|
+
fbInputEl = input
|
|
734
|
+
fbSendEl = send
|
|
735
|
+
const msg = el('div', { class: 'um-fb-msg' })
|
|
736
|
+
const bar = el('div', { class: 'um-fb' }, input, send)
|
|
737
|
+
wrap.appendChild(bar)
|
|
738
|
+
wrap.appendChild(msg)
|
|
739
|
+
|
|
740
|
+
// Keep typing/selection inside the input from being eaten by the drag handler.
|
|
741
|
+
for (const evt of ['pointerdown', 'mousedown', 'click', 'keydown']) {
|
|
742
|
+
input.addEventListener(evt, (e) => e.stopPropagation())
|
|
743
|
+
}
|
|
744
|
+
send.addEventListener('pointerdown', (e) => e.stopPropagation())
|
|
745
|
+
|
|
746
|
+
const submit = () => {
|
|
747
|
+
const text = input.value.trim()
|
|
748
|
+
if (!text) { msg.className = 'um-fb-msg'; msg.textContent = t('feedbackEmpty'); input.focus(); return }
|
|
749
|
+
const st = loadFeedbackState()
|
|
750
|
+
const wait = FEEDBACK_COOLDOWN_MS - (Date.now() - st.lastAt)
|
|
751
|
+
if (wait > 0) { msg.className = 'um-fb-msg'; msg.textContent = t('feedbackWait', Math.ceil(wait / 1000)); return }
|
|
752
|
+
const url = buildFeedbackUrl(text, PLUGIN_VERSION)
|
|
753
|
+
if (!url) return
|
|
754
|
+
const next = { lastAt: Date.now(), count: st.count + 1 }
|
|
755
|
+
saveFeedbackState(next)
|
|
756
|
+
const win = window.open(url, '_blank', 'noopener,noreferrer')
|
|
757
|
+
msg.className = 'um-fb-msg ok'
|
|
758
|
+
msg.innerHTML = ''
|
|
759
|
+
if (win) {
|
|
760
|
+
msg.appendChild(document.createTextNode(t('feedbackOpened')))
|
|
761
|
+
} else {
|
|
762
|
+
const a = el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, t('feedbackOpenHere'))
|
|
763
|
+
msg.appendChild(document.createTextNode(t('feedbackBlocked')))
|
|
764
|
+
msg.appendChild(a)
|
|
765
|
+
}
|
|
766
|
+
input.value = ''
|
|
767
|
+
}
|
|
768
|
+
send.addEventListener('click', (e) => { e.stopPropagation(); submit() })
|
|
769
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit() } })
|
|
770
|
+
return wrap
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Follow the host locale: DSH Web rewrites <html lang> on every language
|
|
774
|
+
// switch. Relabel static chrome in place and re-render dynamic sections.
|
|
775
|
+
let localeObserver = null
|
|
776
|
+
|
|
777
|
+
function applyLocale() {
|
|
778
|
+
const next = detectLocale()
|
|
779
|
+
if (next === locale) return
|
|
780
|
+
locale = next
|
|
781
|
+
if (root) {
|
|
782
|
+
if (headEl) headEl.title = t('headTitle')
|
|
783
|
+
if (refreshBtn) refreshBtn.title = t('refreshNow')
|
|
784
|
+
if (minimizeBtnEl) minimizeBtnEl.title = t('minimize')
|
|
785
|
+
if (secSubsTitle) { secSubsTitle.textContent = ''; secSubsTitle.appendChild(document.createTextNode(t('secSubs'))); secSubsTitle.appendChild(el('span', { class: 'um-tag' }, t('secSubsTag'))) }
|
|
786
|
+
if (secApiTitle) secApiTitle.textContent = t('secApi')
|
|
787
|
+
if (tabBar) tabBar.title = t('tabTitle')
|
|
788
|
+
if (tabNameEl) tabNameEl.textContent = t('title')
|
|
789
|
+
if (tabOpenEl) { tabOpenEl.title = t('tabExpand'); tabOpenEl.setAttribute('aria-label', t('tabExpand')) }
|
|
790
|
+
if (fbInputEl) { fbInputEl.placeholder = t('feedbackPrompt'); fbInputEl.setAttribute('aria-label', t('feedbackAria')) }
|
|
791
|
+
if (fbSendEl) { fbSendEl.textContent = t('feedbackSend'); fbSendEl.title = t('feedbackSendTitle') }
|
|
792
|
+
if (root.parentNode) { if (root.classList.contains('collapsed')) updateTabStatus(); else render() }
|
|
793
|
+
}
|
|
794
|
+
window.dispatchEvent(new CustomEvent(EVT_LOCALE, { detail: { locale } }))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function startLocaleObserver() {
|
|
798
|
+
if (localeObserver || typeof MutationObserver === 'undefined') return
|
|
799
|
+
try {
|
|
800
|
+
localeObserver = new MutationObserver(applyLocale)
|
|
801
|
+
localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
|
802
|
+
} catch { /* noop */ }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function updateWindowLabel() {
|
|
806
|
+
if (!headTitle) return
|
|
807
|
+
headTitle.innerHTML = ''
|
|
808
|
+
const dot = el('span', { class: 'dot' })
|
|
809
|
+
headTitle.appendChild(dot)
|
|
810
|
+
headTitle.appendChild(document.createTextNode(t('title')))
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ── corner-dock collapse / expand ────────────────────────────────────────
|
|
814
|
+
|
|
815
|
+
function viewportSize() {
|
|
816
|
+
return {
|
|
817
|
+
width: (document.documentElement && document.documentElement.clientWidth) || window.innerWidth,
|
|
818
|
+
height: (document.documentElement && document.documentElement.clientHeight) || window.innerHeight,
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function parsePx(value) {
|
|
823
|
+
const n = parseFloat(value)
|
|
824
|
+
return Number.isFinite(n) ? n : NaN
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Current collapsed-bar size; falls back to a stable estimate. */
|
|
828
|
+
function barSize() {
|
|
829
|
+
if (!tabBar) return { width: 120, height: 36 }
|
|
830
|
+
const r = tabBar.getBoundingClientRect()
|
|
831
|
+
return { width: r.width > 0 ? r.width : 120, height: r.height > 0 ? r.height : 36 }
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Dock target for the collapsed bar at a corner (CORNER_GAP from the edges). */
|
|
835
|
+
function cornerRect(corner, bw, bh) {
|
|
836
|
+
const v = viewportSize()
|
|
837
|
+
const g = CORNER_GAP
|
|
838
|
+
const left = corner === 'br' || corner === 'tr' ? v.width - g - bw : g
|
|
839
|
+
const top = corner === 'br' || corner === 'bl' ? v.height - g - bh : g
|
|
840
|
+
return {
|
|
841
|
+
left: Math.max(0, left),
|
|
842
|
+
top: Math.max(0, top),
|
|
843
|
+
width: bw,
|
|
844
|
+
height: bh,
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** The corner nearest a rectangle's centre (tl | tr | bl | br). */
|
|
849
|
+
function nearestCorner(rect) {
|
|
850
|
+
const v = viewportSize()
|
|
851
|
+
const horizontal = rect.left + rect.width / 2 < v.width / 2 ? 'l' : 'r'
|
|
852
|
+
const vertical = rect.top + rect.height / 2 < v.height / 2 ? 't' : 'b'
|
|
853
|
+
return vertical + horizontal
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Learn the real collapsed-bar size while the window is still expanded (no paint). */
|
|
857
|
+
function measureBarWhileExpanded() {
|
|
858
|
+
if (!root || !tabBar) return { width: 120, height: 36 }
|
|
859
|
+
const had = root.classList.contains('collapsed')
|
|
860
|
+
root.classList.add('collapsed')
|
|
861
|
+
const r = tabBar.getBoundingClientRect()
|
|
862
|
+
if (!had) root.classList.remove('collapsed')
|
|
863
|
+
return { width: Math.max(1, r.width), height: Math.max(1, r.height) }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function setBox(left, top, right, bottom) {
|
|
867
|
+
// 'auto' (not '') overrides the stylesheet's default right/bottom so the
|
|
868
|
+
// panel never stretches along an edge it is not anchored to.
|
|
869
|
+
root.style.left = left === null || left === undefined ? 'auto' : `${left}px`
|
|
870
|
+
root.style.top = top === null || top === undefined ? 'auto' : `${top}px`
|
|
871
|
+
root.style.right = right === null || right === undefined ? 'auto' : `${right}px`
|
|
872
|
+
root.style.bottom = bottom === null || bottom === undefined ? 'auto' : `${bottom}px`
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Keep a left/top box fully inside the viewport (never negative, never past the far edge). */
|
|
876
|
+
function clampToViewport(left, top, width, height) {
|
|
877
|
+
const v = viewportSize()
|
|
878
|
+
return {
|
|
879
|
+
left: Math.max(0, Math.min(left, v.width - width)),
|
|
880
|
+
top: Math.max(0, Math.min(top, v.height - height)),
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Place the window after (re)mounting: dock the collapsed bar, or restore the expanded spot. */
|
|
885
|
+
function finalizePlacement() {
|
|
886
|
+
if (!root || !root.parentNode) return
|
|
887
|
+
root.style.transform = ''
|
|
888
|
+
if (uiPrefs.collapsed) {
|
|
889
|
+
dockBarToCorner(uiPrefs.collapsedCorner || 'br')
|
|
890
|
+
} else {
|
|
891
|
+
applyExpandedLayout()
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Snap the collapsed bar into a screen corner. */
|
|
896
|
+
function dockBarToCorner(corner) {
|
|
897
|
+
if (!root || !tabBar) return
|
|
898
|
+
const bar = barSize()
|
|
899
|
+
const rect = cornerRect(corner, bar.width, bar.height)
|
|
900
|
+
setBox(rect.left, rect.top, null, null)
|
|
901
|
+
// A bar dragged to a different corner should expand at that corner, not
|
|
902
|
+
// fly back to wherever the window last sat.
|
|
903
|
+
if (uiPrefs.collapsedCorner !== corner) uiPrefs.expand = { mode: 'edge', corner, left: null, top: null }
|
|
904
|
+
uiPrefs.collapsedCorner = corner
|
|
905
|
+
savePrefs()
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Remember where the window should come back to when expanding. */
|
|
909
|
+
function snapshotExpandState() {
|
|
910
|
+
if (!root) return
|
|
911
|
+
const l = root.style.left
|
|
912
|
+
const t = root.style.top
|
|
913
|
+
const custom = typeof l === 'string' && l.length > 0 && l !== 'auto' && !l.endsWith('%')
|
|
914
|
+
&& typeof t === 'string' && t.length > 0 && t !== 'auto' && !t.endsWith('%')
|
|
915
|
+
if (custom) {
|
|
916
|
+
uiPrefs.expand = { mode: 'custom', left: parsePx(l), top: parsePx(t), corner: null }
|
|
917
|
+
} else {
|
|
918
|
+
uiPrefs.expand = { mode: 'edge', corner: uiPrefs.collapsedCorner || 'br', left: null, top: null }
|
|
919
|
+
}
|
|
920
|
+
savePrefs()
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Apply the stored/derived expanded placement and return its rect. */
|
|
924
|
+
function applyExpandedLayout() {
|
|
925
|
+
root.classList.remove('collapsed')
|
|
926
|
+
const exp = uiPrefs.expand && typeof uiPrefs.expand === 'object' ? uiPrefs.expand : {}
|
|
927
|
+
if (exp.mode === 'custom' && Number.isFinite(Number(exp.left)) && Number.isFinite(Number(exp.top))) {
|
|
928
|
+
// Measure at the requested spot, then pull it back on screen if the
|
|
929
|
+
// viewport shrank or the spot was saved partly off-screen.
|
|
930
|
+
setBox(Number(exp.left), Number(exp.top), null, null)
|
|
931
|
+
const r = root.getBoundingClientRect()
|
|
932
|
+
const c = clampToViewport(Number(exp.left), Number(exp.top), r.width, r.height)
|
|
933
|
+
if (c.left !== Number(exp.left) || c.top !== Number(exp.top)) setBox(c.left, c.top, null, null)
|
|
934
|
+
} else {
|
|
935
|
+
const corner = exp.corner in CORNER_ID ? exp.corner : (uiPrefs.collapsedCorner || 'br')
|
|
936
|
+
const g = EXPAND_GAP
|
|
937
|
+
if (corner === 'br') setBox(null, null, g, g)
|
|
938
|
+
else if (corner === 'bl') setBox(g, null, null, g)
|
|
939
|
+
else if (corner === 'tr') setBox(null, g, g, null)
|
|
940
|
+
else setBox(g, g, null, null)
|
|
941
|
+
}
|
|
942
|
+
return root.getBoundingClientRect()
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function collapseWindow() {
|
|
946
|
+
if (!root || root.classList.contains('collapsed') || animBusy) return
|
|
947
|
+
animBusy = true
|
|
948
|
+
try {
|
|
949
|
+
snapshotExpandState()
|
|
950
|
+
const A = root.getBoundingClientRect()
|
|
951
|
+
const bar = measureBarWhileExpanded()
|
|
952
|
+
const corner = nearestCorner(A)
|
|
953
|
+
const B = cornerRect(corner, bar.width, bar.height)
|
|
954
|
+
uiPrefs.collapsedCorner = corner
|
|
955
|
+
if (root.animate) {
|
|
956
|
+
root.style.transformOrigin = '0 0'
|
|
957
|
+
const anim = root.animate([
|
|
958
|
+
{ transform: 'translate(0px,0px) scale(1,1)', opacity: 1 },
|
|
959
|
+
{
|
|
960
|
+
transform: `translate(${B.left - A.left}px, ${B.top - A.top}px) scale(${bar.width / A.width}, ${bar.height / A.height})`,
|
|
961
|
+
opacity: 0.5,
|
|
962
|
+
},
|
|
963
|
+
], { duration: 240, easing: 'cubic-bezier(.4,.05,.3,1)', fill: 'forwards' })
|
|
964
|
+
try { await anim.finished } catch { /* cancelled */ }
|
|
965
|
+
anim.cancel()
|
|
966
|
+
}
|
|
967
|
+
root.style.transformOrigin = ''
|
|
968
|
+
root.style.transform = ''
|
|
969
|
+
root.classList.add('collapsed')
|
|
970
|
+
setBox(B.left, B.top, null, null)
|
|
971
|
+
if (root.animate) {
|
|
972
|
+
try {
|
|
973
|
+
root.animate(
|
|
974
|
+
[{ transform: 'scale(.9)', opacity: 0.25 }, { transform: 'scale(1)', opacity: 1 }],
|
|
975
|
+
{ duration: 120, easing: 'ease-out' },
|
|
976
|
+
)
|
|
977
|
+
} catch { /* noop */ }
|
|
978
|
+
}
|
|
979
|
+
uiPrefs.collapsed = true
|
|
980
|
+
savePrefs()
|
|
981
|
+
updateTabStatus()
|
|
982
|
+
} finally {
|
|
983
|
+
animBusy = false
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function expandWindow() {
|
|
988
|
+
if (!root || !root.classList.contains('collapsed') || animBusy) return
|
|
989
|
+
animBusy = true
|
|
990
|
+
try {
|
|
991
|
+
const A = root.getBoundingClientRect() // bar rect
|
|
992
|
+
// Measure the future expanded spot first (no paint yet), then restore bar.
|
|
993
|
+
applyExpandedLayout()
|
|
994
|
+
const B = root.getBoundingClientRect()
|
|
995
|
+
root.classList.add('collapsed')
|
|
996
|
+
const bar = barSize()
|
|
997
|
+
const dock = cornerRect(uiPrefs.collapsedCorner || 'br', bar.width, bar.height)
|
|
998
|
+
setBox(dock.left, dock.top, null, null)
|
|
999
|
+
const dx = (B.left + B.width / 2) - (A.left + A.width / 2)
|
|
1000
|
+
const dy = (B.top + B.height / 2) - (A.top + A.height / 2)
|
|
1001
|
+
if (root.animate) {
|
|
1002
|
+
const anim = root.animate([
|
|
1003
|
+
{ transform: 'translate(0px,0px) scale(1,1)', opacity: 1 },
|
|
1004
|
+
{ transform: `translate(${dx}px, ${dy}px) scale(1.06,1.06)`, opacity: 0 },
|
|
1005
|
+
], { duration: 170, easing: 'cubic-bezier(.3,.7,.4,1)', fill: 'forwards' })
|
|
1006
|
+
try { await anim.finished } catch { /* cancelled */ }
|
|
1007
|
+
anim.cancel()
|
|
1008
|
+
}
|
|
1009
|
+
root.style.transform = ''
|
|
1010
|
+
applyExpandedLayout()
|
|
1011
|
+
updateWindowLabel()
|
|
1012
|
+
render() // show the last snapshot immediately; the stale check below refreshes it
|
|
1013
|
+
if (root.animate) {
|
|
1014
|
+
try {
|
|
1015
|
+
await root.animate(
|
|
1016
|
+
[
|
|
1017
|
+
{ transform: 'scale(.95) translateY(8px)', opacity: 0 },
|
|
1018
|
+
{ transform: 'scale(1) translateY(0px)', opacity: 1 },
|
|
1019
|
+
],
|
|
1020
|
+
{ duration: 170, easing: 'cubic-bezier(.2,.8,.3,1)' },
|
|
1021
|
+
).finished
|
|
1022
|
+
} catch { /* cancelled */ }
|
|
1023
|
+
}
|
|
1024
|
+
uiPrefs.collapsed = false
|
|
1025
|
+
savePrefs()
|
|
1026
|
+
updateTabStatus()
|
|
1027
|
+
if (view.lastAt === 0 || Date.now() - view.lastAt > AUTO_REFRESH_MS) void refresh(false)
|
|
1028
|
+
} finally {
|
|
1029
|
+
animBusy = false
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function setCollapsed(collapsed) {
|
|
1034
|
+
if (collapsed) void collapseWindow()
|
|
1035
|
+
else void expandWindow()
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Most-consumed subscription window percent across logged accounts (null when none). */
|
|
1039
|
+
function worstUsedPercent() {
|
|
1040
|
+
let worst = null
|
|
1041
|
+
const subs = view.subs
|
|
1042
|
+
if (subs) {
|
|
1043
|
+
for (const provider of subs) {
|
|
1044
|
+
for (const acc of provider.accounts) {
|
|
1045
|
+
if (!acc.ok) continue
|
|
1046
|
+
for (const win of acc.windows) {
|
|
1047
|
+
const used = Number(win && win.usedPercent)
|
|
1048
|
+
if (Number.isFinite(used)) worst = worst === null ? used : Math.max(worst, used)
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return worst
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function updateTabStatus() {
|
|
1057
|
+
if (!root || !tabBar) return
|
|
1058
|
+
const worst = worstUsedPercent()
|
|
1059
|
+
const pct = worst === null ? 0 : Math.min(100, Math.max(0, worst))
|
|
1060
|
+
const color = pct >= 95 ? '#f85149' : pct >= 80 ? '#f5a623' : '#3fb950'
|
|
1061
|
+
root.style.setProperty('--um-tab-dot', color)
|
|
1062
|
+
const tips = []
|
|
1063
|
+
if (worst === null) tips.push(t('tipSubsUnknown'))
|
|
1064
|
+
else if (worst >= 95) tips.push(t('tipSubsCritical', Math.round(worst)))
|
|
1065
|
+
else if (worst >= 80) tips.push(t('tipSubsTight', Math.round(worst)))
|
|
1066
|
+
else tips.push(t('tipSubsOk', Math.round(worst)))
|
|
1067
|
+
const apiOk = view.api && view.api.balance && view.api.balance.status === 'ok'
|
|
1068
|
+
if (apiOk && view.api) tips.push(t('tipApiToday', fmtUsd(view.api.todayUsd, view.api.config || defaultConfig())))
|
|
1069
|
+
tips.push(t('tabTitle'))
|
|
1070
|
+
const summary = tips.join(' · ')
|
|
1071
|
+
root.title = summary
|
|
1072
|
+
tabBar.title = summary
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function makeDraggable(handle) {
|
|
1076
|
+
let dragging = false
|
|
1077
|
+
let startX = 0
|
|
1078
|
+
let startY = 0
|
|
1079
|
+
let originLeft = 0
|
|
1080
|
+
let originTop = 0
|
|
1081
|
+
let moved = 0
|
|
1082
|
+
handle.addEventListener('pointerdown', (e) => {
|
|
1083
|
+
if (e.target.closest('button')) return
|
|
1084
|
+
const collapsed = root && root.classList.contains('collapsed')
|
|
1085
|
+
// Expanded: only the header row drags; minimized: the whole slim bar drags.
|
|
1086
|
+
if (!collapsed && !e.target.closest('.um-head')) return
|
|
1087
|
+
dragging = true
|
|
1088
|
+
moved = 0
|
|
1089
|
+
startX = e.clientX
|
|
1090
|
+
startY = e.clientY
|
|
1091
|
+
const rect = root.getBoundingClientRect()
|
|
1092
|
+
originLeft = rect.left
|
|
1093
|
+
originTop = rect.top
|
|
1094
|
+
root.style.right = 'auto'
|
|
1095
|
+
root.style.bottom = 'auto'
|
|
1096
|
+
root.style.left = `${originLeft}px`
|
|
1097
|
+
root.style.top = `${originTop}px`
|
|
1098
|
+
handle.setPointerCapture(e.pointerId)
|
|
1099
|
+
})
|
|
1100
|
+
handle.addEventListener('pointermove', (e) => {
|
|
1101
|
+
if (!dragging) return
|
|
1102
|
+
const dx = e.clientX - startX
|
|
1103
|
+
const dy = e.clientY - startY
|
|
1104
|
+
moved = Math.max(moved, Math.abs(dx) + Math.abs(dy))
|
|
1105
|
+
const r = root.getBoundingClientRect()
|
|
1106
|
+
const c = clampToViewport(originLeft + dx, originTop + dy, r.width, r.height)
|
|
1107
|
+
root.style.left = `${c.left}px`
|
|
1108
|
+
root.style.top = `${c.top}px`
|
|
1109
|
+
})
|
|
1110
|
+
const end = (e) => {
|
|
1111
|
+
if (!dragging) return
|
|
1112
|
+
dragging = false
|
|
1113
|
+
try { handle.releasePointerCapture(e.pointerId) } catch { /* noop */ }
|
|
1114
|
+
// A real drag must not be treated as a click-to-expand on the minimized bar.
|
|
1115
|
+
if (moved > 6) {
|
|
1116
|
+
suppressTabClick = true
|
|
1117
|
+
window.setTimeout(() => { suppressTabClick = false }, 200)
|
|
1118
|
+
}
|
|
1119
|
+
if (root.classList.contains('collapsed')) {
|
|
1120
|
+
// Dock the bar to the nearest screen corner after dragging.
|
|
1121
|
+
const rect = root.getBoundingClientRect()
|
|
1122
|
+
dockBarToCorner(nearestCorner(rect))
|
|
1123
|
+
} else {
|
|
1124
|
+
uiPrefs.left = root.offsetLeft
|
|
1125
|
+
uiPrefs.top = root.offsetTop
|
|
1126
|
+
uiPrefs.expand = { mode: 'custom', left: root.offsetLeft, top: root.offsetTop, corner: null }
|
|
1127
|
+
savePrefs()
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
handle.addEventListener('pointerup', end)
|
|
1131
|
+
handle.addEventListener('pointercancel', end)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function renderSubs() {
|
|
1135
|
+
subsBox.innerHTML = ''
|
|
1136
|
+
if (view.subsError) {
|
|
1137
|
+
subsBox.appendChild(el('div', { class: 'um-note err' }, view.subsError))
|
|
1138
|
+
return
|
|
1139
|
+
}
|
|
1140
|
+
if (!view.subs) {
|
|
1141
|
+
subsBox.appendChild(el('div', { class: 'um-note' }, t('loading')))
|
|
1142
|
+
return
|
|
1143
|
+
}
|
|
1144
|
+
const logged = view.subs.filter((p) => p.accounts.length > 0)
|
|
1145
|
+
if (logged.length === 0) {
|
|
1146
|
+
subsBox.appendChild(el('div', { class: 'um-note warn' }, t('subsNotSignedIn')))
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
for (const provider of logged) {
|
|
1150
|
+
for (const acc of provider.accounts) {
|
|
1151
|
+
const card = el('div', { class: 'um-card' })
|
|
1152
|
+
const headLabel = `${provider.name}`
|
|
1153
|
+
const headSubParts = []
|
|
1154
|
+
if (acc.plan) headSubParts.push(acc.plan)
|
|
1155
|
+
if (acc.label && acc.label !== acc.key) headSubParts.push(acc.label)
|
|
1156
|
+
card.appendChild(el('div', { class: 'um-card-head' }, headLabel, el('span', { class: 'sub' }, headSubParts.join(' · '))))
|
|
1157
|
+
if (acc.error) {
|
|
1158
|
+
card.appendChild(el('div', { class: 'um-note err' }, t('subsQueryFailed', acc.error)))
|
|
1159
|
+
} else if (!acc.ok) {
|
|
1160
|
+
card.appendChild(el('div', { class: 'um-note' }, t('subsNoWindows')))
|
|
1161
|
+
} else if (acc.windows.length === 0) {
|
|
1162
|
+
card.appendChild(el('div', { class: 'um-note' }, t('subsNoData')))
|
|
1163
|
+
} else {
|
|
1164
|
+
for (const win of acc.windows) {
|
|
1165
|
+
const used = Number(win.usedPercent)
|
|
1166
|
+
const pct = Number.isFinite(used) ? used : 0
|
|
1167
|
+
const kindLabel = win.kind === 'session' ? t('winSession') : win.kind === 'weekly' ? t('winWeekly') : (win.kind || t('winGeneric'))
|
|
1168
|
+
const scope = (win.scope !== undefined && win.scope !== null && win.scope !== '') ? ` · ${win.scope}` : ''
|
|
1169
|
+
const remaining = Math.max(0, 100 - pct)
|
|
1170
|
+
const row = el('div', { class: 'um-row' },
|
|
1171
|
+
el('span', { class: 'lbl', title: kindLabel + scope }, kindLabel + scope),
|
|
1172
|
+
el('span', { class: 'um-bar' }, el('span', { class: 'um-fill', style: { width: `${Math.min(100, Math.max(0, pct))}%`, background: barColor(pct) } })),
|
|
1173
|
+
el('span', { class: 'um-pct' }, el('strong', {}, `${Math.round(pct)}%`), t('used')),
|
|
1174
|
+
)
|
|
1175
|
+
card.appendChild(row)
|
|
1176
|
+
const resetInfo = fmtReset(Number(win.resetsAt))
|
|
1177
|
+
const subLine = el('div', { class: 'um-sub' })
|
|
1178
|
+
subLine.appendChild(el('span', { class: 'ok' }, t('remaining', Math.round(remaining))))
|
|
1179
|
+
if (resetInfo.date) {
|
|
1180
|
+
subLine.appendChild(document.createTextNode(t('resetAt', resetInfo.date)))
|
|
1181
|
+
if (resetInfo.rel) subLine.appendChild(el('span', {}, `(${resetInfo.rel})`))
|
|
1182
|
+
} else {
|
|
1183
|
+
subLine.appendChild(document.createTextNode(t('resetUnknown')))
|
|
1184
|
+
}
|
|
1185
|
+
card.appendChild(subLine)
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
subsBox.appendChild(card)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function renderApi() {
|
|
1194
|
+
apiBox.innerHTML = ''
|
|
1195
|
+
if (view.apiError) {
|
|
1196
|
+
apiBox.appendChild(el('div', { class: 'um-note err' }, view.apiError))
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
1199
|
+
if (!view.api) {
|
|
1200
|
+
apiBox.appendChild(el('div', { class: 'um-note' }, t('loading')))
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
const cfg = view.api.config || defaultConfig()
|
|
1204
|
+
const balance = view.api.balance || {}
|
|
1205
|
+
if (balance.status === 'error') {
|
|
1206
|
+
apiBox.appendChild(el('div', { class: 'um-note warn' }, balance.message || t('balanceFailed')))
|
|
1207
|
+
} else if (balance.status === 'off') {
|
|
1208
|
+
apiBox.appendChild(el('div', { class: 'um-note' }, t('balanceUnconfigured')))
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const rows = el('div')
|
|
1212
|
+
const todayUsd = view.api.todayUsd || 0
|
|
1213
|
+
const tipParts = []
|
|
1214
|
+
if (view.api.hasBreakdown) {
|
|
1215
|
+
tipParts.push(t('todayCallsTip', view.api.todayCalls))
|
|
1216
|
+
if (view.api.flashCalls > 0) tipParts.push(t('flashTip', view.api.flashCalls, fmtUsd(view.api.flashUsd, cfg)))
|
|
1217
|
+
}
|
|
1218
|
+
rows.appendChild(el('div', { class: 'um-kv' },
|
|
1219
|
+
el('span', { class: 'k' }, t('todaySpend')),
|
|
1220
|
+
el('span', { class: 'v', title: tipParts.join(';') }, fmtUsd(todayUsd, cfg)),
|
|
1221
|
+
))
|
|
1222
|
+
if (view.api.hasBreakdown) {
|
|
1223
|
+
rows.appendChild(el('div', { class: 'um-kv' },
|
|
1224
|
+
el('span', { class: 'k' }, t('todayCalls')),
|
|
1225
|
+
el('span', { class: 'v dim' }, t('callsUnit', view.api.todayCalls)),
|
|
1226
|
+
))
|
|
1227
|
+
}
|
|
1228
|
+
if (balance.status === 'ok') {
|
|
1229
|
+
const balanceTip = []
|
|
1230
|
+
if (Number(balance.grantedBalance) > 0) balanceTip.push(t('granted', fmtBalance(num(balance.grantedBalance), cfg)))
|
|
1231
|
+
if (Number(balance.toppedUpBalance) > 0) balanceTip.push(t('toppedUp', fmtBalance(num(balance.toppedUpBalance), cfg)))
|
|
1232
|
+
rows.appendChild(el('div', { class: 'um-kv' },
|
|
1233
|
+
el('span', { class: 'k' }, t('balanceTotal')),
|
|
1234
|
+
el('span', { class: 'v', title: balanceTip.length > 0 ? balanceTip.join(' · ') : undefined }, fmtBalance(num(balance.totalBalance), cfg)),
|
|
1235
|
+
))
|
|
1236
|
+
}
|
|
1237
|
+
apiBox.appendChild(rows)
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function render() {
|
|
1241
|
+
if (!root || !root.parentNode) return
|
|
1242
|
+
updateWindowLabel()
|
|
1243
|
+
updateTabStatus()
|
|
1244
|
+
renderSubs()
|
|
1245
|
+
renderApi()
|
|
1246
|
+
const foot = []
|
|
1247
|
+
if (view.lastAt > 0) foot.push(t('updatedAt', clockText(new Date(view.lastAt))))
|
|
1248
|
+
foot.push(t('autoTag'))
|
|
1249
|
+
footEl.textContent = foot.join(' · ')
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ── refresh orchestration ────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
let refreshing = false
|
|
1255
|
+
|
|
1256
|
+
async function refresh(force) {
|
|
1257
|
+
if (!root || !root.parentNode || refreshing) return
|
|
1258
|
+
refreshing = true
|
|
1259
|
+
if (refreshBtn) refreshBtn.classList.add('spin')
|
|
1260
|
+
try {
|
|
1261
|
+
const results = await Promise.allSettled([fetchSubs(Boolean(force)), fetchApi(Boolean(force))])
|
|
1262
|
+
const subsResult = results[0]
|
|
1263
|
+
const apiResult = results[1]
|
|
1264
|
+
if (subsResult.status === 'fulfilled') {
|
|
1265
|
+
view.subs = subsResult.value
|
|
1266
|
+
view.subsError = null
|
|
1267
|
+
} else {
|
|
1268
|
+
const err = subsResult.reason
|
|
1269
|
+
view.subs = null
|
|
1270
|
+
view.subsError = (err && err.notFound)
|
|
1271
|
+
? t('subsChannelMissing')
|
|
1272
|
+
: t('subsFetchFailed', (err && err.message) || err)
|
|
1273
|
+
}
|
|
1274
|
+
if (apiResult.status === 'fulfilled') {
|
|
1275
|
+
view.api = apiResult.value
|
|
1276
|
+
view.apiError = null
|
|
1277
|
+
} else {
|
|
1278
|
+
const err = apiResult.reason
|
|
1279
|
+
view.api = null
|
|
1280
|
+
view.apiError = (err && err.notFound)
|
|
1281
|
+
? t('apiChannelMissing')
|
|
1282
|
+
: t('apiFetchFailed', (err && err.message) || err)
|
|
1283
|
+
}
|
|
1284
|
+
view.lastAt = Date.now()
|
|
1285
|
+
view.loading = false
|
|
1286
|
+
} finally {
|
|
1287
|
+
refreshing = false
|
|
1288
|
+
if (refreshBtn) refreshBtn.classList.remove('spin')
|
|
1289
|
+
}
|
|
1290
|
+
// Collapsed: only refresh the tiny bar status (dot/tooltip); skip rebuilding
|
|
1291
|
+
// the hidden content DOM. Expanded: render the full window.
|
|
1292
|
+
if (root.classList.contains('collapsed')) updateTabStatus()
|
|
1293
|
+
else render()
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function startTimers() {
|
|
1297
|
+
if (autoTimer !== null) return
|
|
1298
|
+
// Poll only while the window is actually expanded, visible, and on screen;
|
|
1299
|
+
// collapsing stops auto-polling (expand refreshes on demand).
|
|
1300
|
+
const shouldPoll = () => (
|
|
1301
|
+
!document.hidden
|
|
1302
|
+
&& root !== null
|
|
1303
|
+
&& root.parentNode !== null
|
|
1304
|
+
&& !root.classList.contains('collapsed')
|
|
1305
|
+
)
|
|
1306
|
+
autoTimer = window.setInterval(() => { if (shouldPoll()) void refresh(false) }, AUTO_REFRESH_MS)
|
|
1307
|
+
visibilityHandler = () => { if (shouldPoll()) void refresh(false) }
|
|
1308
|
+
document.addEventListener('visibilitychange', visibilityHandler)
|
|
1309
|
+
// Re-dock / re-clamp when the viewport changes so the window never ends
|
|
1310
|
+
// up off-screen after a resize or sidebar toggle.
|
|
1311
|
+
resizeHandler = () => { if (root && root.parentNode && !animBusy) finalizePlacement() }
|
|
1312
|
+
window.addEventListener('resize', resizeHandler)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function stopTimers() {
|
|
1316
|
+
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null }
|
|
1317
|
+
if (visibilityHandler !== null) { document.removeEventListener('visibilitychange', visibilityHandler); visibilityHandler = null }
|
|
1318
|
+
if (resizeHandler !== null) { window.removeEventListener('resize', resizeHandler); resizeHandler = null }
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function showWindow() {
|
|
1322
|
+
if (root && root.parentNode) return
|
|
1323
|
+
if (!root) buildStructure()
|
|
1324
|
+
document.body.appendChild(root)
|
|
1325
|
+
finalizePlacement()
|
|
1326
|
+
if (!uiPrefs.collapsed) updateWindowLabel()
|
|
1327
|
+
startTimers()
|
|
1328
|
+
// Fresh boot or long-hidden data: fetch right away so nothing shows stale.
|
|
1329
|
+
if (view.lastAt === 0 || Date.now() - view.lastAt > AUTO_REFRESH_MS) void refresh(false)
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function hideWindow() {
|
|
1333
|
+
if (root && root.parentNode) root.parentNode.removeChild(root)
|
|
1334
|
+
stopTimers()
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function syncVisibility() {
|
|
1338
|
+
if (uiPrefs.visible) showWindow()
|
|
1339
|
+
else hideWindow()
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ── Settings card (React, registered into settings.section slot) ─────────
|
|
1343
|
+
|
|
1344
|
+
function SettingsCard() {
|
|
1345
|
+
const [visible, setVisible] = React.useState(() => loadPrefs().visible)
|
|
1346
|
+
const [status, setStatus] = React.useState('')
|
|
1347
|
+
const [, setLocaleTick] = React.useState(0)
|
|
1348
|
+
|
|
1349
|
+
React.useEffect(() => {
|
|
1350
|
+
const onSet = (e) => {
|
|
1351
|
+
if (e.detail && typeof e.detail.visible === 'boolean') setVisible(e.detail.visible)
|
|
1352
|
+
}
|
|
1353
|
+
const onStorage = (e) => {
|
|
1354
|
+
if (e.key === LS_UI) setVisible(loadPrefs().visible)
|
|
1355
|
+
}
|
|
1356
|
+
const onRefreshDone = () => {
|
|
1357
|
+
if (view.lastAt > 0) setStatus(t('updatedAt', clockText(new Date(view.lastAt))))
|
|
1358
|
+
}
|
|
1359
|
+
const onLocale = () => setLocaleTick((n) => n + 1)
|
|
1360
|
+
window.addEventListener(EVT_SET, onSet)
|
|
1361
|
+
window.addEventListener(EVT_LOCALE, onLocale)
|
|
1362
|
+
window.addEventListener(EVT_REFRESH, onRefreshDone)
|
|
1363
|
+
window.addEventListener('storage', onStorage)
|
|
1364
|
+
return () => {
|
|
1365
|
+
window.removeEventListener(EVT_SET, onSet)
|
|
1366
|
+
window.removeEventListener(EVT_LOCALE, onLocale)
|
|
1367
|
+
window.removeEventListener(EVT_REFRESH, onRefreshDone)
|
|
1368
|
+
window.removeEventListener('storage', onStorage)
|
|
1369
|
+
}
|
|
1370
|
+
}, [])
|
|
1371
|
+
|
|
1372
|
+
const toggle = (next) => {
|
|
1373
|
+
setVisiblePref(next)
|
|
1374
|
+
window.dispatchEvent(new CustomEvent(EVT_SET, { detail: { visible: next } }))
|
|
1375
|
+
setVisible(next)
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const doRefresh = () => {
|
|
1379
|
+
if (!uiPrefs.visible) toggle(true)
|
|
1380
|
+
window.dispatchEvent(new CustomEvent(EVT_REFRESH, { detail: { force: true } }))
|
|
1381
|
+
setStatus(t('refreshing'))
|
|
1382
|
+
window.setTimeout(() => {
|
|
1383
|
+
if (view.lastAt > 0) setStatus(t('updatedAt', clockText(new Date(view.lastAt))))
|
|
1384
|
+
}, 1200)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const h = React.createElement
|
|
1388
|
+
return h('div', { className: 'ums-card' },
|
|
1389
|
+
h('div', { className: 'ums-desc' },
|
|
1390
|
+
t('settingsDesc'),
|
|
1391
|
+
h('br'),
|
|
1392
|
+
h('small', null, t('settingsDescSmall')),
|
|
1393
|
+
),
|
|
1394
|
+
h('div', { className: 'ums-row' },
|
|
1395
|
+
h('div', { className: 'ums-main' },
|
|
1396
|
+
h('div', { className: 'ums-name' }, t('settingsShow')),
|
|
1397
|
+
h('div', { className: 'ums-hint' }, visible ? t('settingsShownHint') : t('settingsHiddenHint')),
|
|
1398
|
+
),
|
|
1399
|
+
h('button', {
|
|
1400
|
+
className: 'ums-switch',
|
|
1401
|
+
type: 'button',
|
|
1402
|
+
role: 'switch',
|
|
1403
|
+
'aria-checked': visible,
|
|
1404
|
+
title: visible ? t('settingsHide') : t('settingsShowShort'),
|
|
1405
|
+
onClick: () => toggle(!visible),
|
|
1406
|
+
}, h('span', { className: 'knob' })),
|
|
1407
|
+
),
|
|
1408
|
+
h('div', { className: 'ums-row' },
|
|
1409
|
+
h('div', { className: 'ums-main' },
|
|
1410
|
+
h('div', { className: 'ums-name' }, t('refreshNow')),
|
|
1411
|
+
h('div', { className: 'ums-hint' }, t('settingsRefreshHint')),
|
|
1412
|
+
),
|
|
1413
|
+
h('div', { className: 'ums-actions' },
|
|
1414
|
+
h('button', { className: 'ums-btn', type: 'button', onClick: doRefresh }, t('refreshNow')),
|
|
1415
|
+
h('span', { className: 'ums-status' }, status),
|
|
1416
|
+
),
|
|
1417
|
+
),
|
|
1418
|
+
)
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ── plugin apply ─────────────────────────────────────────────────────────
|
|
1422
|
+
|
|
1423
|
+
let settingsDisposers = []
|
|
1424
|
+
|
|
1425
|
+
async function apply(ctx) {
|
|
1426
|
+
if (window.__dshUsageMiniApplied) return () => {}
|
|
1427
|
+
window.__dshUsageMiniApplied = true
|
|
1428
|
+
|
|
1429
|
+
loadPrefs()
|
|
1430
|
+
ensureStyle()
|
|
1431
|
+
locale = detectLocale()
|
|
1432
|
+
startLocaleObserver()
|
|
1433
|
+
|
|
1434
|
+
const onSet = (e) => {
|
|
1435
|
+
if (e.detail && typeof e.detail.visible === 'boolean') {
|
|
1436
|
+
setVisiblePref(e.detail.visible)
|
|
1437
|
+
syncVisibility()
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const onRefresh = (e) => {
|
|
1441
|
+
void refresh(Boolean(e && e.detail && e.detail.force))
|
|
1442
|
+
}
|
|
1443
|
+
const onStorage = (e) => {
|
|
1444
|
+
if (e.key === LS_UI) {
|
|
1445
|
+
loadPrefs()
|
|
1446
|
+
syncVisibility()
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
window.addEventListener(EVT_SET, onSet)
|
|
1450
|
+
window.addEventListener(EVT_REFRESH, onRefresh)
|
|
1451
|
+
window.addEventListener('storage', onStorage)
|
|
1452
|
+
|
|
1453
|
+
// Settings page card (best effort; the float still works without slots).
|
|
1454
|
+
try {
|
|
1455
|
+
const slots = ctx && typeof ctx.get === 'function' ? ctx.get('slots') : undefined
|
|
1456
|
+
if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
|
|
1457
|
+
const dispose = slots.inject('settings.section', () => slots.register({
|
|
1458
|
+
name: 'settings.section',
|
|
1459
|
+
id: 'usage-mini',
|
|
1460
|
+
order: 200,
|
|
1461
|
+
label: t('title'),
|
|
1462
|
+
inject: () => ({}),
|
|
1463
|
+
}, SettingsCard))
|
|
1464
|
+
settingsDisposers.push(dispose)
|
|
1465
|
+
}
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
// eslint-disable-next-line no-console
|
|
1468
|
+
console.warn('[dsh-usage-mini] settings card unavailable:', err && err.message ? err.message : err)
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
syncVisibility()
|
|
1472
|
+
|
|
1473
|
+
const cleanup = () => {
|
|
1474
|
+
window.removeEventListener(EVT_SET, onSet)
|
|
1475
|
+
window.removeEventListener(EVT_REFRESH, onRefresh)
|
|
1476
|
+
window.removeEventListener('storage', onStorage)
|
|
1477
|
+
stopTimers()
|
|
1478
|
+
for (const dispose of settingsDisposers) {
|
|
1479
|
+
try { dispose() } catch { /* noop */ }
|
|
1480
|
+
}
|
|
1481
|
+
settingsDisposers = []
|
|
1482
|
+
if (root && root.parentNode) root.parentNode.removeChild(root)
|
|
1483
|
+
root = null
|
|
1484
|
+
window.__dshUsageMiniApplied = false
|
|
1485
|
+
}
|
|
1486
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
1487
|
+
ctx.effect(() => cleanup, `${UID}: lifecycle`)
|
|
1488
|
+
}
|
|
1489
|
+
return cleanup
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
exports.apply = apply
|
|
1493
|
+
return module.exports
|
|
1494
|
+
},
|
|
1495
|
+
})
|