Skip to content

Commit 789a08e

Browse files
committed
feat(web-ui): move language selector into settings
1 parent 5769ed4 commit 789a08e

9 files changed

Lines changed: 295 additions & 156 deletions

File tree

tests/unit/web-ui-behavior-parity.test.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,10 @@ test('captured bundled app skeleton only exposes expected data key drift versus
617617
'pasteAgentsContent',
618618
'importSingleSkill',
619619
'openSkillsMenu',
620-
'isClaudeLocalBridgeDisabled'
620+
'isClaudeLocalBridgeDisabled',
621+
'languageOptions',
622+
'currentLanguageLabel',
623+
'openLanguageSettings'
621624
);
622625
const allowedMissingCurrentMethodKeys = [
623626
'convertSession',

web-ui/modules/i18n.dict.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ const DICT = Object.freeze({
154154
'side.sessions.browser.meta': '浏览 / 导出 / 清理',
155155
'side.plugins.tools': '提示词工具',
156156
'side.plugins.tools.meta': '模板 / 变量',
157+
'side.plugins.templatesCount': '{count} 个模板',
158+
'side.newTab': '新标签页',
157159
'side.system.settings': '运行设置',
158160
'side.system.settings.meta': '数据 / 备份',
159161

@@ -911,6 +913,12 @@ const DICT = Object.freeze({
911913
'settings.tab.general': '通用',
912914
'settings.tab.data': '数据',
913915
'settings.tabs.aria': '设置分类',
916+
'settings.language.title': '界面语言',
917+
'settings.language.meta': '选择 Web UI 的显示语言。语言较多时统一在这里管理。',
918+
'settings.language.label': '语言',
919+
'settings.language.hint': '设置会保存到当前浏览器,并立即生效。',
920+
'settings.language.open': '打开语言设置',
921+
'settings.language.sideLabel': '语言 · {language}',
914922
'settings.quickSettings.title': '快捷设置',
915923
'settings.sharePrefix.title': '分享命令前缀',
916924
'settings.sharePrefix.meta': '影响 Web UI 里“复制分享命令”的前缀',
@@ -955,8 +963,22 @@ const DICT = Object.freeze({
955963
'settings.trash.retention': '自动清理',
956964
'settings.trash.retentionMeta': '超过保留天数的回收站记录将自动清除',
957965
'settings.trash.retentionLabel': '保留天数',
966+
'settings.trash.retentionUnit': '天',
958967
'settings.trash.retentionHint': '范围 1-365 天,默认 30 天。每次加载回收站时自动清理过期记录。',
959968

969+
'settings.webhook.title': 'Webhook',
970+
'settings.webhook.meta': '配置变更时外发通知',
971+
'settings.webhook.enabled': '已启用',
972+
'settings.webhook.disabled': '已禁用',
973+
'settings.webhook.edit': '编辑',
974+
'settings.webhook.configure': '配置',
975+
'settings.webhook.enable': '启用',
976+
'settings.webhook.modalTitle': 'Webhook 配置',
977+
'settings.webhook.enabledLabel': '启用状态',
978+
'settings.webhook.enableToggle': '启用 Webhook',
979+
'settings.webhook.urlLabel': 'URL',
980+
'settings.webhook.eventsLabel': '事件',
981+
960982
'settings.templateConfirm.title': '配置模板二次确认',
961983
'settings.templateConfirm.meta': '降低误写入风险',
962984
'settings.templateConfirm.toggle': '应用模板前先预览差异(两步:确认 → 应用)',
@@ -1224,6 +1246,8 @@ const DICT = Object.freeze({
12241246
'side.sessions.browser.meta': '閲覧 / エクスポート / クリーンアップ',
12251247
'side.plugins.tools': 'プロンプトツール',
12261248
'side.plugins.tools.meta': 'テンプレート / 変数',
1249+
'side.plugins.templatesCount': '{count} 個のテンプレート',
1250+
'side.newTab': '新しいタブ',
12271251
'side.system.settings': '実行設定',
12281252
'side.system.settings.meta': 'データ / バックアップ',
12291253

@@ -1969,6 +1993,12 @@ const DICT = Object.freeze({
19691993
'settings.tab.general': '一般',
19701994
'settings.tab.data': 'データ',
19711995
'settings.tabs.aria': '設定カテゴリ',
1996+
'settings.language.title': '表示言語',
1997+
'settings.language.meta': 'Web UI の表示言語を選択します。言語が増えてもここで管理できます。',
1998+
'settings.language.label': '言語',
1999+
'settings.language.hint': 'この設定は現在のブラウザに保存され、すぐに反映されます。',
2000+
'settings.language.open': '言語設定を開く',
2001+
'settings.language.sideLabel': '言語 · {language}',
19722002
'settings.quickSettings.title': 'クイック設定',
19732003
'settings.sharePrefix.title': '共有コマンドプレフィックス',
19742004
'settings.sharePrefix.meta': 'Web UI の「共有コマンドをコピー」のプレフィックスに影響',
@@ -2013,8 +2043,22 @@ const DICT = Object.freeze({
20132043
'settings.trash.retention': '自動クリーンアップ',
20142044
'settings.trash.retentionMeta': '保持日数を超えたゴミ箱レコードは自動的に削除されます',
20152045
'settings.trash.retentionLabel': '保持日数',
2046+
'settings.trash.retentionUnit': '日',
20162047
'settings.trash.retentionHint': '範囲 1-365 日、デフォルト 30 日。ゴミ箱読み込み時に期限切れレコードを自動クリーンアップします。',
20172048

2049+
'settings.webhook.title': 'Webhook',
2050+
'settings.webhook.meta': '設定変更時に外部通知を送信します',
2051+
'settings.webhook.enabled': '有効',
2052+
'settings.webhook.disabled': '無効',
2053+
'settings.webhook.edit': '編集',
2054+
'settings.webhook.configure': '設定',
2055+
'settings.webhook.enable': '有効化',
2056+
'settings.webhook.modalTitle': 'Webhook 設定',
2057+
'settings.webhook.enabledLabel': '有効状態',
2058+
'settings.webhook.enableToggle': 'Webhook を有効化',
2059+
'settings.webhook.urlLabel': 'URL',
2060+
'settings.webhook.eventsLabel': 'イベント',
2061+
20182062
'settings.templateConfirm.title': '設定テンプレートの二次確認',
20192063
'settings.templateConfirm.meta': '誤書き込みリスクを低減',
20202064
'settings.templateConfirm.toggle': 'テンプレート適用前に差分をプレビュー(2段階:確認 → 適用)',
@@ -2283,6 +2327,8 @@ const DICT = Object.freeze({
22832327
'side.sessions.browser.meta': 'Browse / Export / Cleanup',
22842328
'side.plugins.tools': 'Prompt Tools',
22852329
'side.plugins.tools.meta': 'Templates / Variables',
2330+
'side.plugins.templatesCount': '{count} templates',
2331+
'side.newTab': 'New Tab',
22862332
'side.system.settings': 'Runtime Settings',
22872333
'side.system.settings.meta': 'Data / Backup',
22882334

@@ -3041,6 +3087,12 @@ const DICT = Object.freeze({
30413087
'settings.tab.general': 'General',
30423088
'settings.tab.data': 'Data',
30433089
'settings.tabs.aria': 'Settings categories',
3090+
'settings.language.title': 'Interface language',
3091+
'settings.language.meta': 'Choose the Web UI display language. Additional languages are managed here instead of the sidebar.',
3092+
'settings.language.label': 'Language',
3093+
'settings.language.hint': 'This setting is saved in the current browser and applies immediately.',
3094+
'settings.language.open': 'Open language settings',
3095+
'settings.language.sideLabel': 'Language · {language}',
30443096
'settings.quickSettings.title': 'Quick Settings',
30453097
'settings.sharePrefix.title': 'Share command prefix',
30463098
'settings.sharePrefix.meta': 'Used as the prefix for “Copy share command” in the Web UI',
@@ -3081,8 +3133,22 @@ const DICT = Object.freeze({
30813133
'settings.trash.retention': 'Auto-purge',
30823134
'settings.trash.retentionMeta': 'Trash entries older than retention days are auto-purged',
30833135
'settings.trash.retentionLabel': 'Retention days',
3136+
'settings.trash.retentionUnit': 'days',
30843137
'settings.trash.retentionHint': 'Range 1-365 days, default 30. Expired entries are purged on each trash load.',
30853138

3139+
'settings.webhook.title': 'Webhook',
3140+
'settings.webhook.meta': 'Send outgoing notifications when config changes',
3141+
'settings.webhook.enabled': 'Enabled',
3142+
'settings.webhook.disabled': 'Disabled',
3143+
'settings.webhook.edit': 'Edit',
3144+
'settings.webhook.configure': 'Configure',
3145+
'settings.webhook.enable': 'Enable',
3146+
'settings.webhook.modalTitle': 'Webhook settings',
3147+
'settings.webhook.enabledLabel': 'Enabled state',
3148+
'settings.webhook.enableToggle': 'Enable Webhook',
3149+
'settings.webhook.urlLabel': 'URL',
3150+
'settings.webhook.eventsLabel': 'Events',
3151+
30863152
'settings.templateConfirm.title': 'Template apply confirmation',
30873153
'settings.templateConfirm.meta': 'Reduce accidental writes',
30883154
'settings.templateConfirm.toggle': 'Preview diffs before apply (Confirm → Apply)',

web-ui/modules/i18n.mjs

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,34 @@ import { DICT } from './i18n.dict.mjs';
22

33
const I18N_STORAGE_KEY = 'codexmateLang';
44

5+
const LANGUAGE_META = Object.freeze([
6+
Object.freeze({ code: 'zh', nativeName: '中文', englishName: 'Chinese', htmlLang: 'zh-CN', dir: 'ltr' }),
7+
Object.freeze({ code: 'en', nativeName: 'English', englishName: 'English', htmlLang: 'en', dir: 'ltr' }),
8+
Object.freeze({ code: 'ja', nativeName: '日本語', englishName: 'Japanese', htmlLang: 'ja', dir: 'ltr' }),
9+
Object.freeze({ code: 'vi', nativeName: 'Tiếng Việt', englishName: 'Vietnamese', htmlLang: 'vi', dir: 'ltr' })
10+
]);
11+
12+
function getAvailableLanguages() {
13+
return LANGUAGE_META.filter((item) => item && item.code && DICT[item.code]);
14+
}
15+
16+
function getLanguageMeta(code) {
17+
const normalized = typeof code === 'string' ? code.trim().toLowerCase() : '';
18+
return getAvailableLanguages().find((item) => item.code === normalized) || LANGUAGE_META[0];
19+
}
20+
521
function normalizeLang(value) {
622
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
7-
if (normalized === 'en') return 'en';
8-
if (normalized === 'ja') return 'ja';
9-
return 'zh';
23+
return getAvailableLanguages().some((item) => item.code === normalized) ? normalized : 'zh';
24+
}
25+
26+
function applyDocumentLanguage(next) {
27+
try {
28+
if (typeof document === 'undefined' || !document.documentElement) return;
29+
const meta = getLanguageMeta(next);
30+
document.documentElement.lang = meta.htmlLang || meta.code || 'zh-CN';
31+
document.documentElement.dir = meta.dir || 'ltr';
32+
} catch (_) {}
1033
}
1134

1235
function interpolate(template, params) {
@@ -26,13 +49,38 @@ export function createI18nMethods() {
2649
: '';
2750
const next = normalizeLang(saved);
2851
this.lang = next;
29-
try {
30-
if (typeof document !== 'undefined' && document.documentElement) {
31-
if (next === 'en') document.documentElement.lang = 'en';
32-
else if (next === 'ja') document.documentElement.lang = 'ja';
33-
else document.documentElement.lang = 'zh-CN';
52+
applyDocumentLanguage(next);
53+
},
54+
languageOptions() {
55+
return getAvailableLanguages();
56+
},
57+
currentLanguageLabel() {
58+
return getLanguageMeta(this.lang).nativeName || '中文';
59+
},
60+
openLanguageSettings() {
61+
if (typeof this.switchMainTab === 'function') {
62+
this.switchMainTab('settings');
63+
} else {
64+
this.mainTab = 'settings';
65+
if (typeof this.saveNavState === 'function') {
66+
this.saveNavState();
3467
}
35-
} catch (_) {}
68+
}
69+
this.settingsTab = 'general';
70+
this.$nextTick(() => {
71+
const target = typeof document !== 'undefined'
72+
? document.getElementById('settings-language')
73+
: null;
74+
if (target && typeof target.scrollIntoView === 'function') {
75+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
76+
}
77+
const select = typeof document !== 'undefined'
78+
? document.getElementById('settings-language-select')
79+
: null;
80+
if (select && typeof select.focus === 'function') {
81+
select.focus();
82+
}
83+
});
3684
},
3785
setLang(nextLang) {
3886
const next = normalizeLang(nextLang);
@@ -42,13 +90,7 @@ export function createI18nMethods() {
4290
localStorage.setItem(I18N_STORAGE_KEY, next);
4391
}
4492
} catch (_) {}
45-
try {
46-
if (typeof document !== 'undefined' && document.documentElement) {
47-
if (next === 'en') document.documentElement.lang = 'en';
48-
else if (next === 'ja') document.documentElement.lang = 'ja';
49-
else document.documentElement.lang = 'zh-CN';
50-
}
51-
} catch (_) {}
93+
applyDocumentLanguage(next);
5294
},
5395
t(key, params = null) {
5496
const lang = normalizeLang(this.lang);

web-ui/partials/index/layout-header.html

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -93,27 +93,12 @@
9393
@click="onMainTabClick('settings', $event)">{{ t('tab.settings') }}</button>
9494
</div>
9595

96-
<div v-if="!sessionStandalone" class="lang-fab" role="group" :aria-label="t('lang.label')">
97-
<div class="lang-choice" role="group" :aria-label="t('lang.label')">
98-
<button
99-
type="button"
100-
class="lang-choice-btn"
101-
:aria-pressed="(lang || 'zh') === 'zh'"
102-
:class="{ active: (lang || 'zh') === 'zh' }"
103-
@click="setLang('zh')">ZH</button>
104-
<button
105-
type="button"
106-
class="lang-choice-btn"
107-
:aria-pressed="(lang || 'zh') === 'en'"
108-
:class="{ active: (lang || 'zh') === 'en' }"
109-
@click="setLang('en')">EN</button>
110-
<button
111-
type="button"
112-
class="lang-choice-btn"
113-
:aria-pressed="(lang || 'zh') === 'ja'"
114-
:class="{ active: (lang || 'zh') === 'ja' }"
115-
@click="setLang('ja')">日本語</button>
116-
</div>
96+
<div v-if="!sessionStandalone" class="lang-fab">
97+
<button
98+
type="button"
99+
class="language-settings-link"
100+
:aria-label="t('settings.language.open')"
101+
@click="openLanguageSettings">{{ t('settings.language.sideLabel', { language: currentLanguageLabel() }) }}</button>
117102
</div>
118103

119104
<div :class="['app-shell', { standalone: sessionStandalone }]">
@@ -282,7 +267,7 @@
282267
<div class="side-item-title">{{ t('side.plugins.tools') }}</div>
283268
<div class="side-item-meta">
284269
<span>{{ t('side.plugins.tools.meta') }}</span>
285-
<span>{{ promptTemplatesList.length }} templates</span>
270+
<span>{{ t('side.plugins.templatesCount', { count: promptTemplatesList.length }) }}</span>
286271
</div>
287272
</button>
288273
</div>
@@ -308,40 +293,25 @@
308293
:class="['side-item', { active: isMainTabNavActive('trash') }]"
309294
@pointerdown="onMainTabPointerDown('trash', $event)"
310295
@click="onMainTabClick('trash', $event)">
311-
<div class="side-item-title">回收站</div>
296+
<div class="side-item-title">{{ t('settings.trash.title') }}</div>
312297
<div class="side-item-meta">
313-
<span>已删除会话</span>
298+
<span>{{ t('settings.trash.meta') }}</span>
314299
<span v-if="sessionTrashCount > 0" class="side-item-badge">{{ sessionTrashCount }}</span>
315300
</div>
316301
</button>
317302
</div>
318303
<div id="side-tab-new" class="side-item side-item-ghost" tabindex="-1" aria-hidden="true">
319-
<div class="side-item-title">New Tab</div>
304+
<div class="side-item-title">{{ t('side.newTab') }}</div>
320305
<div class="side-item-meta"><span>&nbsp;</span></div>
321306
</div>
322307
</div>
323308

324-
<div class="side-rail-lang" role="group" :aria-label="t('lang.label')">
325-
<div class="lang-choice" role="group" :aria-label="t('lang.label')">
326-
<button
327-
type="button"
328-
class="lang-choice-btn"
329-
:aria-pressed="(lang || 'zh') === 'zh'"
330-
:class="{ active: (lang || 'zh') === 'zh' }"
331-
@click="setLang('zh')">ZH</button>
332-
<button
333-
type="button"
334-
class="lang-choice-btn"
335-
:aria-pressed="(lang || 'zh') === 'en'"
336-
:class="{ active: (lang || 'zh') === 'en' }"
337-
@click="setLang('en')">EN</button>
338-
<button
339-
type="button"
340-
class="lang-choice-btn"
341-
:aria-pressed="(lang || 'zh') === 'ja'"
342-
:class="{ active: (lang || 'zh') === 'ja' }"
343-
@click="setLang('ja')">日本語</button>
344-
</div>
309+
<div class="side-rail-lang">
310+
<button
311+
type="button"
312+
class="language-settings-link"
313+
:aria-label="t('settings.language.open')"
314+
@click="openLanguageSettings">{{ t('settings.language.sideLabel', { language: currentLanguageLabel() }) }}</button>
345315
</div>
346316
</aside>
347317
<main class="main-panel">

0 commit comments

Comments
 (0)