dsh-codinput 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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.en.md +167 -0
- package/README.md +167 -0
- package/TESTING.md +95 -0
- package/assets/en/preview-ball-detail.png +0 -0
- package/assets/en/preview-float.png +0 -0
- package/assets/en/preview-normal.png +0 -0
- package/assets/en/preview-side.png +0 -0
- package/assets/en/preview-split.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/preview-ball-detail.png +0 -0
- package/assets/preview-float.png +0 -0
- package/assets/preview-normal.png +0 -0
- package/assets/preview-side.png +0 -0
- package/assets/preview-split.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +544 -0
- package/lib/index.js +5 -0
- package/package.json +79 -0
- package/src/client/attachments.ts +43 -0
- package/src/client/cm.ts +207 -0
- package/src/client/command.ts +78 -0
- package/src/client/components/AttachmentRail.tsx +241 -0
- package/src/client/components/CodinputBar.tsx +357 -0
- package/src/client/components/CodinputSurface.tsx +375 -0
- package/src/client/components/EditorPane.tsx +214 -0
- package/src/client/components/ExpandBall.tsx +122 -0
- package/src/client/components/MenuSurface.tsx +72 -0
- package/src/client/components/ModelMenu.tsx +234 -0
- package/src/client/components/PermissionMenu.tsx +257 -0
- package/src/client/components/PopupSelectView.tsx +140 -0
- package/src/client/components/SlashMenu.tsx +255 -0
- package/src/client/components/StatsRow.tsx +188 -0
- package/src/client/components/Toolbar.tsx +239 -0
- package/src/client/components/popup-filter.ts +13 -0
- package/src/client/components/useSnapshot.ts +18 -0
- package/src/client/diag.ts +58 -0
- package/src/client/host-types.ts +328 -0
- package/src/client/hotkey.ts +82 -0
- package/src/client/i18n.ts +312 -0
- package/src/client/icons.tsx +296 -0
- package/src/client/index.tsx +178 -0
- package/src/client/modeldir.ts +42 -0
- package/src/client/permission.ts +123 -0
- package/src/client/prefs.ts +123 -0
- package/src/client/services.ts +78 -0
- package/src/client/settings.tsx +236 -0
- package/src/client/sidebar.tsx +114 -0
- package/src/client/sideinput.ts +171 -0
- package/src/client/stats.ts +166 -0
- package/src/client/styles.ts +489 -0
- package/src/client/triggers.ts +84 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快捷键:彻底自定义——设置里按键录入任意组合,规范化存储为
|
|
3
|
+
* `ctrl+alt+shift+meta+key` 顺序的串(`mod` 特例表示 Cmd/Ctrl 任一),
|
|
4
|
+
* 编辑器按键时以同一规范化结果精确匹配。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MODIFIERS = new Set(['ctrl', 'alt', 'shift', 'meta', 'mod']);
|
|
8
|
+
|
|
9
|
+
/** 事件 → 规范化组合串(如 "ctrl+shift+enter");纯修饰键按下返回 null。 */
|
|
10
|
+
export function comboFromEvent(event: KeyboardEvent): string | null {
|
|
11
|
+
const raw = event.key;
|
|
12
|
+
if (raw === 'Shift' || raw === 'Control' || raw === 'Alt' || raw === 'Meta') return null;
|
|
13
|
+
const keyName = raw === ' ' ? 'space' : raw.length === 1 ? raw.toLowerCase() : raw.toLowerCase();
|
|
14
|
+
const parts: string[] = [];
|
|
15
|
+
if (event.ctrlKey) parts.push('ctrl');
|
|
16
|
+
if (event.altKey) parts.push('alt');
|
|
17
|
+
if (event.shiftKey) parts.push('shift');
|
|
18
|
+
if (event.metaKey) parts.push('meta');
|
|
19
|
+
parts.push(keyName);
|
|
20
|
+
return parts.join('+');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 按事件实际按下的修饰键构造等价组合(录制用,同上)。 */
|
|
24
|
+
export function comboFromEventLike(flags: { ctrlKey: boolean; altKey: boolean; shiftKey: boolean; metaKey: boolean }, key: string): string {
|
|
25
|
+
const keyName = key === ' ' ? 'space' : key.length === 1 ? key.toLowerCase() : key.toLowerCase();
|
|
26
|
+
const parts: string[] = [];
|
|
27
|
+
if (flags.ctrlKey) parts.push('ctrl');
|
|
28
|
+
if (flags.altKey) parts.push('alt');
|
|
29
|
+
if (flags.shiftKey) parts.push('shift');
|
|
30
|
+
if (flags.metaKey) parts.push('meta');
|
|
31
|
+
parts.push(keyName);
|
|
32
|
+
return parts.join('+');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseCombo(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean; mod: boolean; key: string } {
|
|
36
|
+
const tokens = new Set(combo.toLowerCase().split('+').filter(Boolean));
|
|
37
|
+
const key = [...tokens].filter((t) => !MODIFIERS.has(t)).join('+');
|
|
38
|
+
return {
|
|
39
|
+
ctrl: tokens.has('ctrl'),
|
|
40
|
+
alt: tokens.has('alt'),
|
|
41
|
+
shift: tokens.has('shift'),
|
|
42
|
+
meta: tokens.has('meta'),
|
|
43
|
+
mod: tokens.has('mod'),
|
|
44
|
+
key,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 存储的组合串是否与这次按键事件完全等价(mod 视作 Ctrl 或 Cmd 任一)。 */
|
|
49
|
+
export function matchEvent(combo: string, event: KeyboardEvent): boolean {
|
|
50
|
+
const c = parseCombo(combo);
|
|
51
|
+
if (!c.key || c.key !== eventKeyOf(event)) return false;
|
|
52
|
+
if (c.mod) {
|
|
53
|
+
if (!(event.ctrlKey || event.metaKey)) return false;
|
|
54
|
+
return event.altKey === c.alt && event.shiftKey === c.shift;
|
|
55
|
+
}
|
|
56
|
+
return event.ctrlKey === c.ctrl && event.altKey === c.alt && event.shiftKey === c.shift && event.metaKey === c.meta;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function eventKeyOf(event: KeyboardEvent): string {
|
|
60
|
+
const raw = event.key;
|
|
61
|
+
return raw === ' ' ? 'space' : raw.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 组合串 → 展示文案。 */
|
|
65
|
+
export function formatCombo(combo: string): string {
|
|
66
|
+
const pretty: Record<string, string> = {
|
|
67
|
+
ctrl: 'Ctrl',
|
|
68
|
+
alt: 'Alt',
|
|
69
|
+
shift: 'Shift',
|
|
70
|
+
meta: 'Cmd',
|
|
71
|
+
mod: 'Ctrl/Cmd',
|
|
72
|
+
enter: 'Enter',
|
|
73
|
+
tab: 'Tab',
|
|
74
|
+
space: 'Space',
|
|
75
|
+
escape: 'Esc',
|
|
76
|
+
};
|
|
77
|
+
return combo
|
|
78
|
+
.split('+')
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((token) => pretty[token] ?? (token.length === 1 ? token.toUpperCase() : token.charAt(0).toUpperCase() + token.slice(1)))
|
|
81
|
+
.join(' + ');
|
|
82
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文案国际化:走宿主 locale 服务(`ctx.locale.register` + `bind`),
|
|
3
|
+
* 宿主未就绪或形状漂移时退回内置词典(默认中文),绝不把 key 直接显示给用户。
|
|
4
|
+
*
|
|
5
|
+
* - 注册命名空间 `dsh-codinput` 的 zh / en 两份词典(宿主内置语言只有这两个)。
|
|
6
|
+
* - `bind(ns)` 返回的函数**引用稳定、按调用当拍读当前语言**,所以组件里直接
|
|
7
|
+
* 调 `t('key')` 即可;要跟随语言切换重渲染的地方用 `useT()`(内部订阅宿主
|
|
8
|
+
* locale 快照变化)。动态键(如候选菜单来源名)用 `tOptional()`,词典里没有
|
|
9
|
+
* 就返回 undefined,交由调用方回退原名。
|
|
10
|
+
* - 插值占位符用 `{name}`(与宿主 Translate 约定一致)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
export type TranslateParams = Record<string, string | number>;
|
|
16
|
+
type Dict = Record<string, string>;
|
|
17
|
+
|
|
18
|
+
const NS = 'dsh-codinput';
|
|
19
|
+
|
|
20
|
+
/** 中文词典(默认语言,也是宿主缺词典键时的兜底)。 */
|
|
21
|
+
const ZH: Dict = {
|
|
22
|
+
'head.edit': '编辑',
|
|
23
|
+
'head.preview': '预览',
|
|
24
|
+
'head.editOnly': '仅编辑',
|
|
25
|
+
'head.previewOnly': '仅预览',
|
|
26
|
+
'head.split': '编辑 + 预览',
|
|
27
|
+
'editor.placeholder': '描述你想要构建的内容,/ 调用指令,@ 文件或对话',
|
|
28
|
+
'preview.empty': '暂无可预览内容',
|
|
29
|
+
'tool.command': '指令(/)',
|
|
30
|
+
'tool.attach': '添加附件',
|
|
31
|
+
'tool.send': '发送',
|
|
32
|
+
'tool.model': '选择模型,当前 {name}',
|
|
33
|
+
'tool.modelPlain': '选择模型',
|
|
34
|
+
'tool.context': '上下文已用 {percent}%',
|
|
35
|
+
'tool.contextTitle': '上下文已用',
|
|
36
|
+
'attach.group': '草稿附件',
|
|
37
|
+
'attach.uploading': '上传中…',
|
|
38
|
+
'attach.failed': '上传失败',
|
|
39
|
+
'attach.retry': '重试上传',
|
|
40
|
+
'attach.remove': '移除附件 {name}',
|
|
41
|
+
'attach.scrollLeft': '向左滚动附件',
|
|
42
|
+
'attach.scrollRight': '向右滚动附件',
|
|
43
|
+
'attach.channelUnavailable': '附件通道不可用',
|
|
44
|
+
'attach.pending': '待上传文件 {name}',
|
|
45
|
+
'stats.session': '会话统计',
|
|
46
|
+
'stats.usage': 'Token 用量',
|
|
47
|
+
'stats.turns': '{turns} 轮 {steps} 步',
|
|
48
|
+
'stats.tps': '{tps} tok/s',
|
|
49
|
+
'stats.modelTime': '模型用时',
|
|
50
|
+
'stats.ttft': '首 token 平均(TTFT)',
|
|
51
|
+
'stats.tpsLabel': '输出速度(TPS)',
|
|
52
|
+
'stats.totalTokens': '{tokens} tok',
|
|
53
|
+
'stats.cacheHit': '缓存命中 {percent}%',
|
|
54
|
+
'stats.cacheHitLabel': '缓存命中',
|
|
55
|
+
'stats.cacheRead': '缓存读取',
|
|
56
|
+
'stats.cacheWrite': '缓存写入',
|
|
57
|
+
'stats.output': '输出',
|
|
58
|
+
'stats.uncached': '未缓存输入',
|
|
59
|
+
'stats.breakdown': '上下文分段',
|
|
60
|
+
'stats.prompt': '系统提示词',
|
|
61
|
+
'stats.tools': '工具定义',
|
|
62
|
+
'stats.messages': '对话消息',
|
|
63
|
+
'menu.model': '模型',
|
|
64
|
+
'menu.reasoning': '推理等级',
|
|
65
|
+
'menu.reasoningNone': '当前模型未提供推理等级。',
|
|
66
|
+
'menu.source.command': '指令',
|
|
67
|
+
'menu.source.skill': '技能',
|
|
68
|
+
'menu.source.subagent': '子智能体',
|
|
69
|
+
'menu.drill': '进入目录',
|
|
70
|
+
'menu.nav': '目录导航',
|
|
71
|
+
'menu.triggerCandidates': '触发候选建议',
|
|
72
|
+
'menu.empty': '没有匹配项',
|
|
73
|
+
'menu.loading': '加载中…',
|
|
74
|
+
'menu.loadFailed': '加载失败',
|
|
75
|
+
'menu.refreshingModels': '正在刷新模型列表…',
|
|
76
|
+
'menu.noModels': '没有可用的模型。',
|
|
77
|
+
'menu.filter': '筛选选项…',
|
|
78
|
+
'popup.ack': '我已了解该操作的影响',
|
|
79
|
+
'perm.trigger': '访问模式,当前:{name}',
|
|
80
|
+
'perm.confirmTitle': '确认启用完全权限?',
|
|
81
|
+
'perm.confirmEnable': '启用完全权限',
|
|
82
|
+
'perm.warning':
|
|
83
|
+
'启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
|
84
|
+
'perm.ack': '我已了解风险,并愿意继续',
|
|
85
|
+
'perm.cancel': '取消',
|
|
86
|
+
'perm.close': '关闭',
|
|
87
|
+
'perm.switchFailed': '权限切换失败',
|
|
88
|
+
'perm.channelRejected': '命令通道拒绝',
|
|
89
|
+
'perm.noInputSurface': '无可用输入面',
|
|
90
|
+
'perm.preset.read-only': '仅可查看',
|
|
91
|
+
'perm.preset.workspace-write': '工作区内修改',
|
|
92
|
+
'perm.preset.danger-full-access': '完全权限',
|
|
93
|
+
'set.enabled': '启用接管',
|
|
94
|
+
'set.enabledDesc': '关闭即恢复官方输入框,草稿不丢;/codinput 同样可切换',
|
|
95
|
+
'set.lineNumbers': '显示行号',
|
|
96
|
+
'set.lineNumbersDesc': '当前行与行号高亮常开',
|
|
97
|
+
'set.font': '编辑器字体',
|
|
98
|
+
'set.fontDesc': 'CSS font-family,留空恢复默认',
|
|
99
|
+
'set.sendKey': '发送快捷键',
|
|
100
|
+
'set.sendKeyDesc': '点击后按下任意组合完成录制;与换行快捷键不得相同',
|
|
101
|
+
'set.lineBreakKey': '换行快捷键',
|
|
102
|
+
'set.lineBreakKeyDesc': '点击后按下任意组合完成录制;与发送快捷键不得相同',
|
|
103
|
+
'set.defaultView': '默认视图',
|
|
104
|
+
'set.defaultViewDesc': '接管面板初始显示的形态',
|
|
105
|
+
'set.recording': '按键…',
|
|
106
|
+
'set.reset': '恢复默认',
|
|
107
|
+
'set.resetAction': '重置',
|
|
108
|
+
'set.resetAll': '清空全部 Codinput 偏好',
|
|
109
|
+
'ball.label': '展开 Codinput 输入面板',
|
|
110
|
+
'ball.title': '展开 Codinput',
|
|
111
|
+
'side.notice': '本会话的输入面已在另一个分栏中打开(输入入口唯一)。',
|
|
112
|
+
'side.guideDesc': '代码编辑器风格的输入面板;开启期间主输入框由本面板接管(共用同一台输入机)',
|
|
113
|
+
'cmd.enter': '进入 Codinput 输入接管',
|
|
114
|
+
'cmd.exit': '退出 Codinput,恢复官方输入框',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** 英文词典(键集与中文一致)。 */
|
|
118
|
+
const EN: Dict = {
|
|
119
|
+
'head.edit': 'Edit',
|
|
120
|
+
'head.preview': 'Preview',
|
|
121
|
+
'head.editOnly': 'Edit only',
|
|
122
|
+
'head.previewOnly': 'Preview only',
|
|
123
|
+
'head.split': 'Edit + Preview',
|
|
124
|
+
'editor.placeholder': 'Describe what you want to build — / for commands, @ for files or chats',
|
|
125
|
+
'preview.empty': 'Nothing to preview',
|
|
126
|
+
'tool.command': 'Commands (/)',
|
|
127
|
+
'tool.attach': 'Add attachment',
|
|
128
|
+
'tool.send': 'Send',
|
|
129
|
+
'tool.model': 'Select model, current {name}',
|
|
130
|
+
'tool.modelPlain': 'Select model',
|
|
131
|
+
'tool.context': 'Context used {percent}%',
|
|
132
|
+
'tool.contextTitle': 'Context used',
|
|
133
|
+
'attach.group': 'Draft attachments',
|
|
134
|
+
'attach.uploading': 'Uploading…',
|
|
135
|
+
'attach.failed': 'Upload failed',
|
|
136
|
+
'attach.retry': 'Retry upload',
|
|
137
|
+
'attach.remove': 'Remove attachment {name}',
|
|
138
|
+
'attach.scrollLeft': 'Scroll attachments left',
|
|
139
|
+
'attach.scrollRight': 'Scroll attachments right',
|
|
140
|
+
'attach.channelUnavailable': 'Attachment channel unavailable',
|
|
141
|
+
'attach.pending': 'Pending file {name}',
|
|
142
|
+
'stats.session': 'Session stats',
|
|
143
|
+
'stats.usage': 'Token usage',
|
|
144
|
+
'stats.turns': '{turns} turns · {steps} steps',
|
|
145
|
+
'stats.tps': '{tps} tok/s',
|
|
146
|
+
'stats.modelTime': 'Model time',
|
|
147
|
+
'stats.ttft': 'Avg first token (TTFT)',
|
|
148
|
+
'stats.tpsLabel': 'Output speed (TPS)',
|
|
149
|
+
'stats.totalTokens': '{tokens} tok',
|
|
150
|
+
'stats.cacheHit': 'Cache hit {percent}%',
|
|
151
|
+
'stats.cacheHitLabel': 'Cache hit',
|
|
152
|
+
'stats.cacheRead': 'Cache read',
|
|
153
|
+
'stats.cacheWrite': 'Cache write',
|
|
154
|
+
'stats.output': 'Output',
|
|
155
|
+
'stats.uncached': 'Uncached input',
|
|
156
|
+
'stats.breakdown': 'Context breakdown',
|
|
157
|
+
'stats.prompt': 'System prompt',
|
|
158
|
+
'stats.tools': 'Tool definitions',
|
|
159
|
+
'stats.messages': 'Conversation',
|
|
160
|
+
'menu.model': 'Model',
|
|
161
|
+
'menu.reasoning': 'Reasoning effort',
|
|
162
|
+
'menu.reasoningNone': 'The current model exposes no reasoning effort.',
|
|
163
|
+
'menu.source.command': 'Commands',
|
|
164
|
+
'menu.source.skill': 'Skills',
|
|
165
|
+
'menu.source.subagent': 'Subagents',
|
|
166
|
+
'menu.drill': 'Open folder',
|
|
167
|
+
'menu.nav': 'Folder navigation',
|
|
168
|
+
'menu.triggerCandidates': 'Trigger suggestions',
|
|
169
|
+
'menu.empty': 'No matches',
|
|
170
|
+
'menu.loading': 'Loading…',
|
|
171
|
+
'menu.loadFailed': 'Failed to load',
|
|
172
|
+
'menu.refreshingModels': 'Refreshing model list…',
|
|
173
|
+
'menu.noModels': 'No models available.',
|
|
174
|
+
'menu.filter': 'Filter options…',
|
|
175
|
+
'popup.ack': 'I understand the impact of this action',
|
|
176
|
+
'perm.trigger': 'Access mode, current: {name}',
|
|
177
|
+
'perm.confirmTitle': 'Enable full access?',
|
|
178
|
+
'perm.confirmEnable': 'Enable full access',
|
|
179
|
+
'perm.warning':
|
|
180
|
+
'With full access the agent skips most confirmations and can perform more actions directly, including sensitive operations, file writes, or external commands. Only use it when you trust the current task.',
|
|
181
|
+
'perm.ack': 'I understand the risk and want to continue',
|
|
182
|
+
'perm.cancel': 'Cancel',
|
|
183
|
+
'perm.close': 'Close',
|
|
184
|
+
'perm.switchFailed': 'Failed to switch permission',
|
|
185
|
+
'perm.channelRejected': 'Command channel rejected',
|
|
186
|
+
'perm.noInputSurface': 'No input surface available',
|
|
187
|
+
'perm.preset.read-only': 'Read only',
|
|
188
|
+
'perm.preset.workspace-write': 'Write in workspace',
|
|
189
|
+
'perm.preset.danger-full-access': 'Full access',
|
|
190
|
+
'set.enabled': 'Take over composer',
|
|
191
|
+
'set.enabledDesc': 'Turning it off restores the official composer; the draft is kept. /codinput toggles it too.',
|
|
192
|
+
'set.lineNumbers': 'Show line numbers',
|
|
193
|
+
'set.lineNumbersDesc': 'The current line and its gutter number stay highlighted',
|
|
194
|
+
'set.font': 'Editor font',
|
|
195
|
+
'set.fontDesc': 'CSS font-family; leave empty for the default',
|
|
196
|
+
'set.sendKey': 'Send shortcut',
|
|
197
|
+
'set.sendKeyDesc': 'Click, then press any combination to record it; must differ from the line-break shortcut',
|
|
198
|
+
'set.lineBreakKey': 'Line-break shortcut',
|
|
199
|
+
'set.lineBreakKeyDesc': 'Click, then press any combination to record it; must differ from the send shortcut',
|
|
200
|
+
'set.defaultView': 'Default view',
|
|
201
|
+
'set.defaultViewDesc': 'Initial layout of the takeover panel',
|
|
202
|
+
'set.recording': 'Press keys…',
|
|
203
|
+
'set.reset': 'Reset',
|
|
204
|
+
'set.resetAction': 'Reset',
|
|
205
|
+
'set.resetAll': 'Clear all Codinput preferences',
|
|
206
|
+
'ball.label': 'Open the Codinput input panel',
|
|
207
|
+
'ball.title': 'Open Codinput',
|
|
208
|
+
'side.notice': "This session's input panel is already open in another split pane (only one input surface exists).",
|
|
209
|
+
'side.guideDesc': 'Code-editor style input panel. While enabled it takes over the composer (sharing the same input machine).',
|
|
210
|
+
'cmd.enter': 'Enable the Codinput composer takeover',
|
|
211
|
+
'cmd.exit': 'Disable Codinput and restore the official composer',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
interface LocaleFaceLike {
|
|
215
|
+
register?(ns: string, locale: string, dict: Dict): (() => void) | void;
|
|
216
|
+
bind?(ns: string): (key: string, params?: TranslateParams) => string;
|
|
217
|
+
subscribe?(fn: () => void): () => void;
|
|
218
|
+
getSnapshot?(): { active?: string };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let bound: ((key: string, params?: TranslateParams) => string) | null = null;
|
|
222
|
+
let activeLocale = 'zh';
|
|
223
|
+
const listeners = new Set<() => void>();
|
|
224
|
+
|
|
225
|
+
function emit(): void {
|
|
226
|
+
for (const fn of listeners) fn();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function interpolate(text: string, params?: TranslateParams): string {
|
|
230
|
+
if (!params) return text;
|
|
231
|
+
return text.replace(/\{(\w+)\}/g, (whole, key: string) => {
|
|
232
|
+
const value = params[key];
|
|
233
|
+
return value === undefined ? whole : String(value);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 当前语言的内置词典(宿主 bind 不可用时的兜底)。 */
|
|
238
|
+
function builtinDict(): Dict {
|
|
239
|
+
return activeLocale.startsWith('zh') ? ZH : EN;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 安装:注册词典并绑定翻译函数(宿主 locale 服务就绪后调用一次)。 */
|
|
243
|
+
export function installI18n(locale: unknown): void {
|
|
244
|
+
const face = locale as LocaleFaceLike | undefined;
|
|
245
|
+
if (!face) return;
|
|
246
|
+
try {
|
|
247
|
+
face.register?.(NS, 'zh', ZH);
|
|
248
|
+
face.register?.(NS, 'en', EN);
|
|
249
|
+
bound = face.bind?.(NS) ?? null;
|
|
250
|
+
} catch {
|
|
251
|
+
// 重复注册(热重载)或形状漂移:退回内置词典
|
|
252
|
+
try {
|
|
253
|
+
bound = face.bind?.(NS) ?? null;
|
|
254
|
+
} catch {
|
|
255
|
+
bound = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const snapshot = face.getSnapshot?.();
|
|
260
|
+
if (snapshot?.active) activeLocale = snapshot.active;
|
|
261
|
+
face.subscribe?.(() => {
|
|
262
|
+
const next = face.getSnapshot?.();
|
|
263
|
+
if (next?.active && next.active !== activeLocale) activeLocale = next.active;
|
|
264
|
+
emit();
|
|
265
|
+
});
|
|
266
|
+
} catch {
|
|
267
|
+
/* 快照不可用:语言切换不重渲染,但不影响首屏文案 */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function subscribeI18n(fn: () => void): () => void {
|
|
272
|
+
listeners.add(fn);
|
|
273
|
+
return () => {
|
|
274
|
+
listeners.delete(fn);
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function lookup(key: string, params?: TranslateParams): string | undefined {
|
|
279
|
+
if (bound) {
|
|
280
|
+
try {
|
|
281
|
+
const text = bound(key, params);
|
|
282
|
+
if (text && text !== key) return text;
|
|
283
|
+
} catch {
|
|
284
|
+
/* 落到内置词典 */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const dict = builtinDict()[key] ?? ZH[key];
|
|
288
|
+
return dict === undefined ? undefined : interpolate(dict, params);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 翻译。优先级:宿主 bind(按当拍语言查,含宿主自己的回退链)→ 内置词典 →
|
|
293
|
+
* 原 key。宿主词典缺键时 bind 原样返回 key,这里据此再落到内置词典。
|
|
294
|
+
*/
|
|
295
|
+
export function t(key: string, params?: TranslateParams): string {
|
|
296
|
+
return lookup(key, params) ?? key;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 可选翻译:词典里没有该键时返回 undefined——用于动态键(如候选菜单的
|
|
301
|
+
* 来源名),调用方回退显示原名而不是把 key 摆到界面上。
|
|
302
|
+
*/
|
|
303
|
+
export function tOptional(key: string, params?: TranslateParams): string | undefined {
|
|
304
|
+
return lookup(key, params);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 语言切换时重渲染的订阅钩子(返回稳定的 t)。 */
|
|
308
|
+
export function useT(): typeof t {
|
|
309
|
+
const [, bump] = useState(0);
|
|
310
|
+
useEffect(() => subscribeI18n(() => bump((n) => n + 1)), []);
|
|
311
|
+
return t;
|
|
312
|
+
}
|