feat(ui): 优化分组选择器交互体验

- 分组下拉添加搜索框,支持按名称/描述快速筛选
- 新建/编辑密钥弹窗的分组选择也支持搜索
- 智能弹出方向:底部空间不足时自动向上弹出
- 倍率独立为平台配色的圆角标签,更醒目
- 分组名称加粗,名称与描述之间增加间距
- 分组选项之间添加分隔线,视觉更清晰
- 切换图标旁增加"选择分组"文字提示
- 下拉宽度自适应内容长度
- i18n: 新增 searchGroup、noGroupFound 词条 (en/zh)
This commit is contained in:
bayma888
2026-03-08 18:26:17 +08:00
parent 785115c62b
commit 2ebbd4c94d
5 changed files with 142 additions and 27 deletions

View File

@@ -1,37 +1,56 @@
<template>
<div class="flex min-w-0 flex-1 items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
<!-- Left: name + description -->
<div
class="flex min-w-0 flex-1 flex-col items-start gap-1"
class="flex min-w-0 flex-1 flex-col items-start"
:title="description || undefined"
>
<!-- Row 1: platform badge (name bold) -->
<GroupBadge
:name="name"
:platform="platform"
:subscription-type="subscriptionType"
:rate-multiplier="rateMultiplier"
:user-rate-multiplier="userRateMultiplier"
:show-rate="false"
class="groupOptionItemBadge"
/>
<!-- Row 2: description with top spacing -->
<span
v-if="description"
class="w-full text-left text-xs text-gray-500 dark:text-gray-400 line-clamp-2"
class="mt-1.5 w-full text-left text-xs leading-relaxed text-gray-500 dark:text-gray-400 line-clamp-2"
>
{{ description }}
</span>
</div>
<svg
v-if="showCheckmark && selected"
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<!-- Right: rate pill + checkmark (vertically centered to first row) -->
<div class="flex shrink-0 items-center gap-2 pt-0.5">
<!-- Rate pill (platform color) -->
<span v-if="rateMultiplier !== undefined" :class="['inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold', ratePillClass]">
<template v-if="hasCustomRate">
<span class="mr-1 line-through opacity-50">{{ rateMultiplier }}x</span>
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x 倍率
</template>
</span>
<!-- Checkmark -->
<svg
v-if="showCheckmark && selected"
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import GroupBadge from './GroupBadge.vue'
import type { SubscriptionType, GroupPlatform } from '@/types'
@@ -46,10 +65,43 @@ interface Props {
showCheckmark?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
subscriptionType: 'standard',
selected: false,
showCheckmark: true,
userRateMultiplier: null
})
// Whether user has a custom rate different from default
const hasCustomRate = computed(() => {
return (
props.userRateMultiplier !== null &&
props.userRateMultiplier !== undefined &&
props.rateMultiplier !== undefined &&
props.userRateMultiplier !== props.rateMultiplier
)
})
// Rate pill color matches platform badge color
const ratePillClass = computed(() => {
switch (props.platform) {
case 'anthropic':
return 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400'
case 'openai':
return 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
case 'gemini':
return 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
case 'sora':
return 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
default: // antigravity and others
return 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400'
}
})
</script>
<style scoped>
/* Bold the group name inside GroupBadge when used in dropdown option */
.groupOptionItemBadge :deep(span.truncate) {
font-weight: 600;
}
</style>

View File

@@ -224,7 +224,13 @@ const filteredOptions = computed(() => {
let opts = props.options as any[]
if (props.searchable && searchQuery.value) {
const query = searchQuery.value.toLowerCase()
opts = opts.filter((opt) => getOptionLabel(opt).toLowerCase().includes(query))
opts = opts.filter((opt) => {
// Match label
if (getOptionLabel(opt).toLowerCase().includes(query)) return true
// Also match description if present
if (opt.description && String(opt.description).toLowerCase().includes(query)) return true
return false
})
}
return opts
})
@@ -434,7 +440,7 @@ onUnmounted(() => {
<style>
.select-dropdown-portal {
@apply w-max min-w-[200px] max-w-[480px];
@apply w-max min-w-[200px];
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;