refactor(settings): enhance card interactions and accessibility across components

Updated the McpSettings, ModelSettings, VectorStoreSettings, and WebSearchSettings components to improve user interactions with service, model, store, and provider cards. Implemented click and keyboard event handling for better accessibility, allowing cards to be clickable based on user roles. Enhanced UI elements with appropriate roles and tabindex attributes, ensuring a consistent and user-friendly experience. Adjusted styles to reflect clickable states and improved focus visibility for better usability.
This commit is contained in:
wizardchen
2026-06-01 14:29:20 +08:00
committed by lyingbug
parent a4557c3a80
commit 65f9018f73
4 changed files with 314 additions and 321 deletions

View File

@@ -42,8 +42,15 @@
class="service-card"
:class="[
`service-card--${service.transport_type || 'unknown'}`,
{ 'service-card--builtin': service.is_builtin }
{
'service-card--builtin': service.is_builtin,
'service-card--clickable': isServiceCardClickable(),
},
]"
:role="isServiceCardClickable() ? 'button' : undefined"
:tabindex="isServiceCardClickable() ? 0 : undefined"
@click="onServiceCardClick($event, service)"
@keydown.enter="onServiceCardClick($event, service)"
>
<div class="service-card__badge" :aria-label="getTransportTypeLabel(service.transport_type)">
<t-icon :name="getTransportTypeIcon(service.transport_type)" size="18px" />
@@ -66,17 +73,23 @@
<span class="service-card__status-dot" />
{{ service.enabled ? $t('common.on') : $t('common.off') }}
</span>
<t-dropdown
:options="service.is_builtin ? getBuiltinServiceOptions() : getServiceOptions(service)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(data: any) => handleMenuAction({ value: data.value }, service)"
<div
v-if="(service.is_builtin ? getBuiltinServiceOptions() : getServiceOptions(service)).length > 0"
class="service-card__actions"
@click.stop
>
<t-button variant="text" shape="square" size="small" class="service-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
<t-dropdown
:options="service.is_builtin ? getBuiltinServiceOptions() : getServiceOptions(service)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(data: any) => handleMenuAction({ value: data.value }, service)"
>
<t-button variant="text" shape="square" size="small" class="service-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
</div>
</div>
<div class="service-card__subtitle">
<span class="service-card__type">{{ getTransportTypeLabel(service.transport_type) }}</span>
@@ -162,6 +175,20 @@ const handleAdd = () => {
dialogVisible.value = true
}
const isServiceCardClickable = () => authStore.hasRole('admin')
const onServiceCardClick = (event: Event, service: MCPService) => {
if (!isServiceCardClickable()) return
if (event.type === 'keydown') {
const ke = event as KeyboardEvent
if (ke.key !== 'Enter' && ke.key !== ' ') return
ke.preventDefault()
}
const target = event.target as HTMLElement | null
if (target?.closest('.service-card__actions')) return
handleEdit(service)
}
// Handle edit button click
const handleEdit = (service: MCPService) => {
currentService.value = { ...service }
@@ -384,19 +411,32 @@ onMounted(() => {
transition: border-color 0.18s ease, box-shadow 0.18s ease;
min-width: 0;
&:hover {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&--builtin {
background: var(--td-bg-color-secondarycontainer);
}
&--clickable {
cursor: pointer;
&:hover {
box-shadow: none;
border-color: var(--td-component-stroke);
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
}
&--builtin:not(.service-card--clickable):hover {
box-shadow: none;
border-color: var(--td-component-stroke);
}
}
.service-card__actions {
flex-shrink: 0;
}
.service-card__badge {
@@ -521,7 +561,8 @@ onMounted(() => {
// switch 始终显示(它是状态锚点);三点按钮只在 hover/focus 时出现。
.service-card:hover .service-card__more,
.service-card:focus-within .service-card__more {
.service-card:focus-within .service-card__more,
.service-card__actions:focus-within .service-card__more {
opacity: 1;
}

View File

@@ -40,120 +40,88 @@
<t-tab-panel value="asr" :label="`${$t('modelSettings.typeShort.asr')}(${countByType('asr')})`" />
</t-tabs>
<div v-if="filteredModels.length > 0" class="model-grid">
<!--
Model card. 我们刻意不复用 SettingCard模型卡需要左侧类型徽章 +
级元信息chip + monospace 原名 + baseUrlSettingCard 还在
Mcp / WebSearch 页用 prefix 槽属于过度抽象
-->
<div
v-for="model in filteredModels"
:key="`${model._modelType}-${model.id}`"
class="model-card"
:class="[`model-card--${model._modelType}`, { 'model-card--builtin': model.isBuiltin }]"
>
<div class="model-card__badge" :aria-label="typeLabel(model._modelType)">
<t-icon :name="typeIcon(model._modelType)" size="18px" />
</div>
<div class="model-card__body">
<!--
Title row. Display name primary; on hover the lock (builtin) and
ellipsis menu fade in from the right. The lock badge is muted by
default since most cards in a typical install ARE built-in
making it loud everywhere just produces visual noise. User-added
cards stand out by NOT having a lock.
-->
<div class="model-card__header">
<h3 class="model-card__title" :title="modelDisplayName(model)">{{ modelDisplayName(model) }}</h3>
<span
v-if="model.isBuiltin"
class="model-card__lock"
:title="$t('modelSettings.builtinTag')"
:aria-label="$t('modelSettings.builtinTag')"
>
<t-icon name="lock-on" />
</span>
<t-dropdown
v-if="getModelOptions(model._modelType, model).length > 0"
:options="getModelOptions(model._modelType, model)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(data: any) => handleMenuAction({ value: data.value }, model._modelType, model)"
>
<t-button variant="text" shape="square" size="small" class="model-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
<t-loading :loading="loading" size="small" class="model-list-loading">
<div v-if="filteredModels.length > 0" class="model-grid">
<div
v-for="model in filteredModels"
:key="`${model._modelType}-${model.id}`"
class="model-card"
:class="[
`model-card--${model._modelType}`,
{
'model-card--builtin': model.isBuiltin,
'model-card--clickable': isModelCardClickable(model),
},
]"
:role="isModelCardClickable(model) ? 'button' : undefined"
:tabindex="isModelCardClickable(model) ? 0 : undefined"
@click="onModelCardClick($event, model._modelType, model)"
@keydown.enter="onModelCardClick($event, model._modelType, model)"
>
<div class="model-card__badge" :aria-label="typeLabel(model._modelType)">
<t-icon :name="typeIcon(model._modelType)" size="18px" />
</div>
<!--
Compact identity row. Only rendered when there's actually
something to show — most built-in models have no displayName
AND no baseUrl, so an "always render" approach left them with
either a blank line or a noisy pseudo-URL. CSS grid's default
row stretching keeps cards in the same row aligned, so we
don't need to fake content for visual symmetry.
When both raw name and URL are present we show ONLY the URL
here, because cramming both into one ellipsizing line — as
seen in the screenshot — produced "deepseek-… · https://…tencen…"
with both ends truncated and neither readable. The raw name
is already accessible via the title attribute on the card.
-->
<div
v-if="identityVisible(model)"
class="model-card__identity"
:title="identityTooltip(model)"
>
<!-- Show URL by preference (it's the more diagnostic of the
two for "is this model wired up correctly"); fall back to
the raw name when there's no URL (display-name-only case). -->
<span class="model-card__identity-text">{{ identityText(model) }}</span>
</div>
<!--
Meta chips, single row. We deliberately keep this line to a
FIXED set of facts so every card renders to the same height,
no matter what optional fields are filled out. Order: type →
vendor → optional dim → optional vision flag. Type chip is
text-only (the 36×36 badge on the left already shows the icon).
-->
<div class="model-card__meta">
<span class="model-card__chip model-card__chip--type">
{{ typeLabel(model._modelType) }}
</span>
<span class="model-card__chip">
{{ vendorLabel(model) }}
</span>
<span v-if="model._modelType === 'embedding' && model.dimension" class="model-card__chip">
{{ model.dimension }} dim
</span>
<span v-if="model._modelType === 'chat' && model.supportsVision"
class="model-card__chip model-card__chip--icon-only"
:title="$t('model.editor.supportsVisionLabel')"
:aria-label="$t('model.editor.supportsVisionLabel')">
<t-icon name="image" />
</span>
<div class="model-card__body">
<div class="model-card__header">
<h3 class="model-card__title">{{ modelDisplayName(model) }}</h3>
<span
v-if="model.isBuiltin"
class="model-card__lock"
:title="$t('modelSettings.builtinTag')"
:aria-label="$t('modelSettings.builtinTag')"
>
<t-icon name="lock-on" />
</span>
<div v-if="getModelOptions(model._modelType, model).length > 0" class="model-card__actions" @click.stop>
<t-dropdown
:options="getModelOptions(model._modelType, model)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(data: any) => handleMenuAction({ value: data.value }, model._modelType, model)"
>
<t-button variant="text" shape="square" size="small" class="model-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
</div>
</div>
<p class="model-card__subtitle">
<span>{{ vendorLabel(model) }}</span>
<template v-if="model._modelType === 'embedding' && model.dimension">
<span class="model-card__sep">·</span>
<span>{{ $t('model.editor.dimensionLabel') }} {{ model.dimension }}</span>
</template>
<template v-if="model._modelType === 'chat' && model.supportsVision">
<span class="model-card__sep">·</span>
<span
class="model-card__vision"
:title="$t('model.editor.supportsVisionLabel')"
:aria-label="$t('model.editor.supportsVisionLabel')"
>
<t-icon name="image" size="12px" />
</span>
</template>
</p>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<t-empty :description="emptyHint">
<t-dropdown
v-if="authStore.hasRole('admin')"
:options="addModelOptions"
placement="bottom"
@click="(data: any) => openAddDialog(data.value)"
>
<t-button theme="primary" variant="outline" size="small">
<template #icon><add-icon /></template>
{{ $t('modelSettings.actions.addModel') }}
</t-button>
</t-dropdown>
</t-empty>
</div>
<div v-else-if="!loading" class="empty-state">
<t-empty :description="emptyHint">
<t-dropdown
v-if="authStore.hasRole('admin')"
:options="addModelOptions"
placement="bottom"
@click="(data: any) => openAddDialog(data.value)"
>
<t-button theme="primary" variant="outline" size="small">
<template #icon><add-icon /></template>
{{ $t('modelSettings.actions.addModel') }}
</t-button>
</t-dropdown>
</t-empty>
</div>
</t-loading>
<!-- 模型编辑器抽屉 -->
<ModelEditorDialog v-model:visible="showDialog" :model-type="currentModelType" :model-data="editingModel"
@@ -304,58 +272,6 @@ const vendorLabel = (model: any): string => {
return providerLabel(model) || sourceLabel(model._modelType)
}
// Hover tooltip for the whole card — shows the long-form details we
// removed from the visible card body so they're still one mouseover
// away. baseUrl is the most useful for debugging "why is this model
// failing" scenarios.
const cardTooltip = (model: any): string => {
const lines: string[] = []
if (model.displayName && model.displayName !== model.name) {
lines.push(`${t('modelSettings.rawModelName')}: ${model.name}`)
}
if (model.baseUrl) {
lines.push(model.baseUrl)
} else if (model.source === 'local') {
lines.push('Ollama (localhost)')
}
return lines.join('\n')
}
// Whether the raw model identifier is worth showing on the card. We hide
// it when the user did NOT set a display name, because then the title is
// already the raw name and printing it again is just noise.
const rawNameVisible = (model: any): boolean => {
const displayName = typeof model.displayName === 'string' ? model.displayName.trim() : ''
return Boolean(displayName) && displayName !== model.name
}
// What goes in the URL slot of the identity row. Local models intentionally
// return '' here — "ollama://localhost" is just noise on Ollama-only built-in
// cards. Only remote models with an explicit base URL get this row.
const urlText = (model: any): string => {
return model.baseUrl || ''
}
// Single-line text shown in the identity row. Picks the more useful of
// the two (URL > raw name) — never crams both into one ellipsizing line
// because that produces double-end truncation that nobody can read.
const identityText = (model: any): string => {
return urlText(model) || (rawNameVisible(model) ? model.name : '')
}
// Whether the identity row should render at all.
const identityVisible = (model: any): boolean => identityText(model).length > 0
// Identity-row tooltip — exposes BOTH name and URL when the user hovers,
// so the diagnostic info we hid from the visible row is still one mouse
// move away.
const identityTooltip = (model: any): string => {
const parts: string[] = []
if (rawNameVisible(model)) parts.push(model.name)
if (urlText(model)) parts.push(urlText(model))
return parts.join('\n')
}
const modelDisplayName = (model: any) => {
const displayName = typeof model.displayName === 'string' ? model.displayName.trim() : ''
return displayName || model.name
@@ -394,12 +310,31 @@ const openAddDialog = (type: ModelType) => {
showDialog.value = true
}
// 可点击打开编辑抽屉:管理员 + 非内置模型
const isModelCardClickable = (model: any) =>
authStore.hasRole('admin') && !model.isBuiltin
const onModelCardClick = (event: Event, type: ModelType, model: any) => {
if (!isModelCardClickable(model)) return
if (event.type === 'keydown') {
const ke = event as KeyboardEvent
if (ke.key !== 'Enter' && ke.key !== ' ') return
ke.preventDefault()
}
const target = event.target as HTMLElement | null
if (target?.closest('.model-card__actions')) return
editModel(type, model)
}
// 编辑模型
const editModel = (type: ModelType, model: any) => {
if (model.isBuiltin) {
MessagePlugin.warning(t('modelSettings.toasts.builtinCannotEdit'))
return
}
if (!authStore.hasRole('admin')) {
return
}
currentModelType.value = type
editingModel.value = { ...model }
showDialog.value = true
@@ -700,6 +635,10 @@ onMounted(() => {
}
}
.model-list-loading {
min-height: 120px;
}
.model-type-tabs {
margin-bottom: 16px;
@@ -736,13 +675,13 @@ onMounted(() => {
gap: 12px;
}
// 模型卡片 —— 左侧类型徽章 + 标题 / identity / 元 chip 行(固定三行)
// 模型卡片 —— 可选类型徽章仅「全部」Tab+ 标题 + 一行副标题
.model-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
padding: 14px 16px;
border: 1px solid var(--td-component-stroke);
border-radius: 10px;
background: var(--td-bg-color-container);
@@ -762,6 +701,20 @@ onMounted(() => {
border-color: var(--td-component-stroke);
}
}
&--clickable {
cursor: pointer;
&:hover {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
}
}
.model-card__badge {
@@ -810,7 +763,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
gap: 2px;
}
.model-card__header {
@@ -833,32 +786,6 @@ onMounted(() => {
white-space: nowrap;
}
/*
Generic chip used for: built-in tag, type chip, source chip, dimension,
vision flag. Same shape across all so the row reads as one consistent
rhythm of pills. Variants tweak color only.
*/
.model-card__chip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 7px 1px 6px;
height: 20px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
color: var(--td-text-color-secondary);
background: var(--td-bg-color-component);
border-radius: 4px;
white-space: nowrap;
.t-icon {
font-size: 12px;
flex-shrink: 0;
}
}
/*
Built-in lock indicator. Most cards in a typical install ARE built-in,
so loud styling everywhere becomes noise — instead the lock is muted
@@ -887,51 +814,29 @@ onMounted(() => {
color: var(--td-text-color-secondary);
}
/*
Icon-only chip variant. Drops horizontal padding to a tight square so the
chip reads as a status badge (vision flag) rather than a text pill that
happens to start with an icon.
*/
.model-card__chip--icon-only {
padding: 0;
width: 20px;
justify-content: center;
.t-icon {
font-size: 12px;
}
.model-card__subtitle {
margin: 2px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Type chip in the meta row — slightly emphasized, picks up the type's
accent color so it links to the left badge. */
.model-card__chip--type {
color: var(--td-text-color-primary);
font-weight: 500;
.model-card__sep {
margin: 0 4px;
color: var(--td-text-color-placeholder);
}
.model-card--chat .model-card__chip--type {
color: #0052D9;
background: rgba(0, 82, 217, 0.08);
.model-card__vision {
display: inline-flex;
align-items: center;
gap: 3px;
}
.model-card--embedding .model-card__chip--type {
color: #6235BB;
background: rgba(98, 53, 187, 0.08);
}
.model-card--rerank .model-card__chip--type {
color: #B85C00;
background: rgba(184, 92, 0, 0.08);
}
.model-card--vllm .model-card__chip--type {
color: #C93E3E;
background: rgba(201, 62, 62, 0.08);
}
.model-card--asr .model-card__chip--type {
color: #118053;
background: rgba(17, 128, 83, 0.08);
.model-card__actions {
flex-shrink: 0;
}
.model-card__more {
@@ -948,48 +853,13 @@ onMounted(() => {
}
}
// Hover / 键盘焦点 / 菜单已展开 时显示,避免静态卡片上有"杂物"。
// Hover / 键盘焦点 时显示更多菜单,避免静态卡片上有"杂物"。
.model-card:hover .model-card__more,
.model-card:focus-within .model-card__more {
.model-card:focus-within .model-card__more,
.model-card__actions:focus-within .model-card__more {
opacity: 1;
}
.model-card__meta {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
min-width: 0;
// Truncate at the row level rather than within each chip — chips clip
// off-screen if the card narrows below the chip set's natural width
// (rare at 320px+ minmax but possible if grid recomputes).
overflow: hidden;
}
/*
Compact identity row: monospace one-liner showing whichever of
baseUrl / raw name is more useful (URL preferred). Conditionally
rendered — empty cards (most built-in ones) skip this row entirely
and grid auto-sizing handles the height.
*/
.model-card__identity {
display: flex;
align-items: center;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.4;
color: var(--td-text-color-placeholder);
}
.model-card__identity-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-state {
padding: 64px 0;
text-align: center;

View File

@@ -30,8 +30,15 @@
class="store-card"
:class="[
`store-card--${store.engine_type}`,
{ 'store-card--env': store.source === 'env' }
{
'store-card--env': store.source === 'env',
'store-card--clickable': isStoreCardClickable(store),
},
]"
:role="isStoreCardClickable(store) ? 'button' : undefined"
:tabindex="isStoreCardClickable(store) ? 0 : undefined"
@click="onStoreCardClick($event, store)"
@keydown.enter="onStoreCardClick($event, store)"
>
<div class="store-card__main">
<div
@@ -58,18 +65,23 @@
测试连接已挪到编辑抽屉的 footer外层菜单不再有"测试"入口
env 来源.env 写入也不需要 dropdown 没有可执行的动作
-->
<t-dropdown
<div
v-if="authStore.hasRole('admin') && storeActionsFor(store).length > 0"
:options="storeActionsFor(store)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(action: any) => handleAction(action, store)"
class="store-card__actions"
@click.stop
>
<t-button variant="text" shape="square" size="small" class="store-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
<t-dropdown
:options="storeActionsFor(store)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(action: any) => handleAction(action, store)"
>
<t-button variant="text" shape="square" size="small" class="store-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
</div>
</div>
<div class="store-card__subtitle">
<span class="store-card__type">{{ store.engine_type }}</span>
@@ -600,7 +612,26 @@ const openAddDialog = () => {
showDialog.value = true
}
// env 来源由 .env 注入,与列表菜单一致:不可点击编辑
const isStoreCardClickable = (store: VectorStoreEntity) =>
authStore.hasRole('admin') && store.source !== 'env'
const onStoreCardClick = (event: Event, store: VectorStoreEntity) => {
if (!isStoreCardClickable(store)) return
if (event.type === 'keydown') {
const ke = event as KeyboardEvent
if (ke.key !== 'Enter' && ke.key !== ' ') return
ke.preventDefault()
}
const target = event.target as HTMLElement | null
if (target?.closest('.store-card__actions')) return
editStore(store)
}
const editStore = (store: VectorStoreEntity) => {
if (store.source === 'env') {
return
}
editingStore.value = store
showAdvanced.value = false
form.value = {
@@ -786,19 +817,32 @@ onMounted(async () => {
transition: border-color 0.18s ease, box-shadow 0.18s ease;
min-width: 0;
&:hover {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&--env {
background: var(--td-bg-color-secondarycontainer);
}
&--clickable {
cursor: pointer;
&:hover {
border-color: var(--td-component-stroke);
box-shadow: none;
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
}
&--env:not(.store-card--clickable):hover {
border-color: var(--td-component-stroke);
box-shadow: none;
}
}
.store-card__actions {
flex-shrink: 0;
}
.store-card__main {
@@ -952,7 +996,8 @@ onMounted(async () => {
}
.store-card:hover .store-card__more,
.store-card:focus-within .store-card__more {
.store-card:focus-within .store-card__more,
.store-card__actions:focus-within .store-card__more {
opacity: 1;
}

View File

@@ -21,7 +21,11 @@
v-for="entity in providerEntities"
:key="entity.id"
class="provider-card"
:class="`provider-card--${entity.provider}`"
:class="[`provider-card--${entity.provider}`, { 'provider-card--clickable': isProviderCardClickable() }]"
:role="isProviderCardClickable() ? 'button' : undefined"
:tabindex="isProviderCardClickable() ? 0 : undefined"
@click="onProviderCardClick($event, entity)"
@keydown.enter="onProviderCardClick($event, entity)"
>
<div
class="provider-card__badge"
@@ -42,18 +46,23 @@
<div class="provider-card__body">
<div class="provider-card__header">
<h3 class="provider-card__title" :title="entity.name">{{ entity.name }}</h3>
<t-dropdown
<div
v-if="getProviderOptions(entity).length > 0"
:options="getProviderOptions(entity)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(data: any) => handleMenuAction({ value: data.value }, entity)"
class="provider-card__actions"
@click.stop
>
<t-button variant="text" shape="square" size="small" class="provider-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
<t-dropdown
:options="getProviderOptions(entity)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(data: any) => handleMenuAction({ value: data.value }, entity)"
>
<t-button variant="text" shape="square" size="small" class="provider-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
</div>
</div>
<div class="provider-card__subtitle">
<span class="provider-card__type">{{ providerTypeLabel(entity.provider) }}</span>
@@ -598,6 +607,20 @@ const testConnection = async () => {
}
}
const isProviderCardClickable = () => authStore.hasRole('admin')
const onProviderCardClick = (event: Event, entity: WebSearchProviderEntity) => {
if (!isProviderCardClickable()) return
if (event.type === 'keydown') {
const ke = event as KeyboardEvent
if (ke.key !== 'Enter' && ke.key !== ' ') return
ke.preventDefault()
}
const target = event.target as HTMLElement | null
if (target?.closest('.provider-card__actions')) return
editProvider(entity)
}
const getProviderOptions = (_entity: WebSearchProviderEntity) => {
// Web search providers carry external API credentials; the backend
// gates every mutation/test behind Admin+ (RegisterWebSearchProviderRoutes).
@@ -687,12 +710,25 @@ onMounted(async () => {
transition: border-color 0.18s ease, box-shadow 0.18s ease;
min-width: 0;
&:hover {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
&--clickable {
cursor: pointer;
&:hover {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
}
}
.provider-card__actions {
flex-shrink: 0;
}
.provider-card__badge {
flex-shrink: 0;
width: 36px;
@@ -814,7 +850,8 @@ onMounted(async () => {
}
.provider-card:hover .provider-card__more,
.provider-card:focus-within .provider-card__more {
.provider-card:focus-within .provider-card__more,
.provider-card__actions:focus-within .provider-card__more {
opacity: 1;
}