codexmate 0.0.34 → 0.0.37

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.
Files changed (34) hide show
  1. package/README.md +14 -5
  2. package/README.zh.md +14 -5
  3. package/cli.js +74 -61
  4. package/package.json +2 -1
  5. package/web-ui/app.js +32 -2
  6. package/web-ui/index.html +1 -1
  7. package/web-ui/logic.sessions.mjs +6 -5
  8. package/web-ui/modules/app.computed.dashboard.mjs +4 -0
  9. package/web-ui/modules/app.computed.session.mjs +147 -6
  10. package/web-ui/modules/app.methods.claude-config.mjs +4 -0
  11. package/web-ui/modules/app.methods.navigation.mjs +32 -16
  12. package/web-ui/modules/app.methods.session-browser.mjs +7 -0
  13. package/web-ui/modules/app.methods.session-trash.mjs +30 -0
  14. package/web-ui/modules/i18n.dict.mjs +5 -0
  15. package/web-ui/modules/sessions-filters-url.mjs +65 -12
  16. package/web-ui/modules/skills.methods.mjs +31 -0
  17. package/web-ui/partials/index/layout-header.html +20 -11
  18. package/web-ui/partials/index/panel-config-claude.html +5 -3
  19. package/web-ui/partials/index/panel-config-codex.html +1 -1
  20. package/web-ui/partials/index/panel-market.html +76 -149
  21. package/web-ui/partials/index/panel-sessions.html +2 -2
  22. package/web-ui/partials/index/panel-settings.html +4 -2
  23. package/web-ui/partials/index/panel-usage.html +111 -68
  24. package/web-ui/res/vue.runtime.global.prod.js +7 -0
  25. package/web-ui/res/web-ui-render.precompiled.js +7269 -0
  26. package/web-ui/session-helpers.mjs +15 -4
  27. package/web-ui/source-bundle.cjs +73 -1
  28. package/web-ui/styles/base-theme.css +10 -0
  29. package/web-ui/styles/layout-shell.css +65 -27
  30. package/web-ui/styles/navigation-panels.css +8 -0
  31. package/web-ui/styles/responsive.css +50 -9
  32. package/web-ui/styles/sessions-usage.css +501 -336
  33. package/web-ui/styles/skills-market.css +294 -0
  34. package/web-ui/styles/titles-cards.css +14 -0
@@ -56,6 +56,32 @@
56
56
  return null;
57
57
  }
58
58
  };
59
+
60
+ const canonicalizeWebUiRuntimeUrl = () => {
61
+ if (typeof window === 'undefined' || !window.location) return;
62
+ try {
63
+ const url = new URL(window.location.href);
64
+ if (url.pathname === '/session') return;
65
+ let pathname = url.pathname;
66
+ let previousPathname = '';
67
+ do {
68
+ previousPathname = pathname;
69
+ pathname = pathname.replace(/\/+web-ui\/+web-ui\/+/, '/web-ui/');
70
+ } while (pathname !== previousPathname);
71
+ if (pathname === '/web-ui' || pathname === '/web-ui/' || pathname === '/web-ui/index.html') {
72
+ pathname = '/';
73
+ }
74
+ url.pathname = pathname;
75
+ url.search = '';
76
+ url.hash = '';
77
+ const nextUrl = url.toString();
78
+ if (nextUrl === window.location.href) return;
79
+ if (window.history && typeof window.history.replaceState === 'function') {
80
+ window.history.replaceState(null, '', nextUrl);
81
+ }
82
+ } catch (_) {}
83
+ };
84
+
59
85
  const persistNavState = (vm, overrides = null) => {
60
86
  if (!vm || vm.__navStateRestoring) return;
61
87
  if (typeof localStorage === 'undefined') return;
@@ -138,6 +164,7 @@
138
164
  const normalizedMode = typeof mode === 'string'
139
165
  ? mode.trim().toLowerCase()
140
166
  : '';
167
+ canonicalizeWebUiRuntimeUrl();
141
168
  this.cancelTouchNavIntentReset();
142
169
  if (typeof this.ensureMainTabSwitchState === 'function') {
143
170
  this.ensureMainTabSwitchState().pendingConfigMode = '';
@@ -412,6 +439,7 @@
412
439
  : '';
413
440
  const targetTab = normalizedTab || tab;
414
441
  if (!targetTab) return;
442
+ canonicalizeWebUiRuntimeUrl();
415
443
  if (targetTab === 'orchestration' && this.taskOrchestrationTabEnabled !== true) {
416
444
  return this.switchMainTab('config');
417
445
  }
@@ -419,20 +447,7 @@
419
447
  mainTab: targetTab,
420
448
  configMode: targetTab === 'config' ? this.configMode : this.configMode
421
449
  });
422
- if (targetTab !== 'sessions') {
423
- try {
424
- const url = new URL(window.location.href);
425
- if (url.pathname !== '/session') {
426
- url.searchParams.delete('s_source');
427
- url.searchParams.delete('s_path');
428
- url.searchParams.delete('s_query');
429
- url.searchParams.delete('s_role');
430
- url.searchParams.delete('s_time');
431
- url.searchParams.delete('tab');
432
- window.history.replaceState(null, '', url.toString());
433
- }
434
- } catch (_) {}
435
- }
450
+ // URL 保持静态,不写入任何状态
436
451
  this.cancelTouchNavIntentReset();
437
452
  if (targetTab === 'sessions') {
438
453
  this.cancelScheduledSessionTabDeferredTeardown();
@@ -474,9 +489,10 @@
474
489
  return;
475
490
  }
476
491
  const isLeavingSessions = previousTab === 'sessions' && targetTab !== 'sessions';
477
- const shouldDeferApply = isLeavingSessions;
492
+ const shouldPreserveSessionRender = isLeavingSessions && this.preserveSessionRenderOnTabLeave === true;
493
+ const shouldDeferApply = isLeavingSessions && !shouldPreserveSessionRender;
478
494
  if (isLeavingSessions && !this.isSessionPanelFastHidden()) {
479
- this.setSessionPanelFastHidden(true);
495
+ this.setSessionPanelFastHidden(!shouldPreserveSessionRender);
480
496
  }
481
497
  if (shouldDeferApply && typeof this.suspendSessionTabRender === 'function') {
482
498
  this.suspendSessionTabRender();
@@ -264,6 +264,13 @@ export function createSessionBrowserMethods(options = {}) {
264
264
  });
265
265
  if (urlState) {
266
266
  applySessionsFilterUrlState(this, urlState);
267
+ // 清理 URL,保持静态
268
+ try {
269
+ const url = new URL(window.location.href);
270
+ url.search = '';
271
+ url.hash = '';
272
+ window.history.replaceState(null, '', url.toString());
273
+ } catch (_) {}
267
274
  try {
268
275
  const sortCache = localStorage.getItem('codexmateSessionSortMode');
269
276
  this.sessionSortMode = normalizeSortMode(sortCache);
@@ -218,6 +218,36 @@ export function createSessionTrashMethods(options = {}) {
218
218
  await this.switchSettingsTab(tab);
219
219
  },
220
220
 
221
+ async onSettingsTabKeydown(event, tab) {
222
+ if (!event) {
223
+ return;
224
+ }
225
+ const tabs = ['general', 'data'];
226
+ const currentTab = this.normalizeSettingsTab(tab || this.settingsTab);
227
+ const currentIndex = Math.max(0, tabs.indexOf(currentTab));
228
+ let nextIndex = currentIndex;
229
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
230
+ nextIndex = (currentIndex + 1) % tabs.length;
231
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
232
+ nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
233
+ } else if (event.key === 'Home') {
234
+ nextIndex = 0;
235
+ } else if (event.key === 'End') {
236
+ nextIndex = tabs.length - 1;
237
+ } else {
238
+ return;
239
+ }
240
+ event.preventDefault();
241
+ const nextTab = tabs[nextIndex];
242
+ await this.switchSettingsTab(nextTab);
243
+ const target = typeof document !== 'undefined'
244
+ ? document.getElementById(`settings-tab-${nextTab}`)
245
+ : null;
246
+ if (target && typeof target.focus === 'function') {
247
+ target.focus();
248
+ }
249
+ },
250
+
221
251
  async switchSettingsTab(tab, options = {}) {
222
252
  const nextTab = this.normalizeSettingsTab(tab);
223
253
  this.settingsTab = nextTab;
@@ -532,6 +532,7 @@ const DICT = Object.freeze({
532
532
  'sessions.loadingList': '会话加载中...',
533
533
  'sessions.empty': '暂无可用会话记录',
534
534
  'sessions.unknownTime': '未知时间',
535
+
535
536
  'sessions.query.placeholder.enabled': '关键词检索(支持 Codex/Claude/Gemini/CodeBuddy,例:claude code)',
536
537
  'sessions.query.placeholder.disabled': '当前来源暂不支持关键词检索',
537
538
  'sessions.pin': '置顶',
@@ -1602,6 +1603,8 @@ const DICT = Object.freeze({
1602
1603
  'sessions.loadingList': 'セッション一覧を読み込み中...',
1603
1604
  'sessions.empty': 'セッションがありません',
1604
1605
  'sessions.unknownTime': '不明な時間',
1606
+
1607
+
1605
1608
  'sessions.query.placeholder.enabled': 'セッションを検索...',
1606
1609
  'sessions.query.placeholder.disabled': '現在のソースでは検索は利用できません',
1607
1610
  'sessions.pin': 'ピン留め',
@@ -2658,6 +2661,8 @@ const DICT = Object.freeze({
2658
2661
  'sessions.loadingList': 'Loading sessions...',
2659
2662
  'sessions.empty': 'No sessions found',
2660
2663
  'sessions.unknownTime': 'unknown time',
2664
+
2665
+
2661
2666
  'sessions.query.placeholder.enabled': 'Search keywords (Codex/Claude/Gemini/CodeBuddy, e.g. claude code)',
2662
2667
  'sessions.query.placeholder.disabled': 'Keyword search is not available for this source',
2663
2668
  'sessions.pin': 'Pin',
@@ -55,20 +55,76 @@ export function applySessionsFilterUrlState(vm, state) {
55
55
  }
56
56
  }
57
57
 
58
+ export function canonicalizeWebUiUrl(url) {
59
+ if (!url || typeof url !== 'object') return url;
60
+ if (url.pathname === '/web-ui/index.html') {
61
+ url.pathname = '/';
62
+ }
63
+ return url;
64
+ }
65
+
66
+ function canonicalizeWebUiHistoryUrl(value) {
67
+ if (typeof window === 'undefined' || !window.location) return value;
68
+ if (typeof value === 'undefined' || value === null) return value;
69
+ try {
70
+ const url = canonicalizeWebUiUrl(new URL(String(value), window.location.href));
71
+ return url && url.pathname === '/' ? url.href : value;
72
+ } catch (_) {
73
+ return value;
74
+ }
75
+ }
76
+
77
+ export function normalizeCurrentWebUiUrl() {
78
+ try {
79
+ const url = canonicalizeWebUiUrl(new URL(window.location.href));
80
+ if (url && url.href !== window.location.href) {
81
+ window.history.replaceState(null, '', url.href);
82
+ }
83
+ return url;
84
+ } catch (_) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function installWebUiUrlCanonicalization() {
90
+ if (typeof window === 'undefined' || !window.history) return false;
91
+ if (window.__codexmateWebUiUrlCanonicalizationInstalled) {
92
+ normalizeCurrentWebUiUrl();
93
+ return true;
94
+ }
95
+ try {
96
+ const originalReplaceState = window.history.replaceState;
97
+ const originalPushState = window.history.pushState;
98
+ if (typeof originalReplaceState === 'function') {
99
+ window.history.replaceState = function replaceState(state, title, url) {
100
+ return originalReplaceState.call(this, state, title, canonicalizeWebUiHistoryUrl(url));
101
+ };
102
+ }
103
+ if (typeof originalPushState === 'function') {
104
+ window.history.pushState = function pushState(state, title, url) {
105
+ return originalPushState.call(this, state, title, canonicalizeWebUiHistoryUrl(url));
106
+ };
107
+ }
108
+ window.__codexmateWebUiUrlCanonicalizationInstalled = true;
109
+ normalizeCurrentWebUiUrl();
110
+ return true;
111
+ } catch (_) {
112
+ normalizeCurrentWebUiUrl();
113
+ return false;
114
+ }
115
+ }
116
+
58
117
  export function buildSessionsFilterShareUrl(vm) {
59
118
  try {
60
- const url = new URL(window.location.href);
61
- if (url.pathname === '/session') return '';
119
+ // 使用干净的根路径作为基础 URL,避免把 /web-ui/index.html 或 /session 带进分享链接。
120
+ const baseUrl = window.location.origin + '/';
121
+ const url = canonicalizeWebUiUrl(new URL(baseUrl));
122
+ url.searchParams.set('tab', 'sessions');
62
123
  url.searchParams.set('s_source', String(vm.sessionFilterSource || 'all'));
63
124
  if (vm.sessionPathFilter) url.searchParams.set('s_path', String(vm.sessionPathFilter || ''));
64
- else url.searchParams.delete('s_path');
65
125
  if (vm.sessionQuery && isSessionQueryEnabled(vm.sessionFilterSource)) url.searchParams.set('s_query', String(vm.sessionQuery || ''));
66
- else url.searchParams.delete('s_query');
67
126
  if (vm.sessionRoleFilter && vm.sessionRoleFilter !== 'all') url.searchParams.set('s_role', String(vm.sessionRoleFilter || 'all'));
68
- else url.searchParams.delete('s_role');
69
127
  if (vm.sessionTimePreset && vm.sessionTimePreset !== 'all') url.searchParams.set('s_time', String(vm.sessionTimePreset || 'all'));
70
- else url.searchParams.delete('s_time');
71
- url.searchParams.set('tab', 'sessions');
72
128
  return url.toString();
73
129
  } catch (_) {
74
130
  return '';
@@ -76,10 +132,7 @@ export function buildSessionsFilterShareUrl(vm) {
76
132
  }
77
133
 
78
134
  export function syncSessionsFilterUrl(vm) {
79
- const url = buildSessionsFilterShareUrl(vm);
80
- if (!url) return;
81
- try {
82
- window.history.replaceState(null, '', url);
83
- } catch (_) {}
135
+ // URL 保持静态,不同步状态到 URL
136
+ // 所有状态通过 localStorage 管理
84
137
  }
85
138
 
@@ -477,6 +477,37 @@ export function createSkillsMethods({ api }) {
477
477
  this.skillsDeleting = false;
478
478
  await this.scanImportableSkills({ silent: true });
479
479
  }
480
+ },
481
+
482
+ openSkillsMenu() {
483
+ // Open skills manager modal as menu
484
+ this.openSkillsManager();
485
+ },
486
+
487
+ async importSingleSkill(skill) {
488
+ if (this.skillsImporting || this.skillsZipImporting) return;
489
+ const key = this.buildSkillImportKey(skill);
490
+ this.skillsImporting = true;
491
+ try {
492
+ const res = await api('import-skills', {
493
+ targetApp: this.skillsTargetApp,
494
+ imports: [skill]
495
+ });
496
+ if (res && res.error) {
497
+ this.showMessage(res.error, 'error');
498
+ return;
499
+ }
500
+ const imported = Array.isArray(res && res.imported) ? res.imported : [];
501
+ if (imported.length > 0) {
502
+ this.showMessage(`已导入 ${skill.displayName || skill.name}`, 'success');
503
+ await this.refreshSkillsList({ silent: true });
504
+ await this.scanImportableSkills({ silent: true });
505
+ }
506
+ } catch (e) {
507
+ this.showMessage('导入失败', 'error');
508
+ } finally {
509
+ this.skillsImporting = false;
510
+ }
480
511
  }
481
512
  };
482
513
  }
@@ -1,6 +1,6 @@
1
1
  <div id="app" class="container" v-cloak>
2
2
  <div v-if="!sessionStandalone" class="top-tabs" role="tablist" :aria-label="t('nav.topTabs.aria')">
3
- <button class="top-tab"
3
+ <button type="button" class="top-tab"
4
4
  id="tab-dashboard"
5
5
  role="tab"
6
6
  data-main-tab="dashboard"
@@ -10,7 +10,7 @@
10
10
  :class="{ active: isMainTabNavActive('dashboard') }"
11
11
  @pointerdown="onMainTabPointerDown('dashboard', $event)"
12
12
  @click="onMainTabClick('dashboard', $event)">{{ t('tab.dashboard') }}</button>
13
- <button class="top-tab"
13
+ <button type="button" class="top-tab"
14
14
  id="tab-docs"
15
15
  role="tab"
16
16
  data-main-tab="docs"
@@ -20,7 +20,7 @@
20
20
  :class="{ active: isMainTabNavActive('docs') }"
21
21
  @pointerdown="onMainTabPointerDown('docs', $event)"
22
22
  @click="onMainTabClick('docs', $event)">{{ t('tab.docs') }}</button>
23
- <button class="top-tab"
23
+ <button type="button" class="top-tab"
24
24
  id="tab-config"
25
25
  role="tab"
26
26
  data-main-tab="config"
@@ -31,7 +31,7 @@
31
31
  :class="{ active: isMainTabNavActive('config') }"
32
32
  @pointerdown="onMainTabPointerDown('config', $event)"
33
33
  @click="onMainTabClick('config', $event)">{{ t('tab.config') }}</button>
34
- <button class="top-tab"
34
+ <button type="button" class="top-tab"
35
35
  id="tab-sessions"
36
36
  role="tab"
37
37
  data-main-tab="sessions"
@@ -41,7 +41,7 @@
41
41
  :class="{ active: isMainTabNavActive('sessions') }"
42
42
  @pointerdown="onMainTabPointerDown('sessions', $event)"
43
43
  @click="onMainTabClick('sessions', $event)">{{ t('tab.sessions') }}</button>
44
- <button class="top-tab"
44
+ <button type="button" class="top-tab"
45
45
  id="tab-usage"
46
46
  role="tab"
47
47
  data-main-tab="usage"
@@ -51,7 +51,7 @@
51
51
  :class="{ active: isMainTabNavActive('usage') }"
52
52
  @pointerdown="onMainTabPointerDown('usage', $event)"
53
53
  @click="onMainTabClick('usage', $event)">{{ t('tab.usage') }}</button>
54
- <button v-if="taskOrchestrationTabEnabled" class="top-tab"
54
+ <button v-if="taskOrchestrationTabEnabled" type="button" class="top-tab"
55
55
  id="tab-orchestration"
56
56
  role="tab"
57
57
  data-main-tab="orchestration"
@@ -61,7 +61,7 @@
61
61
  :class="{ active: isMainTabNavActive('orchestration') }"
62
62
  @pointerdown="onMainTabPointerDown('orchestration', $event)"
63
63
  @click="onMainTabClick('orchestration', $event)">{{ t('tab.orchestration') }}</button>
64
- <button class="top-tab"
64
+ <button type="button" class="top-tab"
65
65
  id="tab-market"
66
66
  role="tab"
67
67
  data-main-tab="market"
@@ -71,7 +71,7 @@
71
71
  :class="{ active: isMainTabNavActive('market') }"
72
72
  @pointerdown="onMainTabPointerDown('market', $event)"
73
73
  @click="onMainTabClick('market', $event)">{{ t('tab.market') }}</button>
74
- <button class="top-tab"
74
+ <button type="button" class="top-tab"
75
75
  id="tab-plugins"
76
76
  role="tab"
77
77
  data-main-tab="plugins"
@@ -81,7 +81,7 @@
81
81
  :class="{ active: isMainTabNavActive('plugins') }"
82
82
  @pointerdown="onMainTabPointerDown('plugins', $event)"
83
83
  @click="onMainTabClick('plugins', $event)">{{ t('tab.plugins') }}</button>
84
- <button class="top-tab"
84
+ <button type="button" class="top-tab"
85
85
  id="tab-settings"
86
86
  role="tab"
87
87
  data-main-tab="settings"
@@ -122,10 +122,9 @@
122
122
  <div class="brand-head">
123
123
  <img class="brand-logo" src="/res/logo-pack.webp" alt="Codex Mate logo">
124
124
  <div class="brand-copy">
125
- <div class="brand-kicker">Codex Mate <span v-if="appVersion" class="brand-version">v{{ appVersion }}</span></div>
125
+ <div class="brand-kicker">Codex Mate<span v-if="appVersion" class="brand-version"> v{{ appVersion }}</span></div>
126
126
  </div>
127
127
  </div>
128
- <div class="brand-subtitle">{{ t('brand.subtitle.localConfigSessionsWorkspace') }}</div>
129
128
  </div>
130
129
 
131
130
  <div class="side-rail-nav">
@@ -316,6 +315,10 @@
316
315
  </div>
317
316
  </button>
318
317
  </div>
318
+ <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>
320
+ <div class="side-item-meta"><span>&nbsp;</span></div>
321
+ </div>
319
322
  </div>
320
323
 
321
324
  <div class="side-rail-lang" role="group" :aria-label="t('lang.label')">
@@ -479,6 +482,12 @@
479
482
  <span class="value">{{ installRegistryPreview || t('common.defaultOfficial') }}</span>
480
483
  </div>
481
484
  </div>
485
+ <div class="status-strip status-strip-placeholder" v-else-if="!sessionStandalone" aria-hidden="true">
486
+ <div class="status-chip">
487
+ <span class="label">&nbsp;</span>
488
+ <span class="value">&nbsp;</span>
489
+ </div>
490
+ </div>
482
491
  <div
483
492
  v-if="!sessionStandalone && mainTab === 'config' && isProviderConfigMode && forceCompactLayout && !loading && !initError && displayProvidersList.length > 1"
484
493
  class="provider-fast-switch">
@@ -100,7 +100,7 @@
100
100
  </div>
101
101
 
102
102
  <div class="card-list">
103
- <div :class="['card', { active: currentClaudeConfig === 'claude-local' }]" @click="applyClaudeLocalBridge()" @keydown.enter.self.prevent="applyClaudeLocalBridge()" @keydown.space.self.prevent="applyClaudeLocalBridge()" tabindex="0" role="button" :aria-current="currentClaudeConfig === 'claude-local' ? 'true' : null">
103
+ <div :class="['card', { active: currentClaudeConfig === 'claude-local', disabled: isClaudeLocalBridgeDisabled() }]" @click="isClaudeLocalBridgeDisabled() ? null : applyClaudeLocalBridge()" @keydown.enter.self.prevent="isClaudeLocalBridgeDisabled() ? null : applyClaudeLocalBridge()" @keydown.space.self.prevent="isClaudeLocalBridgeDisabled() ? null : applyClaudeLocalBridge()" :tabindex="isClaudeLocalBridgeDisabled() ? -1 : 0" role="button" :aria-current="currentClaudeConfig === 'claude-local' ? 'true' : null">
104
104
  <div class="card-leading">
105
105
  <div class="card-icon">L</div>
106
106
  <div class="card-content">
@@ -108,8 +108,10 @@
108
108
  <svg class="bridge-pool-summary-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M6 8v4h6v4"/><path d="M18 8v4h-6v4"/></svg>
109
109
  <span class="bridge-pool-summary-text">{{ t('claude.localBridge.enabled') }} {{ claudeLocalBridgeCandidateProviders().filter(cp => !isClaudeLocalBridgeExcluded(cp.name)).length }} / {{ claudeLocalBridgeCandidateProviders().length }}</span>
110
110
  </div>
111
- <div class="card-title">claude-local</div>
112
- <div class="card-subtitle card-subtitle-model">{{ t('claude.localBridge.poolHint') }}</div>
111
+ <div class="card-title">
112
+ <span>local</span>
113
+ <span class="provider-readonly-badge">{{ t('config.badge.system') }}</span>
114
+ </div>
113
115
  </div>
114
116
  </div>
115
117
  <div class="card-trailing">
@@ -120,7 +120,7 @@
120
120
  </template>
121
121
 
122
122
  <div v-if="!loading && !initError" class="card-list">
123
- <div v-for="provider in displayProvidersList" :key="provider.name" :class="['card', { active: displayCurrentProvider === provider.name }]" @click="switchProvider(provider.name)" @keydown.enter.self.prevent="switchProvider(provider.name)" @keydown.space.self.prevent="switchProvider(provider.name)" tabindex="0" role="button" :aria-current="displayCurrentProvider === provider.name ? 'true' : null">
123
+ <div v-for="provider in displayProvidersList" :key="provider.name" :class="['card', { active: displayCurrentProvider === provider.name, disabled: provider.name === 'local' && isLocalProviderDisabled }]" @click="(provider.name === 'local' && isLocalProviderDisabled) ? null : switchProvider(provider.name)" @keydown.enter.self.prevent="(provider.name === 'local' && isLocalProviderDisabled) ? null : switchProvider(provider.name)" @keydown.space.self.prevent="(provider.name === 'local' && isLocalProviderDisabled) ? null : switchProvider(provider.name)" :tabindex="provider.name === 'local' && isLocalProviderDisabled ? -1 : 0" role="button" :aria-current="displayCurrentProvider === provider.name ? 'true' : null">
124
124
  <div class="card-leading">
125
125
  <div class="card-icon">{{ provider.name.charAt(0).toUpperCase() }}<span v-if="isTransformProvider(provider)" class="card-icon-dot" title="通过内建转换适配"></span></div>
126
126
  <div class="card-content">