mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-19 06:14:45 +08:00
Merge pull request #860 from bayma888/feature/group-display-fix
feat(ui): 优化分组选择器、交互体验和样式遮挡体验问题
This commit is contained in:
@@ -1,37 +1,56 @@
|
|||||||
<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 truncate text-left text-xs text-gray-500 dark:text-gray-400"
|
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 {
|
|||||||
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(() => {
|
|||||||
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(() => {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.select-dropdown-portal {
|
.select-dropdown-portal {
|
||||||
@apply w-max min-w-[160px] max-w-[320px];
|
@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 {
|
|||||||
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 {
|
|||||||
apiKey: 'API 密钥',
|
apiKey: 'API 密钥',
|
||||||
group: '分组',
|
group: '分组',
|
||||||
noGroup: '无分组',
|
noGroup: '无分组',
|
||||||
|
searchGroup: '搜索分组...',
|
||||||
|
noGroupFound: '未找到匹配的分组',
|
||||||
created: '创建时间',
|
created: '创建时间',
|
||||||
copyToClipboard: '复制到剪贴板',
|
copyToClipboard: '复制到剪贴板',
|
||||||
copied: '已复制!',
|
copied: '已复制!',
|
||||||
|
|||||||
@@ -101,8 +101,9 @@
|
|||||||
<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-0 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"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -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 @@
|
|||||||
<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-64 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 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</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)
|
|||||||
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(() =>
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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) => {
|
|||||||
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 = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user