opencode-visual-cache 1.4.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "types": "dist/index.d.ts",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.4.0";
2
+ export const PLUGIN_VERSION="1.6.0";
@@ -0,0 +1,153 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Balance providers — pluggable account-balance query adapters.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /** 归一化后的余额条目——显示层与具体 provider 解耦。 */
6
+ export interface BalanceEntry {
7
+ currency: string // 原生币种(CNY/USD…),复用现有汇率换算
8
+ total: string // 余额字符串
9
+ }
10
+
11
+ /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
12
+ export class BalanceError extends Error {}
13
+
14
+ /** 可插拔的余额 provider 适配器。 */
15
+ export interface BalanceProvider {
16
+ id: string // 唯一标识,同时用作 KV key 命名空间
17
+ name: string // 显示名(专有名词,无需 i18n)
18
+ keyPlaceholder?: string // key 输入框占位(如 "sk-...")
19
+ fetchBalance(apiKey: string, signal?: AbortSignal): Promise<BalanceEntry[]>
20
+ }
21
+
22
+ const siliconflowProvider: BalanceProvider = {
23
+ id: "siliconflow",
24
+ name: "SiliconFlow",
25
+ keyPlaceholder: "sk-...",
26
+ async fetchBalance(apiKey, signal) {
27
+ // 国内站 api.siliconflow.cn(CNY);国际站为 api.siliconflow.com(USD)
28
+ const res = await fetch("https://api.siliconflow.cn/v1/user/info", {
29
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
30
+ signal,
31
+ })
32
+ if (!res.ok) {
33
+ if (res.status === 401) throw new BalanceError("401")
34
+ if (res.status === 403) throw new BalanceError("403")
35
+ throw new BalanceError(String(res.status))
36
+ }
37
+ const json = await res.json() as {
38
+ status?: boolean
39
+ data?: {
40
+ balance?: string | number
41
+ chargeBalance?: string | number
42
+ totalBalance?: string | number
43
+ }
44
+ }
45
+ // totalBalance 为总余额(含充值+赠送),缺失时回退 balance
46
+ const total = json.data?.totalBalance ?? json.data?.balance
47
+ if (typeof total === "undefined" || total === null) throw new BalanceError("EMPTY")
48
+ return [{ currency: "CNY", total: String(total) }]
49
+ },
50
+ }
51
+
52
+ const deepseekProvider: BalanceProvider = {
53
+ id: "deepseek",
54
+ name: "DeepSeek",
55
+ keyPlaceholder: "sk-...",
56
+ async fetchBalance(apiKey, signal) {
57
+ const res = await fetch("https://api.deepseek.com/user/balance", {
58
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
59
+ signal,
60
+ })
61
+ if (!res.ok) {
62
+ if (res.status === 401) throw new BalanceError("401")
63
+ if (res.status === 402 || res.status === 403) throw new BalanceError("403")
64
+ throw new BalanceError(String(res.status))
65
+ }
66
+ const json = await res.json() as {
67
+ is_available?: boolean
68
+ balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[]
69
+ }
70
+ const infos = json.balance_infos ?? []
71
+ if (infos.length === 0) throw new BalanceError("EMPTY")
72
+ return infos.map((info) => ({
73
+ currency: info.currency ?? "CNY",
74
+ total: info.total_balance ?? "0",
75
+ }))
76
+ },
77
+ }
78
+
79
+ const openrouterProvider: BalanceProvider = {
80
+ id: "openrouter",
81
+ name: "OpenRouter",
82
+ keyPlaceholder: "sk-or-...",
83
+ async fetchBalance(apiKey, signal) {
84
+ // 官方文档标注需 Management key,实测普通 API key 亦可查询账户余额
85
+ const res = await fetch("https://openrouter.ai/api/v1/credits", {
86
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
87
+ signal,
88
+ })
89
+ if (!res.ok) {
90
+ if (res.status === 401 || res.status === 403) throw new BalanceError("403")
91
+ throw new BalanceError(String(res.status))
92
+ }
93
+ const json = await res.json() as {
94
+ data?: { total_credits?: number; total_usage?: number }
95
+ }
96
+ const credits = json.data?.total_credits
97
+ const usage = json.data?.total_usage
98
+ if (typeof credits !== "number" || typeof usage !== "number") throw new BalanceError("EMPTY")
99
+ // 剩余额度 = 充值总额 - 已用
100
+ return [{ currency: "USD", total: (credits - usage).toFixed(2) }]
101
+ },
102
+ }
103
+
104
+ const moonshotProvider: BalanceProvider = {
105
+ id: "moonshot",
106
+ name: "Moonshot",
107
+ keyPlaceholder: "sk-...",
108
+ async fetchBalance(apiKey, signal) {
109
+ // 国内站 api.moonshot.cn(CNY);国际站 api.moonshot.ai(USD)
110
+ const res = await fetch("https://api.moonshot.cn/v1/users/me/balance", {
111
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
112
+ signal,
113
+ })
114
+ if (!res.ok) {
115
+ if (res.status === 401) throw new BalanceError("401")
116
+ if (res.status === 403) throw new BalanceError("403")
117
+ throw new BalanceError(String(res.status))
118
+ }
119
+ const json = await res.json() as {
120
+ data?: { available_balance?: string | number }
121
+ }
122
+ const balance = json.data?.available_balance
123
+ if (typeof balance === "undefined" || balance === null) throw new BalanceError("EMPTY")
124
+ return [{ currency: "CNY", total: String(balance) }]
125
+ },
126
+ }
127
+
128
+ /** 已注册的 provider 列表(按需追加新适配器)。 */
129
+ export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider]
130
+
131
+ /** 按 id 取 provider;未知 id 回退到第一个。 */
132
+ export function getBalanceProvider(id: string): BalanceProvider {
133
+ return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider
134
+ }
135
+
136
+ /**
137
+ * 按 OpenCode providerID 匹配余额 provider。
138
+ * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。
139
+ * 比较不区分大小写,容忍 providerID 的大小写变体。
140
+ */
141
+ export function matchBalanceProvider(providerId: string): BalanceProvider | undefined {
142
+ const id = providerId.toLowerCase()
143
+ const exact = balanceProviders.find((p) => p.id.toLowerCase() === id)
144
+ if (exact) return exact
145
+ return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase()))
146
+ }
147
+
148
+ /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */
149
+ export function maskKey(k: string): string {
150
+ if (!k) return ""
151
+ if (k.length <= 10) return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 5))
152
+ return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 10)) + k.slice(-5)
153
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,380 @@
1
+ // ---------------------------------------------------------------------------
2
+ // i18n — centralized translations. Add a language by appending a table that
3
+ // satisfies `Translation`; the compiler enforces key completeness.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ export type LangCode = "zh" | "en" | "ja" | "ko"
7
+
8
+ const ZH_T = {
9
+ title: "缓存统计",
10
+ hit: "命中率",
11
+ totalHit: "总命中:",
12
+ read: "缓存读:",
13
+ write: "缓存写:",
14
+ miss: "未命中:",
15
+ out: "输出:",
16
+ cost: "费用:",
17
+ saved: "累计节省:",
18
+ model: "模型:",
19
+ provider: "提供商:",
20
+ rate: "单价:",
21
+ hitFolded: "命中",
22
+ inputRate: "输入",
23
+ cacheRate: "缓存",
24
+ writeRate: "写入",
25
+ noData: "等待缓存数据...",
26
+ tok: "tok",
27
+ distTitle: "估算 Token 分布",
28
+ distSys: "系统提示:",
29
+ distUser: "用户:",
30
+ distAgent: "Agent 指令:",
31
+ distTool: "Tool 调用:",
32
+ distRes: "Tool 结果:",
33
+ distTotal: "总计:",
34
+ distOut: "输出:",
35
+ secDetail: "明细",
36
+ secModel: "模型",
37
+ secSkills: "已加载技能",
38
+ balTotal: "总余额:",
39
+ balNoKey: "未配置 {p} API Key",
40
+ balLoading: "查询中...",
41
+ balError: "查询失败",
42
+ balErr401: "API Key 无效",
43
+ balErr403: "余额查询被拒绝",
44
+ balErrEmpty:"未获取到余额数据",
45
+ balErrTimeout: "查询超时",
46
+ balUnsupported: "当前提供商不支持余额查询",
47
+ barHit: "命中率",
48
+ barBal: "余额",
49
+ barTok: "Tokens",
50
+ // ── /cache-section 面板 ──
51
+ secToggle: "切换区块",
52
+ secBalance: "余额",
53
+ secBottom: "底部状态栏",
54
+ secBorder: "面板边框",
55
+ // ── toast ──
56
+ keySaved: "API Key 已保存,正在查询余额...",
57
+ keyCleared: "API Key 已清除",
58
+ currencySet: "币种: {v} ({s}), 汇率: {r}",
59
+ rateSet: "汇率已设为 {r}",
60
+ panelConfigTitle: "缓存面板配置",
61
+ panelConfigMsg: "币种: {c} | 汇率: {r} | 明细: {d} | 模型: {m} | 分布: {t} | 技能: {k} | 余额: {b} | 底部: {f}",
62
+ borderShown: "面板边框 已显示",
63
+ borderHidden: "面板边框 已隐藏",
64
+ sectionShown: "{s} 已显示",
65
+ sectionHidden: "{s} 已隐藏",
66
+ langSwitched: "语言已切换为中文",
67
+ autoSwitchOn: "自动切换余额提供商: 开",
68
+ autoSwitchOff: "自动切换余额提供商: 关",
69
+ providerManual: "余额提供商: {p}(自动切换已关闭)",
70
+ runInSession: "请在会话内运行此命令",
71
+ backToMain: "已切回主会话",
72
+ subAgentSwitched: "已切换至子代理: {s}",
73
+ // ── 对话框 / 菜单 ──
74
+ langTitle: "显示语言",
75
+ subPrefix: "子代理: ",
76
+ keyUser: "(用户 key)",
77
+ keyOpenCode: "(OpenCode)",
78
+ keyNotSet: "(未配置)",
79
+ balKeyPrompt: "输入 {p} API Key 以显示账户余额(留空清除)",
80
+ balProvTitle: "余额提供商 / 自动切换",
81
+ autoSwitchOpt: "自动切换提供商",
82
+ balSelectTitle: "选择余额提供商",
83
+ backToMainTitle: "回到主会话",
84
+ subSelectTitle: "选择子代理",
85
+ subSwitchTitle: "切换子代理",
86
+ subViewTitle: "查看子代理缓存",
87
+ subNoFound: "未找到子代理,请手动粘贴 Session ID",
88
+ } as const
89
+
90
+ /** 结构约束:值放宽为 string,键集合来自中文表(新增语言缺 key 会编译报错)。 */
91
+ export type Translation = { [K in keyof typeof ZH_T]: string }
92
+
93
+ const EN_T: Translation = {
94
+ title: "Token Cache",
95
+ hit: "Hit",
96
+ totalHit: "Total Hit:",
97
+ read: "Read:",
98
+ write: "Write:",
99
+ miss: "Miss:",
100
+ out: "Out:",
101
+ cost: "Cost:",
102
+ saved: "Total Saved:",
103
+ model: "Model:",
104
+ provider: "Provider:",
105
+ rate: "Rate:",
106
+ hitFolded: "hit",
107
+ inputRate: "in",
108
+ cacheRate: "cache",
109
+ writeRate: "write",
110
+ noData: "Waiting for cache data...",
111
+ tok: "tok",
112
+ distTitle: "Estimated Token Dist.",
113
+ distSys: "System:",
114
+ distUser: "User:",
115
+ distAgent: "Agent Instr:",
116
+ distTool: "Tool Call:",
117
+ distRes: "Tool Result:",
118
+ distTotal: "Total:",
119
+ distOut: "Output:",
120
+ secDetail: "Detail",
121
+ secModel: "Model",
122
+ secSkills: "Loaded Skills",
123
+ balTotal: "Total:",
124
+ balNoKey: "{p} API Key not set",
125
+ balLoading: "Fetching...",
126
+ balError: "Fetch failed",
127
+ balErr401: "Invalid API Key",
128
+ balErr403: "Balance request rejected",
129
+ balErrEmpty:"No balance data",
130
+ balErrTimeout: "Request timed out",
131
+ balUnsupported: "Balance query unsupported",
132
+ barHit: "Hit",
133
+ barBal: "Balance",
134
+ barTok: "Tokens",
135
+ // ── /cache-section panel ──
136
+ secToggle: "Toggle Section",
137
+ secBalance: "Balance",
138
+ secBottom: "Bottom Bar",
139
+ secBorder: "Panel Border",
140
+ // ── toasts ──
141
+ keySaved: "API Key saved, fetching balance...",
142
+ keyCleared: "API Key cleared",
143
+ currencySet: "Currency: {v} ({s}), rate: {r}",
144
+ rateSet: "Exchange rate set to {r}",
145
+ panelConfigTitle: "Cache Panel Config",
146
+ panelConfigMsg: "Currency: {c} | Rate: {r} | Detail: {d} | Model: {m} | Dist: {t} | Skills: {k} | Balance: {b} | Bottom: {f}",
147
+ borderShown: "Panel border shown",
148
+ borderHidden: "Panel border hidden",
149
+ sectionShown: "{s} section shown",
150
+ sectionHidden: "{s} section hidden",
151
+ langSwitched: "Switched to English",
152
+ autoSwitchOn: "Auto-switch balance provider: ON",
153
+ autoSwitchOff: "Auto-switch balance provider: OFF",
154
+ providerManual: "Balance provider: {p} (auto-switch off)",
155
+ runInSession: "Please run this command inside a session",
156
+ backToMain: "Switched to main session",
157
+ subAgentSwitched: "Showing sub-agent: {s}",
158
+ // ── dialogs / menus ──
159
+ langTitle: "Display Language",
160
+ subPrefix: "Sub: ",
161
+ keyUser: " (user key)",
162
+ keyOpenCode: " (OpenCode)",
163
+ keyNotSet: " (not set)",
164
+ balKeyPrompt: "Enter your {p} API key to show account balance (leave empty to clear)",
165
+ balProvTitle: "Balance Provider / Auto-switch",
166
+ autoSwitchOpt: "Auto-switch provider",
167
+ balSelectTitle: "Select Balance Provider",
168
+ backToMainTitle: "Back to Main",
169
+ subSelectTitle: "Select Sub-Agent",
170
+ subSwitchTitle: "Switch Sub",
171
+ subViewTitle: "View Sub Cache",
172
+ subNoFound: "No sub-agents found. Paste a Session ID manually",
173
+ }
174
+
175
+ const JA_T: Translation = {
176
+ title: "キャッシュ統計",
177
+ hit: "ヒット率",
178
+ totalHit: "総ヒット:",
179
+ read: "キャッシュ読込:",
180
+ write: "キャッシュ書込:",
181
+ miss: "未ヒット:",
182
+ out: "出力:",
183
+ cost: "費用:",
184
+ saved: "累計節約:",
185
+ model: "モデル:",
186
+ provider: "プロバイダ:",
187
+ rate: "単価:",
188
+ hitFolded: "ヒット",
189
+ inputRate: "入力",
190
+ cacheRate: "キャッシュ",
191
+ writeRate: "書込",
192
+ noData: "キャッシュデータ待機中...",
193
+ tok: "tok",
194
+ distTitle: "Token 分布(推定)",
195
+ distSys: "システム:",
196
+ distUser: "ユーザー:",
197
+ distAgent: "Agent 指示:",
198
+ distTool: "Tool 呼び出し:",
199
+ distRes: "Tool 結果:",
200
+ distTotal: "合計:",
201
+ distOut: "出力:",
202
+ secDetail: "明細",
203
+ secModel: "モデル",
204
+ secSkills: "読み込み済みスキル",
205
+ balTotal: "総残高:",
206
+ balNoKey: "{p} の API Key 未設定",
207
+ balLoading: "取得中...",
208
+ balError: "取得失敗",
209
+ balErr401: "API Key 無効",
210
+ balErr403: "残高照会が拒否されました",
211
+ balErrEmpty:"残高データなし",
212
+ balErrTimeout: "タイムアウト",
213
+ balUnsupported: "このプロバイダは残高照会非対応",
214
+ barHit: "ヒット率",
215
+ barBal: "残高",
216
+ barTok: "Tokens",
217
+ // ── /cache-section パネル ──
218
+ secToggle: "セクション切替",
219
+ secBalance: "残高",
220
+ secBottom: "下部ステータスバー",
221
+ secBorder: "パネル枠線",
222
+ // ── toast ──
223
+ keySaved: "API Key 保存済み、残高を取得中...",
224
+ keyCleared: "API Key をクリアしました",
225
+ currencySet: "通貨: {v} ({s}), レート: {r}",
226
+ rateSet: "レートを {r} に設定しました",
227
+ panelConfigTitle: "パネル設定",
228
+ panelConfigMsg: "通貨: {c} | レート: {r} | 明細: {d} | モデル: {m} | 分布: {t} | スキル: {k} | 残高: {b} | 下部: {f}",
229
+ borderShown: "パネル枠線 表示",
230
+ borderHidden: "パネル枠線 非表示",
231
+ sectionShown: "{s} 表示",
232
+ sectionHidden: "{s} 非表示",
233
+ langSwitched: "日本語に切り替えました",
234
+ autoSwitchOn: "残高プロバイダ自動切替: オン",
235
+ autoSwitchOff: "残高プロバイダ自動切替: オフ",
236
+ providerManual: "残高プロバイダ: {p}(自動切替オフ)",
237
+ runInSession: "セッション内で実行してください",
238
+ backToMain: "メインセッションに戻りました",
239
+ subAgentSwitched: "サブエージェントへ切替: {s}",
240
+ // ── ダイアログ / メニュー ──
241
+ langTitle: "表示言語",
242
+ subPrefix: "サブ: ",
243
+ keyUser: "(ユーザー key)",
244
+ keyOpenCode: "(OpenCode)",
245
+ keyNotSet: "(未設定)",
246
+ balKeyPrompt: "{p} の API Key を入力して残高を表示(空欄でクリア)",
247
+ balProvTitle: "残高プロバイダ / 自動切替",
248
+ autoSwitchOpt: "自動切替プロバイダ",
249
+ balSelectTitle: "残高プロバイダ選択",
250
+ backToMainTitle: "メインに戻る",
251
+ subSelectTitle: "サブエージェント選択",
252
+ subSwitchTitle: "サブ切替",
253
+ subViewTitle: "サブキャッシュ表示",
254
+ subNoFound: "サブエージェントが見つかりません。Session ID を手動で貼り付けてください",
255
+ }
256
+
257
+ const KO_T: Translation = {
258
+ title: "캐시 통계",
259
+ hit: "히트율",
260
+ totalHit: "총 히트:",
261
+ read: "캐시 읽기:",
262
+ write: "캐시 쓰기:",
263
+ miss: "미히트:",
264
+ out: "출력:",
265
+ cost: "비용:",
266
+ saved: "누적 절약:",
267
+ model: "모델:",
268
+ provider: "프로바이더:",
269
+ rate: "단가:",
270
+ hitFolded: "히트",
271
+ inputRate: "입력",
272
+ cacheRate: "캐시",
273
+ writeRate: "쓰기",
274
+ noData: "캐시 데이터 대기 중...",
275
+ tok: "tok",
276
+ distTitle: "Token 분포(추정)",
277
+ distSys: "시스템:",
278
+ distUser: "사용자:",
279
+ distAgent: "Agent 지시:",
280
+ distTool: "Tool 호출:",
281
+ distRes: "Tool 결과:",
282
+ distTotal: "합계:",
283
+ distOut: "출력:",
284
+ secDetail: "상세",
285
+ secModel: "모델",
286
+ secSkills: "로드된 스킬",
287
+ balTotal: "총 잔액:",
288
+ balNoKey: "{p} API Key 미설정",
289
+ balLoading: "조회 중...",
290
+ balError: "조회 실패",
291
+ balErr401: "API Key 무효",
292
+ balErr403: "잔액 조회가 거부되었습니다",
293
+ balErrEmpty:"잔액 데이터 없음",
294
+ balErrTimeout: "시간 초과",
295
+ balUnsupported: "이 프로바이더는 잔액 조회 미지원",
296
+ barHit: "히트율",
297
+ barBal: "잔액",
298
+ barTok: "Tokens",
299
+ // ── /cache-section 패널 ──
300
+ secToggle: "섹션 전환",
301
+ secBalance: "잔액",
302
+ secBottom: "하단 상태바",
303
+ secBorder: "패널 테두리",
304
+ // ── toast ──
305
+ keySaved: "API Key 저장됨, 잔액 조회 중...",
306
+ keyCleared: "API Key 삭제됨",
307
+ currencySet: "통화: {v} ({s}), 환율: {r}",
308
+ rateSet: "환율을 {r}(으)로 설정했습니다",
309
+ panelConfigTitle: "패널 설정",
310
+ panelConfigMsg: "통화: {c} | 환율: {r} | 상세: {d} | 모델: {m} | 분포: {t} | 스킬: {k} | 잔액: {b} | 하단: {f}",
311
+ borderShown: "패널 테두리 표시",
312
+ borderHidden: "패널 테두리 숨김",
313
+ sectionShown: "{s} 표시",
314
+ sectionHidden: "{s} 숨김",
315
+ langSwitched: "한국어로 전환했습니다",
316
+ autoSwitchOn: "잔액 프로바이더 자동 전환: 켜짐",
317
+ autoSwitchOff: "잔액 프로바이더 자동 전환: 꺼짐",
318
+ providerManual: "잔액 프로바이더: {p}(자동 전환 꺼짐)",
319
+ runInSession: "세션 내에서 실행해 주세요",
320
+ backToMain: "메인 세션으로 돌아갔습니다",
321
+ subAgentSwitched: "서브 에이전트로 전환: {s}",
322
+ // ── 다이얼로그 / 메뉴 ──
323
+ langTitle: "표시 언어",
324
+ subPrefix: "서브: ",
325
+ keyUser: "(사용자 key)",
326
+ keyOpenCode: "(OpenCode)",
327
+ keyNotSet: "(미설정)",
328
+ balKeyPrompt: "{p} API Key를 입력하여 잔액 표시(비우면 삭제)",
329
+ balProvTitle: "잔액 프로바이더 / 자동 전환",
330
+ autoSwitchOpt: "자동 전환 프로바이더",
331
+ balSelectTitle: "잔액 프로바이더 선택",
332
+ backToMainTitle: "메인으로 돌아가기",
333
+ subSelectTitle: "서브 에이전트 선택",
334
+ subSwitchTitle: "서브 전환",
335
+ subViewTitle: "서브 캐시 보기",
336
+ subNoFound: "서브 에이전트를 찾을 수 없습니다. Session ID를 직접 붙여넣으세요",
337
+ }
338
+
339
+ export const LANGS: Record<LangCode, Translation> = { zh: ZH_T, en: EN_T, ja: JA_T, ko: KO_T }
340
+
341
+ /** 语言元数据:/cache-lang 选项与自动检测共用。 */
342
+ export const LANG_META: { code: LangCode; label: string }[] = [
343
+ { code: "zh", label: "中文" },
344
+ { code: "en", label: "English" },
345
+ { code: "ja", label: "日本語" },
346
+ { code: "ko", label: "한국어" },
347
+ ]
348
+
349
+ /**
350
+ * 模板参数替换:`{key}` 占位符统一在此处理。
351
+ * 未提供的参数保留原占位符,避免静默丢失。
352
+ */
353
+ export function applyParams(tpl: string, params?: Record<string, string | number>): string {
354
+ if (!params) return tpl
355
+ return tpl.replace(/\{(\w+)\}/g, (m, k: string) =>
356
+ k in params ? String(params[k]) : m,
357
+ )
358
+ }
359
+
360
+ /**
361
+ * 翻译查找函数工厂:`t("key")` 返回当前语言的文本,`t("key", { p })` 附带模板参数。
362
+ * `getCode` 读取语言信号——在 SolidJS 渲染/memo 上下文中调用时自动建立响应式依赖。
363
+ */
364
+ export function createT(getCode: () => LangCode) {
365
+ return (key: keyof Translation, params?: Record<string, string | number>): string =>
366
+ applyParams(LANGS[getCode()][key], params)
367
+ }
368
+
369
+ /** 按系统 locale 自动检测语言(zh → 中文,ja → 日语,ko → 韩语,其余 → 英文)。 */
370
+ export function detectLang(): LangCode {
371
+ try {
372
+ const loc = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase()
373
+ if (loc.startsWith("zh")) return "zh"
374
+ if (loc.startsWith("ja")) return "ja"
375
+ if (loc.startsWith("ko")) return "ko"
376
+ return "en"
377
+ } catch {
378
+ return "en"
379
+ }
380
+ }