dsh-token-gauge 0.1.0

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/src/host.ts ADDED
@@ -0,0 +1,339 @@
1
+ // ============================================================================
2
+ // DSH Host 半边:Token 用量监听、累计、计费与持久化
3
+ // 运行环境:Node(宿主进程)。只做数据,不直接触碰 DOM。
4
+ // 与 Client 半边通过 DSH 内部事件总线通信:
5
+ // Host -> Client : ctx.events.emit('token-gauge:state', state)
6
+ // Client -> Host : ctx.events.emit('token-gauge:cmd', { type, payload })
7
+ // ============================================================================
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+
13
+ const PLUGIN_ID = 'dsh-token-gauge'
14
+ const DATA_DIR = join(homedir(), '.dsh-token-gauge')
15
+ const STATE_FILE = join(DATA_DIR, 'state.json')
16
+
17
+ // ---- 类型定义 --------------------------------------------------------------
18
+ interface Pricing {
19
+ inputHit: number // 缓存命中输入(¥/百万 token)
20
+ inputMiss: number // 缓存未命中输入
21
+ output: number // 输出
22
+ cacheWrite: number // 缓存写入
23
+ }
24
+
25
+ interface Buckets {
26
+ input: number // 未命中缓存的输入 token
27
+ cacheRead: number // 缓存命中输入 token
28
+ cacheWrite: number // 缓存写入 token
29
+ output: number // 输出 token
30
+ }
31
+
32
+ interface Entry extends Buckets {
33
+ cost: number
34
+ calls: number
35
+ }
36
+
37
+ type Metric = 'global' | 'session' | 'today' | 'daily' | 'weekly' | 'monthly'
38
+
39
+ interface DisplayFlags {
40
+ usage: boolean
41
+ cost: boolean
42
+ io: boolean
43
+ cacheMiss: boolean
44
+ }
45
+
46
+ interface Limits {
47
+ global: number | null
48
+ daily: number | null
49
+ weekly: number | null
50
+ monthly: number | null
51
+ }
52
+
53
+ interface Settings {
54
+ style: string
55
+ palette: string
56
+ lang: 'zh' | 'en'
57
+ metric: Metric
58
+ display: DisplayFlags
59
+ limits: Limits
60
+ pricing: Record<string, Pricing>
61
+ }
62
+
63
+ interface State {
64
+ global: Entry
65
+ daily: Record<string, Entry>
66
+ weekly: Record<string, Entry>
67
+ monthly: Record<string, Entry>
68
+ sessions: Record<string, Entry>
69
+ models: Record<string, Entry>
70
+ activeSession: string
71
+ settings: Settings
72
+ }
73
+
74
+ // ---- DeepSeek 官方 CNY 定价预设(¥/百万 token),可被设置覆盖 -----------------
75
+ const DEFAULT_PRICING: Record<string, Pricing> = {
76
+ 'deepseek-chat': { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
77
+ 'deepseek-v3': { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
78
+ 'deepseek-reasoner': { inputHit: 1, inputMiss: 4, output: 16, cacheWrite: 4 },
79
+ 'deepseek-r1': { inputHit: 1, inputMiss: 4, output: 16, cacheWrite: 4 },
80
+ 'deepseek-v4-flash': { inputHit: 0.5, inputMiss: 2, output: 2, cacheWrite: 2 },
81
+ 'deepseek-v4-pro': { inputHit: 1, inputMiss: 4, output: 8, cacheWrite: 4 },
82
+ 'deepseek-coder': { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
83
+ __default__: { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
84
+ }
85
+
86
+ // ---- 工具函数 --------------------------------------------------------------
87
+ function emptyEntry(): Entry {
88
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, cost: 0, calls: 0 }
89
+ }
90
+
91
+ function addEntry(t: Entry, s: Entry): void {
92
+ t.input += s.input
93
+ t.cacheRead += s.cacheRead
94
+ t.cacheWrite += s.cacheWrite
95
+ t.output += s.output
96
+ t.cost += s.cost
97
+ t.calls += s.calls
98
+ }
99
+
100
+ function totalTokens(e: Buckets): number {
101
+ return e.input + e.cacheRead + e.cacheWrite + e.output
102
+ }
103
+
104
+ function mergedPricing(settings: Settings): Record<string, Pricing> {
105
+ return { ...DEFAULT_PRICING, ...(settings.pricing || {}) }
106
+ }
107
+
108
+ function costFor(modelKey: string, b: Buckets, pricing: Record<string, Pricing>): number {
109
+ const p = pricing[modelKey] || pricing['__default__'] || DEFAULT_PRICING['__default__']
110
+ return (
111
+ (b.input * p.inputMiss +
112
+ b.cacheRead * p.inputHit +
113
+ b.cacheWrite * p.cacheWrite +
114
+ b.output * p.output) /
115
+ 1_000_000
116
+ )
117
+ }
118
+
119
+ function todayKey(d = new Date()): string {
120
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
121
+ }
122
+
123
+ function monthKey(d = new Date()): string {
124
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
125
+ }
126
+
127
+ function weekKey(d = new Date()): string {
128
+ // ISO 周序号
129
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
130
+ const dayNum = (date.getUTCDay() + 6) % 7
131
+ date.setUTCDate(date.getUTCDate() - dayNum + 3)
132
+ const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4))
133
+ const week =
134
+ 1 +
135
+ Math.round(
136
+ ((date.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7,
137
+ )
138
+ return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`
139
+ }
140
+
141
+ function defaultSettings(): Settings {
142
+ return {
143
+ style: 'ev', // 默认风格:现代电车科技
144
+ palette: 'neon', // 默认配色:霓虹蓝
145
+ lang: 'zh',
146
+ metric: 'global',
147
+ display: {
148
+ usage: true,
149
+ cost: true,
150
+ io: true,
151
+ cacheMiss: true,
152
+ },
153
+ limits: { global: null, daily: null, weekly: null, monthly: null },
154
+ pricing: {},
155
+ }
156
+ }
157
+
158
+ function defaultState(): State {
159
+ return {
160
+ global: emptyEntry(),
161
+ daily: {},
162
+ weekly: {},
163
+ monthly: {},
164
+ sessions: {},
165
+ models: {},
166
+ activeSession: '',
167
+ settings: defaultSettings(),
168
+ }
169
+ }
170
+
171
+ function loadState(): State {
172
+ try {
173
+ if (existsSync(STATE_FILE)) {
174
+ const raw = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as Partial<State>
175
+ const base = defaultState()
176
+ const merged: State = {
177
+ ...base,
178
+ ...raw,
179
+ settings: { ...base.settings, ...(raw.settings || {}) },
180
+ }
181
+ // 补全嵌套字段,避免旧版本缺字段
182
+ merged.settings.display = { ...base.settings.display, ...(raw.settings?.display || {}) }
183
+ merged.settings.limits = { ...base.settings.limits, ...(raw.settings?.limits || {}) }
184
+ merged.settings.pricing = raw.settings?.pricing || {}
185
+ merged.daily = raw.daily || {}
186
+ merged.weekly = raw.weekly || {}
187
+ merged.monthly = raw.monthly || {}
188
+ merged.sessions = raw.sessions || {}
189
+ merged.models = raw.models || {}
190
+ merged.global = { ...emptyEntry(), ...(raw.global || {}) }
191
+ return merged
192
+ }
193
+ } catch (e) {
194
+ console.warn(`[${PLUGIN_ID}] load state failed, use default:`, e)
195
+ }
196
+ return defaultState()
197
+ }
198
+
199
+ function saveState(state: State): void {
200
+ try {
201
+ mkdirSync(DATA_DIR, { recursive: true })
202
+ writeFileSync(STATE_FILE, JSON.stringify(state))
203
+ } catch (e) {
204
+ console.warn(`[${PLUGIN_ID}] save state failed:`, e)
205
+ }
206
+ }
207
+
208
+ // 从事件载荷中尽量稳妥地抽取用量桶 + 元数据
209
+ function extractUsage(data: any): { buckets: Buckets; model: string; session: string; id?: string } | null {
210
+ if (!data) return null
211
+ const u = data.usage ?? data
212
+ const input =
213
+ u.input_tokens ?? u.prompt_tokens ?? u.uncachedInput ?? u.input ?? u.total_input_tokens ?? 0
214
+ const cacheRead =
215
+ u.cache_read_tokens ?? u.cacheRead ?? u.prompt_cache_hit_tokens ?? u.cache_read ?? 0
216
+ const cacheWrite = u.cache_write_tokens ?? u.cacheWrite ?? u.cache_write ?? 0
217
+ const output =
218
+ u.output_tokens ?? u.completion_tokens ?? u.output ?? u.total_output_tokens ?? 0
219
+ if (!input && !cacheRead && !cacheWrite && !output) return null
220
+ const model: string = data.model ?? u.model ?? 'unknown'
221
+ const session: string = data.session ?? u.session ?? data.sessionId ?? u.sessionId ?? ''
222
+ const id: string | undefined = data.id ?? u.id ?? data.seq ?? u.seq
223
+ return {
224
+ buckets: { input: Number(input) || 0, cacheRead: Number(cacheRead) || 0, cacheWrite: Number(cacheWrite) || 0, output: Number(output) || 0 },
225
+ model,
226
+ session,
227
+ id,
228
+ }
229
+ }
230
+
231
+ // ---- 插件入口 --------------------------------------------------------------
232
+ export const name = PLUGIN_ID
233
+ export const inject = ['events']
234
+ export function apply(ctx: Context): void {
235
+ const state = loadState()
236
+ let saveTimer: NodeJS.Timeout | null = null
237
+
238
+ const scheduleSave = (): void => {
239
+ if (saveTimer) return
240
+ saveTimer = setTimeout(() => {
241
+ saveTimer = null
242
+ saveState(state)
243
+ }, 400)
244
+ }
245
+
246
+ const pushState = (): void => {
247
+ ctx.events.emit(`${PLUGIN_ID}:state`, state)
248
+ }
249
+
250
+ // 累计一次用量
251
+ const countedIds = new Set<string>()
252
+ const accumulate = (data: any): void => {
253
+ const ex = extractUsage(data)
254
+ if (!ex) return
255
+ if (ex.id) {
256
+ if (countedIds.has(ex.id)) return
257
+ countedIds.add(ex.id)
258
+ }
259
+ const modelKey = ex.model || 'unknown'
260
+ if (ex.session) state.activeSession = ex.session
261
+ const pricing = mergedPricing(state.settings)
262
+ const cost = costFor(modelKey, ex.buckets, pricing)
263
+ const e: Entry = { ...ex.buckets, cost, calls: 1 }
264
+
265
+ addEntry(state.global, e)
266
+ const dk = todayKey()
267
+ state.daily[dk] = state.daily[dk] || emptyEntry()
268
+ addEntry(state.daily[dk], e)
269
+ const wk = weekKey()
270
+ state.weekly[wk] = state.weekly[wk] || emptyEntry()
271
+ addEntry(state.weekly[wk], e)
272
+ const mk = monthKey()
273
+ state.monthly[mk] = state.monthly[mk] || emptyEntry()
274
+ addEntry(state.monthly[mk], e)
275
+ if (ex.session) {
276
+ state.sessions[ex.session] = state.sessions[ex.session] || emptyEntry()
277
+ addEntry(state.sessions[ex.session], e)
278
+ }
279
+ state.models[modelKey] = state.models[modelKey] || emptyEntry()
280
+ addEntry(state.models[modelKey], e)
281
+
282
+ scheduleSave()
283
+ pushState()
284
+ }
285
+
286
+ // 监听用量事件(兼容多种事件名/字段形态)
287
+ ctx.effect(() => {
288
+ const onUsage = (data: any) => accumulate(data)
289
+ ctx.events.on('assistant/message.usage', onUsage)
290
+ ctx.events.on('usage', onUsage)
291
+ ctx.events.on('assistant/message', (data: any) => {
292
+ if (data && (data.usage || (data.data && data.data.usage))) onUsage(data)
293
+ })
294
+ return () => {
295
+ ctx.events.off('assistant/message.usage', onUsage)
296
+ ctx.events.off('usage', onUsage)
297
+ }
298
+ })
299
+
300
+ // 响应 Client 指令
301
+ ctx.effect(() => {
302
+ const onCmd = (msg: { type: string; payload?: any }) => {
303
+ if (!msg || typeof msg.type !== 'string') return
304
+ switch (msg.type) {
305
+ case 'getState':
306
+ pushState()
307
+ break
308
+ case 'setSettings':
309
+ state.settings = { ...state.settings, ...(msg.payload || {}) }
310
+ scheduleSave()
311
+ pushState()
312
+ break
313
+ case 'resetSettings':
314
+ state.settings = defaultSettings()
315
+ scheduleSave()
316
+ pushState()
317
+ break
318
+ case 'clearUsage':
319
+ state.global = emptyEntry()
320
+ state.daily = {}
321
+ state.weekly = {}
322
+ state.monthly = {}
323
+ state.sessions = {}
324
+ state.models = {}
325
+ countedIds.clear()
326
+ scheduleSave()
327
+ pushState()
328
+ break
329
+ default:
330
+ break
331
+ }
332
+ }
333
+ ctx.events.on(`${PLUGIN_ID}:cmd`, onCmd)
334
+ return () => ctx.events.off(`${PLUGIN_ID}:cmd`, onCmd)
335
+ })
336
+
337
+ // 初始推送
338
+ pushState()
339
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,59 @@
1
+ // 中英文文案
2
+ type Dict = Record<string, { zh: string; en: string }>
3
+
4
+ const I18N: Dict = {
5
+ title: { zh: 'Token 仪表盘', en: 'Token Gauge' },
6
+ settings: { zh: '设置', en: 'Settings' },
7
+ style: { zh: '风格', en: 'Style' },
8
+ palette: { zh: '配色', en: 'Palette' },
9
+ display: { zh: '显示内容', en: 'Display' },
10
+ metric: { zh: '用量口径', en: 'Metric' },
11
+ limit: { zh: '限额', en: 'Limit' },
12
+ limitGlobal: { zh: '全局限额', en: 'Global limit' },
13
+ limitDaily: { zh: '日限额', en: 'Daily limit' },
14
+ limitWeekly: { zh: '周限额', en: 'Weekly limit' },
15
+ limitMonthly: { zh: '月限额', en: 'Monthly limit' },
16
+ usage: { zh: '用量', en: 'Usage' },
17
+ cost: { zh: '费用', en: 'Cost' },
18
+ input: { zh: '输入', en: 'Input' },
19
+ output: { zh: '输出', en: 'Output' },
20
+ cacheMiss: { zh: '缓存未命中', en: 'Cache miss' },
21
+ lang: { zh: '语言', en: 'Language' },
22
+ export: { zh: '导出报表', en: 'Export report' },
23
+ exportCsv: { zh: 'CSV', en: 'CSV' },
24
+ exportJson: { zh: 'JSON', en: 'JSON' },
25
+ close: { zh: '关闭', en: 'Close' },
26
+ save: { zh: '保存', en: 'Save' },
27
+ reset: { zh: '恢复默认', en: 'Reset' },
28
+ clear: { zh: '清空用量', en: 'Clear usage' },
29
+ advanced: { zh: '高级·定价(¥/百万token)', en: 'Advanced · Pricing (¥/M tokens)' },
30
+ overLimit: { zh: '已超出限额!', en: 'Over limit!' },
31
+ noData: { zh: '暂无用量数据,开始对话后此处会实时更新。', en: 'No usage yet. Start a chat to see live updates.' },
32
+ autoScale: { zh: '自动量程', en: 'auto' },
33
+ metricGlobal: { zh: '全局累计', en: 'Global' },
34
+ metricSession: { zh: '当前会话', en: 'Session' },
35
+ metricToday: { zh: '今日', en: 'Today' },
36
+ metricDaily: { zh: '每日', en: 'Daily' },
37
+ metricWeekly: { zh: '每周', en: 'Weekly' },
38
+ metricMonthly: { zh: '每月', en: 'Monthly' },
39
+ inputTokens: { zh: '输入 Token', en: 'Input tokens' },
40
+ outputTokens: { zh: '输出 Token', en: 'Output tokens' },
41
+ cacheHit: { zh: '缓存命中', en: 'Cache hit' },
42
+ cacheWrite: { zh: '缓存写入', en: 'Cache write' },
43
+ model: { zh: '模型', en: 'Model' },
44
+ period: { zh: '周期', en: 'Period' },
45
+ totalCost: { zh: '总费用', en: 'Total cost' },
46
+ totalTokens: { zh: '总 Token', en: 'Total tokens' },
47
+ calls: { zh: '调用次数', en: 'Calls' },
48
+ hintLimit: { zh: '留空表示不限(仪表盘自动量程)。', en: 'Leave empty for no limit (auto scale).' },
49
+ }
50
+
51
+ export function t(lang: 'zh' | 'en', key: string): string {
52
+ const e = I18N[key]
53
+ if (!e) return key
54
+ return e[lang] ?? e.zh
55
+ }
56
+
57
+ export function metricLabel(lang: 'zh' | 'en', metric: string): string {
58
+ return t(lang, 'metric' + metric.charAt(0).toUpperCase() + metric.slice(1))
59
+ }
@@ -0,0 +1,270 @@
1
+ // 侧边栏内设置弹窗:风格 / 配色 / 显示内容 / 用量口径 / 限额 / 定价 / 语言 / 导出
2
+ import { STYLES, PALETTES } from './styles'
3
+ import { t, metricLabel } from './i18n'
4
+
5
+ type Lang = 'zh' | 'en'
6
+ interface DisplayFlags {
7
+ usage: boolean
8
+ cost: boolean
9
+ io: boolean
10
+ cacheMiss: boolean
11
+ }
12
+ interface Settings {
13
+ style: string
14
+ palette: string
15
+ lang: Lang
16
+ metric: string
17
+ display: DisplayFlags
18
+ limits: { global: number | null; daily: number | null; weekly: number | null; monthly: number | null }
19
+ pricing: Record<string, any>
20
+ }
21
+ interface State {
22
+ settings: Settings
23
+ }
24
+
25
+ export interface SettingsModalOpts {
26
+ onSettings: (patch: Partial<Settings>) => void
27
+ onExport: (format: 'csv' | 'json') => void
28
+ onClear: () => void
29
+ onReset: () => void
30
+ getLang: () => Lang
31
+ }
32
+
33
+ export interface SettingsModal {
34
+ el: HTMLElement
35
+ open: (state: State) => void
36
+ close: () => void
37
+ sync: (state: State) => void
38
+ }
39
+
40
+ function chip(label: string, on: boolean, onClick: () => void): HTMLElement {
41
+ const c = document.createElement('div')
42
+ c.className = 'tg-chip' + (on ? ' on' : '')
43
+ c.textContent = label
44
+ c.addEventListener('click', onClick)
45
+ return c
46
+ }
47
+
48
+ export function createSettingsModal(opts: SettingsModalOpts): SettingsModal {
49
+ const mask = document.createElement('div')
50
+ mask.className = 'tg-modal-mask'
51
+ mask.style.display = 'none'
52
+ const box = document.createElement('div')
53
+ box.className = 'tg-modal'
54
+ mask.appendChild(box)
55
+
56
+ let openState: State | null = null
57
+ let isOpen = false
58
+
59
+ const render = (state: State) => {
60
+ const lang = opts.getLang()
61
+ const s = state.settings
62
+ box.innerHTML = ''
63
+ const title = document.createElement('h3')
64
+ title.textContent = t(lang, 'settings')
65
+ box.appendChild(title)
66
+
67
+ // 语言
68
+ const langField = document.createElement('div')
69
+ langField.className = 'tg-field'
70
+ langField.innerHTML = `<label>${t(lang, 'lang')}</label>`
71
+ const langRow = document.createElement('div')
72
+ langRow.className = 'tg-row'
73
+ langRow.append(
74
+ chip('中文', s.lang === 'zh', () => opts.onSettings({ lang: 'zh' })),
75
+ chip('English', s.lang === 'en', () => opts.onSettings({ lang: 'en' })),
76
+ )
77
+ langField.appendChild(langRow)
78
+ box.appendChild(langField)
79
+
80
+ // 风格
81
+ const styleField = document.createElement('div')
82
+ styleField.className = 'tg-field'
83
+ styleField.innerHTML = `<label>${t(lang, 'style')}</label>`
84
+ const styleRow = document.createElement('div')
85
+ styleRow.className = 'tg-row'
86
+ for (const st of STYLES) {
87
+ styleRow.appendChild(
88
+ chip(lang === 'zh' ? st.labelZh : st.labelEn, s.style === st.id, () => opts.onSettings({ style: st.id })),
89
+ )
90
+ }
91
+ styleField.appendChild(styleRow)
92
+ box.appendChild(styleField)
93
+
94
+ // 配色
95
+ const palField = document.createElement('div')
96
+ palField.className = 'tg-field'
97
+ palField.innerHTML = `<label>${t(lang, 'palette')}</label>`
98
+ const palRow = document.createElement('div')
99
+ palRow.className = 'tg-row'
100
+ for (const p of PALETTES) {
101
+ palRow.appendChild(
102
+ chip(lang === 'zh' ? p.labelZh : p.labelEn, s.palette === p.id, () => opts.onSettings({ palette: p.id })),
103
+ )
104
+ }
105
+ palField.appendChild(palRow)
106
+ box.appendChild(palField)
107
+
108
+ // 用量口径
109
+ const metricField = document.createElement('div')
110
+ metricField.className = 'tg-field'
111
+ metricField.innerHTML = `<label>${t(lang, 'metric')}</label>`
112
+ const metricRow = document.createElement('div')
113
+ metricRow.className = 'tg-row'
114
+ for (const m of ['global', 'session', 'today', 'daily', 'weekly', 'monthly']) {
115
+ metricRow.appendChild(chip(metricLabel(lang, m), s.metric === m, () => opts.onSettings({ metric: m })))
116
+ }
117
+ metricField.appendChild(metricRow)
118
+ box.appendChild(metricField)
119
+
120
+ // 显示内容
121
+ const dispField = document.createElement('div')
122
+ dispField.className = 'tg-field'
123
+ dispField.innerHTML = `<label>${t(lang, 'display')}</label>`
124
+ const dispKeys: { key: keyof DisplayFlags; label: string }[] = [
125
+ { key: 'usage', label: t(lang, 'usage') },
126
+ { key: 'cost', label: t(lang, 'cost') },
127
+ { key: 'io', label: t(lang, 'input') + '/' + t(lang, 'output') },
128
+ { key: 'cacheMiss', label: t(lang, 'cacheMiss') },
129
+ ]
130
+ for (const d of dispKeys) {
131
+ const row = document.createElement('label')
132
+ row.className = 'tg-toggle'
133
+ const cb = document.createElement('input')
134
+ cb.type = 'checkbox'
135
+ cb.checked = !!s.display[d.key]
136
+ cb.addEventListener('change', () => {
137
+ const next = { ...s.display, [d.key]: cb.checked }
138
+ opts.onSettings({ display: next })
139
+ })
140
+ row.append(cb, document.createTextNode(d.label))
141
+ dispField.appendChild(row)
142
+ }
143
+ box.appendChild(dispField)
144
+
145
+ // 限额
146
+ const limitField = document.createElement('div')
147
+ limitField.className = 'tg-field'
148
+ limitField.innerHTML = `<label>${t(lang, 'limit')}</label>`
149
+ const limitDefs: { key: keyof Settings['limits']; label: string }[] = [
150
+ { key: 'global', label: t(lang, 'limitGlobal') },
151
+ { key: 'daily', label: t(lang, 'limitDaily') },
152
+ { key: 'weekly', label: t(lang, 'limitWeekly') },
153
+ { key: 'monthly', label: t(lang, 'limitMonthly') },
154
+ ]
155
+ for (const ld of limitDefs) {
156
+ const wrap = document.createElement('div')
157
+ const lab = document.createElement('label')
158
+ lab.className = 'tg-toggle'
159
+ lab.textContent = ld.label
160
+ const inp = document.createElement('input')
161
+ inp.className = 'tg-input'
162
+ inp.type = 'number'
163
+ inp.min = '0'
164
+ inp.placeholder = '∞'
165
+ inp.value = s.limits[ld.key] == null ? '' : String(s.limits[ld.key])
166
+ inp.addEventListener('change', () => {
167
+ const v = inp.value.trim()
168
+ const val = v === '' ? null : Math.max(0, Number(v))
169
+ if (v !== '' && Number.isNaN(Number(v))) return
170
+ opts.onSettings({ limits: { ...s.limits, [ld.key]: val } })
171
+ })
172
+ wrap.append(lab, inp)
173
+ limitField.appendChild(wrap)
174
+ }
175
+ const hint = document.createElement('div')
176
+ hint.className = 'tg-hint'
177
+ hint.textContent = t(lang, 'hintLimit')
178
+ limitField.appendChild(hint)
179
+ box.appendChild(limitField)
180
+
181
+ // 高级定价
182
+ const advField = document.createElement('div')
183
+ advField.className = 'tg-field'
184
+ advField.innerHTML = `<label>${t(lang, 'advanced')}</label>`
185
+ const ta = document.createElement('textarea')
186
+ ta.className = 'tg-input'
187
+ ta.rows = 5
188
+ ta.style.fontFamily = 'monospace'
189
+ ta.value = JSON.stringify(s.pricing || {}, null, 2)
190
+ advField.appendChild(ta)
191
+ const advErr = document.createElement('div')
192
+ advErr.className = 'tg-hint'
193
+ advErr.style.color = 'var(--c-danger)'
194
+ const savePrice = document.createElement('button')
195
+ savePrice.className = 'tg-btn ghost'
196
+ savePrice.style.marginTop = '6px'
197
+ savePrice.textContent = t(lang, 'save')
198
+ savePrice.addEventListener('click', () => {
199
+ try {
200
+ const parsed = JSON.parse(ta.value || '{}')
201
+ opts.onSettings({ pricing: parsed })
202
+ advErr.textContent = ''
203
+ } catch (e) {
204
+ advErr.textContent = String(e)
205
+ }
206
+ })
207
+ advField.append(savePrice, advErr)
208
+ box.appendChild(advField)
209
+
210
+ // 导出
211
+ const expField = document.createElement('div')
212
+ expField.className = 'tg-field'
213
+ expField.innerHTML = `<label>${t(lang, 'export')}</label>`
214
+ const expRow = document.createElement('div')
215
+ expRow.className = 'tg-actions'
216
+ const csvBtn = document.createElement('button')
217
+ csvBtn.className = 'tg-btn ghost'
218
+ csvBtn.textContent = 'CSV'
219
+ csvBtn.addEventListener('click', () => opts.onExport('csv'))
220
+ const jsonBtn = document.createElement('button')
221
+ jsonBtn.className = 'tg-btn ghost'
222
+ jsonBtn.textContent = 'JSON'
223
+ jsonBtn.addEventListener('click', () => opts.onExport('json'))
224
+ expRow.append(csvBtn, jsonBtn)
225
+ expField.appendChild(expRow)
226
+ box.appendChild(expField)
227
+
228
+ // 操作
229
+ const act = document.createElement('div')
230
+ act.className = 'tg-actions'
231
+ const clearBtn = document.createElement('button')
232
+ clearBtn.className = 'tg-btn ghost'
233
+ clearBtn.textContent = t(lang, 'clear')
234
+ clearBtn.addEventListener('click', () => opts.onClear())
235
+ const resetBtn = document.createElement('button')
236
+ resetBtn.className = 'tg-btn ghost'
237
+ resetBtn.textContent = t(lang, 'reset')
238
+ resetBtn.addEventListener('click', () => opts.onReset())
239
+ const closeBtn = document.createElement('button')
240
+ closeBtn.className = 'tg-btn'
241
+ closeBtn.textContent = t(lang, 'close')
242
+ closeBtn.addEventListener('click', () => exports.close())
243
+ act.append(clearBtn, resetBtn, closeBtn)
244
+ box.appendChild(act)
245
+ }
246
+
247
+ const exports: SettingsModal = {
248
+ el: mask,
249
+ open: (state: State) => {
250
+ openState = state
251
+ isOpen = true
252
+ render(state)
253
+ mask.style.display = 'flex'
254
+ },
255
+ close: () => {
256
+ isOpen = false
257
+ mask.style.display = 'none'
258
+ },
259
+ sync: (state: State) => {
260
+ openState = state
261
+ if (isOpen) render(state)
262
+ },
263
+ }
264
+
265
+ mask.addEventListener('click', (e) => {
266
+ if (e.target === mask) exports.close()
267
+ })
268
+
269
+ return exports
270
+ }