codexmate 0.1.27 → 0.1.28

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.
@@ -17,10 +17,8 @@ function createConfigBootstrapController(deps = {}) {
17
17
  writeModels,
18
18
  readCurrentModels,
19
19
  writeCurrentModels,
20
- updateAuthJson,
21
20
  CONFIG_DIR,
22
21
  CONFIG_FILE,
23
- AUTH_FILE,
24
22
  MODELS_FILE,
25
23
  RECENT_CONFIGS_FILE,
26
24
  INIT_MARK_FILE,
@@ -49,10 +47,8 @@ function createConfigBootstrapController(deps = {}) {
49
47
  if (typeof writeModels !== 'function') throw new Error('createConfigBootstrapController 缺少 writeModels');
50
48
  if (typeof readCurrentModels !== 'function') throw new Error('createConfigBootstrapController 缺少 readCurrentModels');
51
49
  if (typeof writeCurrentModels !== 'function') throw new Error('createConfigBootstrapController 缺少 writeCurrentModels');
52
- if (typeof updateAuthJson !== 'function') throw new Error('createConfigBootstrapController 缺少 updateAuthJson');
53
50
  if (!CONFIG_DIR) throw new Error('createConfigBootstrapController 缺少 CONFIG_DIR');
54
51
  if (!CONFIG_FILE) throw new Error('createConfigBootstrapController 缺少 CONFIG_FILE');
55
- if (!AUTH_FILE) throw new Error('createConfigBootstrapController 缺少 AUTH_FILE');
56
52
  if (!MODELS_FILE) throw new Error('createConfigBootstrapController 缺少 MODELS_FILE');
57
53
  if (!RECENT_CONFIGS_FILE) throw new Error('createConfigBootstrapController 缺少 RECENT_CONFIGS_FILE');
58
54
  if (!INIT_MARK_FILE) throw new Error('createConfigBootstrapController 缺少 INIT_MARK_FILE');
@@ -131,6 +127,7 @@ base_url = "http://127.0.0.1:3737/bridge/local/v1"
131
127
  wire_api = "responses"
132
128
  requires_openai_auth = true
133
129
  preferred_auth_method = "codexmate"
130
+ experimental_bearer_token = "codexmate"
134
131
  codexmate_bridge = "local"
135
132
  request_max_retries = 4
136
133
  stream_max_retries = 10
@@ -181,7 +178,7 @@ stream_idle_timeout_ms = 300000
181
178
  // Check if [model_providers.local] section already exists
182
179
  if (/\[model_providers\.local\]/.test(content)) return;
183
180
 
184
- const localSection = `\n[model_providers.local]\nname = "local"\nbase_url = "http://127.0.0.1:3737/bridge/local/v1"\nwire_api = "responses"\nrequires_openai_auth = true\npreferred_auth_method = "codexmate"\ncodexmate_bridge = "local"\nrequest_max_retries = 4\nstream_max_retries = 10\nstream_idle_timeout_ms = 300000\n`;
181
+ const localSection = `\n[model_providers.local]\nname = "local"\nbase_url = "http://127.0.0.1:3737/bridge/local/v1"\nwire_api = "responses"\nrequires_openai_auth = true\npreferred_auth_method = "codexmate"\nexperimental_bearer_token = "codexmate"\ncodexmate_bridge = "local"\nrequest_max_retries = 4\nstream_max_retries = 10\nstream_idle_timeout_ms = 300000\n`;
185
182
  fs.appendFileSync(CONFIG_FILE, localSection, 'utf-8');
186
183
  }
187
184
 
@@ -263,10 +260,6 @@ stream_idle_timeout_ms = 300000
263
260
  currentModels[defaultProvider] = defaultModel;
264
261
  writeCurrentModels(currentModels);
265
262
  }
266
-
267
- if (!fs.existsSync(AUTH_FILE)) {
268
- updateAuthJson('');
269
- }
270
263
  }
271
264
 
272
265
  function writeInitMark(payload) {
package/cli.js CHANGED
@@ -757,6 +757,7 @@ base_url = "http://127.0.0.1:3737/bridge/local/v1"
757
757
  wire_api = "responses"
758
758
  requires_openai_auth = true
759
759
  preferred_auth_method = "codexmate"
760
+ experimental_bearer_token = "codexmate"
760
761
  codexmate_bridge = "local"
761
762
  request_max_retries = 4
762
763
  stream_max_retries = 10
@@ -893,17 +894,27 @@ function writeCurrentModels(data) {
893
894
  fs.writeFileSync(CURRENT_MODELS_FILE, JSON.stringify(data, null, 2), 'utf-8');
894
895
  }
895
896
 
896
- function updateAuthJson(apiKey) {
897
- assertToolConfigWriteAllowed('codex');
898
- let authData = {};
899
- if (fs.existsSync(AUTH_FILE)) {
900
- try {
901
- const content = fs.readFileSync(AUTH_FILE, 'utf-8');
902
- if (content.trim()) authData = JSON.parse(content);
903
- } catch (e) { }
897
+ function ensureActiveProviderBearerToken(content, providerName, providerSegments = null) {
898
+ // 单文件认证:auth.json 不再写入,激活 provider 块缺 experimental_bearer_token 时
899
+ // 从 preferred_auth_method(内部键仓)惰性补写,保证 codex 仅凭 config.toml 取到密钥。
900
+ const ranges = findProviderSectionRanges(content, providerName, providerSegments);
901
+ if (!ranges.length) return content;
902
+ let updated = content;
903
+ const sorted = [...ranges].sort((a, b) => b.start - a.start);
904
+ for (const range of sorted) {
905
+ const block = updated.slice(range.start, range.end);
906
+ if (/(^|\n)\s*experimental_bearer_token\s*=/.test(block)) continue;
907
+ const prefMatch = block.match(/^(\s*)preferred_auth_method\s*=\s*"((?:\\.|[^"\\\n])*)"\s*(?:#.*)?$/m);
908
+ if (!prefMatch || !prefMatch[2].trim()) continue;
909
+ const insertLine = `${prefMatch[1]}experimental_bearer_token = "${prefMatch[2]}"`;
910
+ const lineEndIndex = block.indexOf('\n', prefMatch.index + prefMatch[0].length);
911
+ const insertAt = lineEndIndex === -1
912
+ ? range.start + block.length
913
+ : range.start + lineEndIndex + 1;
914
+ const prefix = lineEndIndex === -1 ? '\n' : '';
915
+ updated = updated.slice(0, insertAt) + prefix + insertLine + '\n' + updated.slice(insertAt);
904
916
  }
905
- authData['OPENAI_API_KEY'] = apiKey;
906
- fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), { encoding: 'utf-8', mode: 0o600 });
917
+ return updated;
907
918
  }
908
919
 
909
920
  function isPlainObject(value) {
@@ -1029,6 +1040,10 @@ function normalizeSidebarCollapsedPreference(value) {
1029
1040
  return normalizeBooleanPreference(value, false);
1030
1041
  }
1031
1042
 
1043
+ function normalizeThemePreference(value) {
1044
+ return value === 'dark' ? 'dark' : 'light';
1045
+ }
1046
+
1032
1047
  function normalizeSessionFilterSourcePreference(value) {
1033
1048
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
1034
1049
  if (normalized === 'all' || normalized === 'claude' || normalized === 'gemini' || normalized === 'codebuddy' || normalized === 'workbuddy' || normalized === 'pi') return normalized;
@@ -1100,6 +1115,7 @@ function normalizeWebUiPreferences(value = {}) {
1100
1115
  sysPromptMode: normalizeSysPromptModePreference(source.sysPromptMode),
1101
1116
  projectClaudeMdPath: typeof source.projectClaudeMdPath === 'string' ? source.projectClaudeMdPath : '',
1102
1117
  sidebarCollapsed: normalizeSidebarCollapsedPreference(source.sidebarCollapsed),
1118
+ theme: normalizeThemePreference(source.theme),
1103
1119
  starPrompted: normalizeBooleanPreference(source.starPrompted, false),
1104
1120
  sessionLoadNativeDialog: normalizeBooleanPreference(source.sessionLoadNativeDialog, false),
1105
1121
  language: typeof source.language === 'string' ? source.language : '',
@@ -1549,6 +1565,7 @@ const PROVIDER_CONFIG_KEYS = new Set([
1549
1565
  'wire_api',
1550
1566
  'requires_openai_auth',
1551
1567
  'preferred_auth_method',
1568
+ 'experimental_bearer_token',
1552
1569
  'request_max_retries',
1553
1570
  'stream_max_retries',
1554
1571
  'stream_idle_timeout_ms'
@@ -2753,7 +2770,6 @@ const {
2753
2770
  writeModels,
2754
2771
  readCurrentModels,
2755
2772
  writeCurrentModels,
2756
- updateAuthJson,
2757
2773
  CONFIG_DIR,
2758
2774
  CONFIG_FILE,
2759
2775
  AUTH_FILE,
@@ -3071,8 +3087,8 @@ function applyConfigTemplate(params = {}) {
3071
3087
  return { error: `模板中找不到当前 provider: ${activeProvider}` };
3072
3088
  }
3073
3089
 
3074
- writeConfig(template.trim() + '\n');
3075
- updateAuthJson(activeProviderBlock.preferred_auth_method || '');
3090
+ const ensuredTemplate = ensureActiveProviderBearerToken(template.trim(), activeProvider);
3091
+ writeConfig(ensuredTemplate + '\n');
3076
3092
 
3077
3093
  const models = readModels();
3078
3094
  if (!models.includes(parsed.model)) {
@@ -3310,6 +3326,7 @@ function addProviderToConfig(params = {}) {
3310
3326
  `wire_api = "responses"`,
3311
3327
  `requires_openai_auth = true`,
3312
3328
  `preferred_auth_method = "${safeKey}"`,
3329
+ `experimental_bearer_token = "${safeKey}"`,
3313
3330
  `models = [{ id = "${escapeTomlBasicString(model)}", name = "${escapeTomlBasicString(model)}" }]`,
3314
3331
  ...extraLines,
3315
3332
  `request_max_retries = 4`,
@@ -10921,17 +10938,16 @@ function cmdSwitch(providerName, silent = false) {
10921
10938
  throw new Error('提供商不存在');
10922
10939
  }
10923
10940
 
10924
- // 切换提供商
10941
+ // 切换提供商(单文件认证:同步确保目标块含 experimental_bearer_token)
10925
10942
  const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
10926
- const newContent = content.replace(
10943
+ const switchedContent = content.replace(
10927
10944
  /^(model_provider\s*=\s*)(["']).*?(["'])/m,
10928
10945
  `$1$2${providerName}$3`
10929
10946
  );
10930
- writeConfig(newContent);
10931
-
10932
- // 更新认证信息
10933
- const apiKey = providers[providerName].preferred_auth_method || '';
10934
- updateAuthJson(apiKey);
10947
+ const providerSegments = providers[providerName] && Array.isArray(providers[providerName].__codexmate_legacy_segments)
10948
+ ? providers[providerName].__codexmate_legacy_segments
10949
+ : null;
10950
+ writeConfig(ensureActiveProviderBearerToken(switchedContent, providerName, providerSegments));
10935
10951
 
10936
10952
  // 切换到该提供商的模型
10937
10953
  const currentModels = readCurrentModels();
@@ -11072,6 +11088,7 @@ base_url = "${safeBaseUrl}"
11072
11088
  wire_api = "responses"
11073
11089
  requires_openai_auth = true
11074
11090
  preferred_auth_method = "${safeApiKey}"
11091
+ experimental_bearer_token = "${safeApiKey}"
11075
11092
  request_max_retries = 4
11076
11093
  stream_max_retries = 10
11077
11094
  stream_idle_timeout_ms = 300000
@@ -11373,6 +11390,7 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
11373
11390
  updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', computeLocalOpenaiBridgeBaseUrl());
11374
11391
  updatedBlock = replaceTomlBooleanField(updatedBlock, 'requires_openai_auth', true);
11375
11392
  updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', 'codexmate');
11393
+ updatedBlock = replaceTomlStringField(updatedBlock, 'experimental_bearer_token', 'codexmate');
11376
11394
  updatedBlock = replaceTomlStringField(updatedBlock, 'codexmate_bridge', 'openai');
11377
11395
  if (hasOpenaiBridgeMaxRetries) {
11378
11396
  updatedBlock = replaceTomlNumberField(updatedBlock, 'codexmate_bridge_max_retries', openaiBridgeMaxRetries);
@@ -11383,6 +11401,7 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
11383
11401
  }
11384
11402
  if (apiKey !== undefined) {
11385
11403
  updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', apiKey);
11404
+ updatedBlock = replaceTomlStringField(updatedBlock, 'experimental_bearer_token', apiKey);
11386
11405
  }
11387
11406
  }
11388
11407
 
@@ -11397,12 +11416,6 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
11397
11416
  }
11398
11417
  writeConfig(finalContent);
11399
11418
 
11400
- // 如果更新了 API Key 且该提供商是当前激活的,同步更新 auth.json
11401
- const currentProvider = config.model_provider;
11402
- if (apiKey !== undefined && name === currentProvider) {
11403
- updateAuthJson(apiKey);
11404
- }
11405
-
11406
11419
  if (!silent) {
11407
11420
  console.log('✓ 已更新提供商:', name);
11408
11421
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
package/web-ui/app.js CHANGED
@@ -575,6 +575,7 @@ document.addEventListener('DOMContentLoaded', () => {
575
575
  kilocodeAutoSaveSignature: '',
576
576
  forceCompactLayout: false,
577
577
  sidebarCollapsed: false,
578
+ theme: 'light',
578
579
  sessionLoadNativeDialog: false,
579
580
  starPrompted: false,
580
581
  webhookConfig: { enabled: false, url: '', events: ['provider-switch', 'claude-md-edit'] },
@@ -604,6 +605,9 @@ document.addEventListener('DOMContentLoaded', () => {
604
605
  if (typeof this.initI18n === 'function') {
605
606
  this.initI18n();
606
607
  }
608
+ if (typeof this.applyDocumentTheme === 'function') {
609
+ this.applyDocumentTheme();
610
+ }
607
611
  if (typeof this.loadWebhookSettings === 'function') {
608
612
  this.loadWebhookSettings();
609
613
  }
package/web-ui/index.html CHANGED
@@ -11,6 +11,17 @@
11
11
  <script src="/res/marked.min.js"></script>
12
12
  <script src="/res/purify.min.js"></script>
13
13
  <link rel="stylesheet" href="/web-ui/styles.css">
14
+ <script>
15
+ // Render hint: skip the light flash for dark-theme users before the
16
+ // server preference store loads. Kept in sync by applyDocumentTheme().
17
+ (function () {
18
+ try {
19
+ if (localStorage.getItem('codexmateThemeCache') === 'dark') {
20
+ document.documentElement.setAttribute('data-theme', 'dark');
21
+ }
22
+ } catch (_) {}
23
+ })();
24
+ </script>
14
25
  </head>
15
26
  <body>
16
27
  <!-- @include ./partials/index/layout-header.html -->
@@ -38,6 +38,7 @@ import { createPluginsMethods } from './plugins.methods.mjs';
38
38
  import { createI18nMethods } from './i18n.mjs';
39
39
  import { createWebhookMethods } from './app.methods.webhook.mjs';
40
40
  import { createWebUiPreferencesMethods } from './app.methods.web-ui-preferences.mjs';
41
+ import { createThemeMethods } from './app.methods.theme.mjs';
41
42
  import {
42
43
  CONFIG_MODE_SET,
43
44
  getProviderConfigModeMeta
@@ -92,6 +93,7 @@ export function createAppMethods() {
92
93
  ...createProvidersMethods({ api }),
93
94
  ...createProviderCacheMethods({ api }),
94
95
  ...createWebUiPreferencesMethods({ api }),
96
+ ...createThemeMethods(),
95
97
  ...createClaudeConfigMethods({ api }),
96
98
  ...createToolConfigPermissionMethods({ api }),
97
99
  ...createOpenclawCoreMethods(),
@@ -0,0 +1,38 @@
1
+ const THEME_STORAGE_CACHE_KEY = 'codexmateThemeCache';
2
+
3
+ export function createThemeMethods() {
4
+ return {
5
+ normalizeTheme(value) {
6
+ return value === 'dark' ? 'dark' : 'light';
7
+ },
8
+ applyDocumentTheme() {
9
+ try {
10
+ if (typeof document === 'undefined' || !document.documentElement) return;
11
+ const next = this.normalizeTheme(this.theme);
12
+ if (next === 'dark') {
13
+ document.documentElement.setAttribute('data-theme', 'dark');
14
+ } else {
15
+ document.documentElement.removeAttribute('data-theme');
16
+ }
17
+ // Render hint only: lets the inline head script skip the light
18
+ // flash before server preferences arrive. Source of truth is
19
+ // the server-side webUi preference store.
20
+ const storage = typeof globalThis !== 'undefined' ? globalThis.localStorage : null;
21
+ if (storage && typeof storage.setItem === 'function' && typeof storage.removeItem === 'function') {
22
+ if (next === 'dark') storage.setItem(THEME_STORAGE_CACHE_KEY, 'dark');
23
+ else storage.removeItem(THEME_STORAGE_CACHE_KEY);
24
+ }
25
+ } catch (_) {}
26
+ },
27
+ setTheme(theme) {
28
+ const next = this.normalizeTheme(theme);
29
+ if (next !== this.theme) {
30
+ this.theme = next;
31
+ this.applyDocumentTheme();
32
+ }
33
+ if (typeof this.persistWebUiPreferences === 'function') {
34
+ this.persistWebUiPreferences({ theme: next });
35
+ }
36
+ }
37
+ };
38
+ }
@@ -352,6 +352,9 @@ export function createWebUiPreferencesMethods(options = {}) {
352
352
  ? source.projectClaudeMdPath
353
353
  : (typeof this.projectClaudeMdPath === 'string' ? this.projectClaudeMdPath : ''),
354
354
  sidebarCollapsed: normalizeBoolean(hasOwn(source, 'sidebarCollapsed') ? source.sidebarCollapsed : this.sidebarCollapsed, false),
355
+ theme: typeof this.normalizeTheme === 'function'
356
+ ? this.normalizeTheme(hasOwn(source, 'theme') ? source.theme : this.theme)
357
+ : 'light',
355
358
  configModeVisibility: typeof this.normalizeConfigModeVisibility === 'function'
356
359
  ? this.normalizeConfigModeVisibility(hasOwn(source, 'configModeVisibility') ? source.configModeVisibility : this.configModeVisibility)
357
360
  : { codex: true, claude: true, openclaw: true, opencode: true, kilocode: true },
@@ -423,6 +426,12 @@ export function createWebUiPreferencesMethods(options = {}) {
423
426
  if (hasOwn(source, 'sidebarCollapsed')) {
424
427
  this.sidebarCollapsed = normalizeBoolean(source.sidebarCollapsed, false);
425
428
  }
429
+ if (typeof source.theme === 'string') {
430
+ this.theme = typeof this.normalizeTheme === 'function'
431
+ ? this.normalizeTheme(source.theme)
432
+ : 'light';
433
+ if (typeof this.applyDocumentTheme === 'function') this.applyDocumentTheme();
434
+ }
426
435
  if (hasOwn(source, 'configModeVisibility') && typeof this.normalizeConfigModeVisibility === 'function') {
427
436
  this.configModeVisibility = this.normalizeConfigModeVisibility(source.configModeVisibility);
428
437
  }
@@ -706,7 +706,7 @@ const en = Object.freeze({
706
706
  'sessions.preview.loading': 'Loading...',
707
707
  'sessions.batch.select': 'Batch delete',
708
708
  'sessions.batch.exit': 'Exit selection',
709
- 'sessions.batch.selectAll': 'Select all',
709
+ 'sessions.batch.selectAllVisibleN': 'Select {count} visible',
710
710
  'sessions.batch.unselectAll': 'Clear selection',
711
711
  'sessions.batch.deleteSelected': 'Delete selected',
712
712
  'sessions.batch.deleteSelectedN': 'Delete selected ({count})',
@@ -1133,6 +1133,11 @@ const en = Object.freeze({
1133
1133
  'settings.language.meta': 'Choose the Web UI display language',
1134
1134
  'settings.language.label': 'Interface language',
1135
1135
  'settings.language.hint': 'Language preference is saved in this browser.',
1136
+ 'settings.theme.title': 'Appearance',
1137
+ 'settings.theme.meta': 'Choose the Web UI color theme',
1138
+ 'settings.theme.light': 'Light',
1139
+ 'settings.theme.dark': 'Dark',
1140
+ 'settings.theme.hint': 'Theme preference is saved automatically and persists across reloads.',
1136
1141
  'settings.sharePrefix.title': 'Share command prefix',
1137
1142
  'settings.sharePrefix.meta': 'Used as the prefix for “Copy share command” in the Web UI',
1138
1143
  'settings.sharePrefix.label': 'Prefix',
@@ -708,7 +708,7 @@ const ja = Object.freeze({
708
708
  'sessions.preview.loading': 'プレビュー読み込み中...',
709
709
  'sessions.batch.select': '一括削除',
710
710
  'sessions.batch.exit': '選択を終了',
711
- 'sessions.batch.selectAll': 'すべて選択',
711
+ 'sessions.batch.selectAllVisibleN': '表示中の {count} 件を選択',
712
712
  'sessions.batch.unselectAll': '選択を解除',
713
713
  'sessions.batch.deleteSelected': '選択を削除',
714
714
  'sessions.batch.deleteSelectedN': '選択を削除({count})',
@@ -1136,6 +1136,11 @@ const ja = Object.freeze({
1136
1136
  'settings.language.meta': 'Web UI の表示言語を選択',
1137
1137
  'settings.language.label': 'インターフェース言語',
1138
1138
  'settings.language.hint': '言語設定はこのブラウザに保存されます。',
1139
+ 'settings.theme.title': '外観テーマ',
1140
+ 'settings.theme.meta': 'Web UI のカラーテーマを選択します',
1141
+ 'settings.theme.light': 'ライト',
1142
+ 'settings.theme.dark': 'ダーク',
1143
+ 'settings.theme.hint': 'テーマ設定は自動保存され、再読み込み後も保持されます。',
1139
1144
  'settings.sharePrefix.title': '共有コマンドプレフィックス',
1140
1145
  'settings.sharePrefix.meta': 'Web UI の「共有コマンドをコピー」のプレフィックスに影響',
1141
1146
  'settings.sharePrefix.label': 'プレフィックス',
@@ -397,6 +397,11 @@ const vi = Object.freeze({
397
397
  'settings.language.meta': 'Chọn ngôn ngữ hiển thị cho Web UI',
398
398
  'settings.language.label': 'Ngôn ngữ giao diện',
399
399
  'settings.language.hint': 'Tùy chọn ngôn ngữ được lưu trong trình duyệt này.',
400
+ 'settings.theme.title': 'Chủ đề giao diện',
401
+ 'settings.theme.meta': 'Chọn chủ đề màu cho Web UI',
402
+ 'settings.theme.light': 'Sáng',
403
+ 'settings.theme.dark': 'Tối',
404
+ 'settings.theme.hint': 'Tùy chọn chủ đề được tự động lưu và giữ nguyên sau khi tải lại.',
400
405
 
401
406
  'settings.webhook.title': 'Webhook',
402
407
  'settings.webhook.meta': 'Gửi thông báo sự kiện tới dịch vụ bên ngoài',
@@ -881,7 +886,7 @@ const vi = Object.freeze({
881
886
  'sessions.preview.loading': 'Đang tải...',
882
887
  'sessions.batch.select': 'Xóa hàng loạt',
883
888
  'sessions.batch.exit': 'Thoát chọn',
884
- 'sessions.batch.selectAll': 'Chọn tất cả',
889
+ 'sessions.batch.selectAllVisibleN': 'Chọn {count} mục hiển thị',
885
890
  'sessions.batch.unselectAll': 'Bỏ chọn tất cả',
886
891
  'sessions.batch.deleteSelected': 'Xóa đã chọn',
887
892
  'sessions.batch.deleteSelectedN': 'Xóa đã chọn ({count})',
@@ -705,7 +705,7 @@ const zhTw = Object.freeze({
705
705
  'sessions.preview.loading': '載入中...',
706
706
  'sessions.batch.select': '批次刪除',
707
707
  'sessions.batch.exit': '退出選擇',
708
- 'sessions.batch.selectAll': '全選',
708
+ 'sessions.batch.selectAllVisibleN': '全選可見 {count} 項',
709
709
  'sessions.batch.unselectAll': '取消全選',
710
710
  'sessions.batch.deleteSelected': '刪除選中',
711
711
  'sessions.batch.deleteSelectedN': '刪除選中({count})',
@@ -1132,6 +1132,11 @@ const zhTw = Object.freeze({
1132
1132
  'settings.language.meta': '選擇 Web UI 的顯示語言',
1133
1133
  'settings.language.label': '界面語言',
1134
1134
  'settings.language.hint': '語言偏好會保存在目前瀏覽器。',
1135
+ 'settings.theme.title': '外觀主題',
1136
+ 'settings.theme.meta': '選擇 Web UI 的配色主題',
1137
+ 'settings.theme.light': '淺色',
1138
+ 'settings.theme.dark': '深色',
1139
+ 'settings.theme.hint': '主題偏好會自動儲存,重新整理後保持不變。',
1135
1140
  'settings.sharePrefix.title': '分享命令前綴',
1136
1141
  'settings.sharePrefix.meta': '影響 Web UI 裡“複製分享命令”的前綴',
1137
1142
  'settings.sharePrefix.label': '前綴',
@@ -705,7 +705,7 @@ const zh = Object.freeze({
705
705
  'sessions.preview.loading': '加载中...',
706
706
  'sessions.batch.select': '批量删除',
707
707
  'sessions.batch.exit': '退出选择',
708
- 'sessions.batch.selectAll': '全选',
708
+ 'sessions.batch.selectAllVisibleN': '全选可见 {count} 项',
709
709
  'sessions.batch.unselectAll': '取消全选',
710
710
  'sessions.batch.deleteSelected': '删除选中',
711
711
  'sessions.batch.deleteSelectedN': '删除选中({count})',
@@ -1132,6 +1132,11 @@ const zh = Object.freeze({
1132
1132
  'settings.language.meta': '选择 Web UI 的显示语言',
1133
1133
  'settings.language.label': '界面语言',
1134
1134
  'settings.language.hint': '语言偏好会保存在当前浏览器。',
1135
+ 'settings.theme.title': '外观主题',
1136
+ 'settings.theme.meta': '选择 Web UI 的配色主题',
1137
+ 'settings.theme.light': '浅色',
1138
+ 'settings.theme.dark': '深色',
1139
+ 'settings.theme.hint': '主题偏好会自动保存,刷新后保持不变。',
1135
1140
  'settings.sharePrefix.title': '分享命令前缀',
1136
1141
  'settings.sharePrefix.meta': '影响 Web UI 里“复制分享命令”的前缀',
1137
1142
  'settings.sharePrefix.label': '前缀',
@@ -105,8 +105,8 @@
105
105
  <span>{{ t('sessions.batch.select') }}</span>
106
106
  </button>
107
107
  <template v-else>
108
- <button class="btn-tool btn-tool-compact" type="button" @click="toggleSelectAllVisibleForBatch" :disabled="sessionsLoading" :title="isAllVisibleBatchSelected() ? t('sessions.batch.unselectAll') : t('sessions.batch.selectAll')">
109
- {{ isAllVisibleBatchSelected() ? t('sessions.batch.unselectAll') : t('sessions.batch.selectAll') }}
108
+ <button class="btn-tool btn-tool-compact" type="button" @click="toggleSelectAllVisibleForBatch" :disabled="sessionsLoading" :title="isAllVisibleBatchSelected() ? t('sessions.batch.unselectAll') : t('sessions.batch.selectAllVisibleN', { count: getVisibleBatchSelectableCount() })">
109
+ {{ isAllVisibleBatchSelected() ? t('sessions.batch.unselectAll') : t('sessions.batch.selectAllVisibleN', { count: getVisibleBatchSelectableCount() }) }}
110
110
  </button>
111
111
  <button class="btn-tool btn-tool-compact btn-tool-batch-delete" type="button" @click="deleteSelectedSessions" :disabled="sessionsLoading || sessionDeletingSelected || getSelectedBatchCount() === 0" :title="getSelectedBatchCount() > 0 ? t('sessions.batch.deleteSelectedN', { count: getSelectedBatchCount() }) : t('sessions.batch.emptySelection')">
112
112
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M7 6V4a2 2 0 012-2h6a2 2 0 012 2v2M10 11v6M14 11v6"/><path d="M5 6l1 14a2 2 0 002 2h8a2 2 0 002-2l1-14"/></svg>
@@ -61,6 +61,30 @@
61
61
  </div>
62
62
  </section>
63
63
 
64
+ <section class="settings-card" :aria-label="t('settings.theme.title')">
65
+ <div class="settings-card-main">
66
+ <div class="settings-card-content">
67
+ <div class="settings-card-title">{{ t('settings.theme.title') }}</div>
68
+ <p class="settings-card-desc">{{ t('settings.theme.meta') }}</p>
69
+ <div class="settings-toggle-group">
70
+ <button
71
+ type="button"
72
+ :class="['toggle-btn', { active: theme === 'light' }]"
73
+ @click="setTheme('light')">
74
+ {{ t('settings.theme.light') }}
75
+ </button>
76
+ <button
77
+ type="button"
78
+ :class="['toggle-btn', { active: theme === 'dark' }]"
79
+ @click="setTheme('dark')">
80
+ {{ t('settings.theme.dark') }}
81
+ </button>
82
+ </div>
83
+ <p class="settings-card-hint">{{ t('settings.theme.hint') }}</p>
84
+ </div>
85
+ </div>
86
+ </section>
87
+
64
88
  <section class="settings-card" :aria-label="t('settings.sharePrefix.title')">
65
89
  <div class="settings-card-main">
66
90
  <div class="settings-card-content">
@@ -4052,8 +4052,8 @@ return function render(_ctx, _cache) {
4052
4052
  type: "button",
4053
4053
  onClick: _ctx.toggleSelectAllVisibleForBatch,
4054
4054
  disabled: _ctx.sessionsLoading,
4055
- title: _ctx.isAllVisibleBatchSelected() ? _ctx.t('sessions.batch.unselectAll') : _ctx.t('sessions.batch.selectAll')
4056
- }, _toDisplayString(_ctx.isAllVisibleBatchSelected() ? _ctx.t('sessions.batch.unselectAll') : _ctx.t('sessions.batch.selectAll')), 9 /* TEXT, PROPS */, ["onClick", "disabled", "title"]),
4055
+ title: _ctx.isAllVisibleBatchSelected() ? _ctx.t('sessions.batch.unselectAll') : _ctx.t('sessions.batch.selectAllVisibleN', { count: _ctx.getVisibleBatchSelectableCount() })
4056
+ }, _toDisplayString(_ctx.isAllVisibleBatchSelected() ? _ctx.t('sessions.batch.unselectAll') : _ctx.t('sessions.batch.selectAllVisibleN', { count: _ctx.getVisibleBatchSelectableCount() })), 9 /* TEXT, PROPS */, ["onClick", "disabled", "title"]),
4057
4057
  _createElementVNode("button", {
4058
4058
  class: "btn-tool btn-tool-compact btn-tool-batch-delete",
4059
4059
  type: "button",
@@ -5535,6 +5535,30 @@ return function render(_ctx, _cache) {
5535
5535
  ])
5536
5536
  ])
5537
5537
  ], 8 /* PROPS */, ["aria-label"]),
5538
+ _createElementVNode("section", {
5539
+ class: "settings-card",
5540
+ "aria-label": _ctx.t('settings.theme.title')
5541
+ }, [
5542
+ _createElementVNode("div", { class: "settings-card-main" }, [
5543
+ _createElementVNode("div", { class: "settings-card-content" }, [
5544
+ _createElementVNode("div", { class: "settings-card-title" }, _toDisplayString(_ctx.t('settings.theme.title')), 1 /* TEXT */),
5545
+ _createElementVNode("p", { class: "settings-card-desc" }, _toDisplayString(_ctx.t('settings.theme.meta')), 1 /* TEXT */),
5546
+ _createElementVNode("div", { class: "settings-toggle-group" }, [
5547
+ _createElementVNode("button", {
5548
+ type: "button",
5549
+ class: _normalizeClass(['toggle-btn', { active: _ctx.theme === 'light' }]),
5550
+ onClick: $event => (_ctx.setTheme('light'))
5551
+ }, _toDisplayString(_ctx.t('settings.theme.light')), 11 /* TEXT, CLASS, PROPS */, ["onClick"]),
5552
+ _createElementVNode("button", {
5553
+ type: "button",
5554
+ class: _normalizeClass(['toggle-btn', { active: _ctx.theme === 'dark' }]),
5555
+ onClick: $event => (_ctx.setTheme('dark'))
5556
+ }, _toDisplayString(_ctx.t('settings.theme.dark')), 11 /* TEXT, CLASS, PROPS */, ["onClick"])
5557
+ ]),
5558
+ _createElementVNode("p", { class: "settings-card-hint" }, _toDisplayString(_ctx.t('settings.theme.hint')), 1 /* TEXT */)
5559
+ ])
5560
+ ])
5561
+ ], 8 /* PROPS */, ["aria-label"]),
5538
5562
  _createElementVNode("section", {
5539
5563
  class: "settings-card",
5540
5564
  "aria-label": _ctx.t('settings.sharePrefix.title')
@@ -15,6 +15,7 @@
15
15
  --color-surface-alt: #F7EFE8;
16
16
  --color-surface-elevated: #FFFFFF;
17
17
  --color-surface-tint: rgba(255, 253, 250, 0.86);
18
+ --color-surface-chip: rgba(255, 255, 255, 0.76);
18
19
  --color-text-primary: #241F1C;
19
20
  --color-text-secondary: #5A504A;
20
21
  --color-text-tertiary: #82746A;
@@ -157,7 +157,7 @@
157
157
  line-height: 1 !important;
158
158
  border-radius: var(--radius-sm) !important;
159
159
  border: 1px solid var(--color-border-soft) !important;
160
- background: rgba(255, 255, 255, 0.76) !important;
160
+ background: var(--color-surface-chip) !important;
161
161
  font-size: 12px !important;
162
162
  font-weight: var(--font-weight-secondary) !important;
163
163
  color: var(--color-text-secondary) !important;
@@ -179,7 +179,7 @@
179
179
  -webkit-tap-highlight-color: transparent;
180
180
  }
181
181
 
182
- .session-source-pill:hover:not(:disabled) {
182
+ .session-source-pill:hover:not(:disabled):not(.active) {
183
183
  color: var(--color-text-secondary);
184
184
  background: var(--color-surface);
185
185
  }
@@ -46,7 +46,7 @@
46
46
  -webkit-tap-highlight-color: transparent;
47
47
  }
48
48
 
49
- .usage-range-btn:hover:not(:disabled) {
49
+ .usage-range-btn:hover:not(:disabled):not(.active) {
50
50
  color: var(--color-text-secondary);
51
51
  background: rgba(200, 121, 99, 0.08);
52
52
  }
@@ -171,7 +171,7 @@
171
171
  left: 3px;
172
172
  width: 20px;
173
173
  height: 20px;
174
- background: white;
174
+ background: #fff;
175
175
  border-radius: 50%;
176
176
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
177
177
  transition: all 0.25s cubic-bezier(0.25, 1, 0.5, 1);
@@ -229,7 +229,7 @@
229
229
  flex: 1;
230
230
  }
231
231
 
232
- .settings-toggle-group .toggle-btn:hover:not(:disabled) {
232
+ .settings-toggle-group .toggle-btn:hover:not(:disabled):not(.active) {
233
233
  color: var(--color-text-secondary);
234
234
  background: var(--color-surface);
235
235
  }