Commit 2ebbd4c9 authored by bayma888's avatar bayma888
Browse files

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

- 分组下拉添加搜索框,支持按名称/描述快速筛选
- 新建/编辑密钥弹窗的分组选择也支持搜索
- 智能弹出方向:底部空间不足时自动向上弹出
- 倍率独立为平台配色的圆角标签,更醒目
- 分组名称加粗,名称与描述之间增加间距
- 分组选项之间添加分隔线,视觉更清晰
- 切换图标旁增加"选择分组"文字提示
- 下拉宽度自适应内容长度
- i18n: 新增 searchGroup、noGroupFound 词条 (en/zh)
parent 785115c6
<template> <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 <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" :title="description || undefined"
> >
<!-- Row 1: platform badge (name bold) -->
<GroupBadge <GroupBadge
:name="name" :name="name"
:platform="platform" :platform="platform"
:subscription-type="subscriptionType" :subscription-type="subscriptionType"
:rate-multiplier="rateMultiplier" :show-rate="false"
:user-rate-multiplier="userRateMultiplier" class="groupOptionItemBadge"
/> />
<!-- Row 2: description with top spacing -->
<span <span
v-if="description" 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 }} {{ description }}
</span> </span>
</div> </div>
<svg
v-if="showCheckmark && selected" <!-- Right: rate pill + checkmark (vertically centered to first row) -->
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400" <div class="flex shrink-0 items-center gap-2 pt-0.5">
fill="none" <!-- Rate pill (platform color) -->
stroke="currentColor" <span v-if="rateMultiplier !== undefined" :class="['inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold', ratePillClass]">
viewBox="0 0 24 24" <template v-if="hasCustomRate">
stroke-width="2" <span class="mr-1 line-through opacity-50">{{ rateMultiplier }}x</span>
> <span class="font-bold">{{ userRateMultiplier }}x</span>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> </template>
</svg> <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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import GroupBadge from './GroupBadge.vue' import GroupBadge from './GroupBadge.vue'
import type { SubscriptionType, GroupPlatform } from '@/types' import type { SubscriptionType, GroupPlatform } from '@/types'
...@@ -46,10 +65,43 @@ interface Props { ...@@ -46,10 +65,43 @@ interface Props {
showCheckmark?: boolean showCheckmark?: boolean
} }
withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
subscriptionType: 'standard', subscriptionType: 'standard',
selected: false, selected: false,
showCheckmark: true, showCheckmark: true,
userRateMultiplier: null 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> </script>
<style scoped>
/* Bold the group name inside GroupBadge when used in dropdown option */
.groupOptionItemBadge :deep(span.truncate) {
font-weight: 600;
}
</style>
...@@ -224,7 +224,13 @@ const filteredOptions = computed(() => { ...@@ -224,7 +224,13 @@ const filteredOptions = computed(() => {
let opts = props.options as any[] let opts = props.options as any[]
if (props.searchable && searchQuery.value) { if (props.searchable && searchQuery.value) {
const query = searchQuery.value.toLowerCase() 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 return opts
}) })
...@@ -434,7 +440,7 @@ onUnmounted(() => { ...@@ -434,7 +440,7 @@ onUnmounted(() => {
<style> <style>
.select-dropdown-portal { .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 bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
......
...@@ -536,6 +536,8 @@ export default { ...@@ -536,6 +536,8 @@ export default {
apiKey: 'API Key', apiKey: 'API Key',
group: 'Group', group: 'Group',
noGroup: 'No group', noGroup: 'No group',
searchGroup: 'Search groups...',
noGroupFound: 'No groups found',
created: 'Created', created: 'Created',
copyToClipboard: 'Copy to clipboard', copyToClipboard: 'Copy to clipboard',
copied: 'Copied!', copied: 'Copied!',
......
...@@ -536,6 +536,8 @@ export default { ...@@ -536,6 +536,8 @@ export default {
apiKey: 'API 密钥', apiKey: 'API 密钥',
group: '分组', group: '分组',
noGroup: '无分组', noGroup: '无分组',
searchGroup: '搜索分组...',
noGroupFound: '未找到匹配的分组',
created: '创建时间', created: '创建时间',
copyToClipboard: '复制到剪贴板', copyToClipboard: '复制到剪贴板',
copied: '已复制!', copied: '已复制!',
......
...@@ -101,6 +101,7 @@ ...@@ -101,6 +101,7 @@
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ <span v-else class="text-sm text-gray-400 dark:text-dark-500">{{
t('keys.noGroup') t('keys.noGroup')
}}</span> }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('keys.selectGroup') }}</span>
<svg <svg
class="h-3.5 w-3.5 text-gray-400 opacity-60 transition-opacity group-hover/dropdown:opacity-100" class="h-3.5 w-3.5 text-gray-400 opacity-60 transition-opacity group-hover/dropdown:opacity-100"
fill="none" fill="none"
...@@ -385,6 +386,8 @@ ...@@ -385,6 +386,8 @@
v-model="formData.group_id" v-model="formData.group_id"
:options="groupOptions" :options="groupOptions"
:placeholder="t('keys.selectGroup')" :placeholder="t('keys.selectGroup')"
:searchable="true"
:search-placeholder="t('keys.searchGroup')"
data-tour="key-form-group" data-tour="key-form-group"
> >
<template #selected="{ option }"> <template #selected="{ option }">
...@@ -955,17 +958,38 @@ ...@@ -955,17 +958,38 @@
<div <div
v-if="groupSelectorKeyId !== null && dropdownPosition" v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef" ref="dropdownRef"
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-auto min-w-[280px] max-w-[480px] overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10" class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-max min-w-[380px] overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
style="pointer-events: auto !important;" style="pointer-events: auto !important;"
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }" :style="{
top: dropdownPosition.top !== undefined ? dropdownPosition.top + 'px' : undefined,
bottom: dropdownPosition.bottom !== undefined ? dropdownPosition.bottom + 'px' : undefined,
left: dropdownPosition.left + 'px'
}"
> >
<div class="max-h-64 overflow-y-auto p-1.5"> <!-- Search box -->
<div class="border-b border-gray-100 p-2 dark:border-dark-700">
<div class="relative">
<svg class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="groupSearchQuery"
type="text"
class="w-full rounded-lg border border-gray-200 bg-gray-50 py-1.5 pl-8 pr-3 text-sm text-gray-900 placeholder-gray-400 outline-none focus:border-primary-300 focus:ring-1 focus:ring-primary-300 dark:border-dark-600 dark:bg-dark-700 dark:text-white dark:placeholder-gray-500 dark:focus:border-primary-600 dark:focus:ring-primary-600"
:placeholder="t('keys.searchGroup')"
@click.stop
/>
</div>
</div>
<!-- Group list -->
<div class="max-h-80 overflow-y-auto p-1.5">
<button <button
v-for="option in groupOptions" v-for="option in filteredGroupOptions"
:key="option.value ?? 'null'" :key="option.value ?? 'null'"
@click="changeGroup(selectedKeyForGroup!, option.value)" @click="changeGroup(selectedKeyForGroup!, option.value)"
:class="[ :class="[
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors', 'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors',
'border-b border-gray-100 last:border-0 dark:border-dark-700',
selectedKeyForGroup?.group_id === option.value || selectedKeyForGroup?.group_id === option.value ||
(!selectedKeyForGroup?.group_id && option.value === null) (!selectedKeyForGroup?.group_id && option.value === null)
? 'bg-primary-50 dark:bg-primary-900/20' ? 'bg-primary-50 dark:bg-primary-900/20'
...@@ -986,6 +1010,10 @@ ...@@ -986,6 +1010,10 @@
" "
/> />
</button> </button>
<!-- Empty state when search has no results -->
<div v-if="filteredGroupOptions.length === 0" class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">
{{ t('keys.noGroupFound') }}
</div>
</div> </div>
</div> </div>
</Teleport> </Teleport>
...@@ -1085,7 +1113,7 @@ const copiedKeyId = ref<number | null>(null) ...@@ -1085,7 +1113,7 @@ const copiedKeyId = ref<number | null>(null)
const groupSelectorKeyId = ref<number | null>(null) const groupSelectorKeyId = ref<number | null>(null)
const publicSettings = ref<PublicSettings | null>(null) const publicSettings = ref<PublicSettings | null>(null)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null) const dropdownPosition = ref<{ top?: number; bottom?: number; left: number } | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map()) const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
let abortController: AbortController | null = null let abortController: AbortController | null = null
...@@ -1189,6 +1217,17 @@ const groupOptions = computed(() => ...@@ -1189,6 +1217,17 @@ const groupOptions = computed(() =>
})) }))
) )
// Group dropdown search
const groupSearchQuery = ref('')
const filteredGroupOptions = computed(() => {
const query = groupSearchQuery.value.trim().toLowerCase()
if (!query) return groupOptions.value
return groupOptions.value.filter((opt) => {
return opt.label.toLowerCase().includes(query) ||
(opt.description && opt.description.toLowerCase().includes(query))
})
})
const maskKey = (key: string): string => { const maskKey = (key: string): string => {
if (key.length <= 12) return key if (key.length <= 12) return key
return `${key.slice(0, 8)}...${key.slice(-4)}` return `${key.slice(0, 8)}...${key.slice(-4)}`
...@@ -1348,12 +1387,26 @@ const openGroupSelector = (key: ApiKey) => { ...@@ -1348,12 +1387,26 @@ const openGroupSelector = (key: ApiKey) => {
const buttonEl = groupButtonRefs.value.get(key.id) const buttonEl = groupButtonRefs.value.get(key.id)
if (buttonEl) { if (buttonEl) {
const rect = buttonEl.getBoundingClientRect() const rect = buttonEl.getBoundingClientRect()
dropdownPosition.value = { const dropdownEstHeight = 400 // estimated max dropdown height
top: rect.bottom + 4, const spaceBelow = window.innerHeight - rect.bottom
left: rect.left const spaceAbove = rect.top
if (spaceBelow < dropdownEstHeight && spaceAbove > spaceBelow) {
// Not enough space below, pop upward
dropdownPosition.value = {
bottom: window.innerHeight - rect.top + 4,
left: rect.left
}
} else {
// Default: pop downward
dropdownPosition.value = {
top: rect.bottom + 4,
left: rect.left
}
} }
} }
groupSelectorKeyId.value = key.id groupSelectorKeyId.value = key.id
groupSearchQuery.value = ''
} }
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment