Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
1624523c
Unverified
Commit
1624523c
authored
Mar 08, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 08, 2026
Browse files
Merge pull request #860 from bayma888/feature/group-display-fix
feat(ui): 优化分组选择器、交互体验和样式遮挡体验问题
parents
313afe14
2ebbd4c9
Changes
5
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/common/GroupOptionItem.vue
View file @
1624523c
<
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
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
}}
</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
>
frontend/src/components/common/Select.vue
View file @
1624523c
...
...
@@ -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-[
160px]
max-w-[3
20px];
@apply
w-max
min-w-[2
0
0px];
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
...
...
frontend/src/i18n/locales/en.ts
View file @
1624523c
...
...
@@ -536,6 +536,8 @@ export default {
apiKey
:
'
API Key
'
,
group
:
'
Group
'
,
noGroup
:
'
No group
'
,
searchGroup
:
'
Search groups...
'
,
noGroupFound
:
'
No groups found
'
,
created
:
'
Created
'
,
copyToClipboard
:
'
Copy to clipboard
'
,
copied
:
'
Copied!
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
1624523c
...
...
@@ -536,6 +536,8 @@ export default {
apiKey
:
'
API 密钥
'
,
group
:
'
分组
'
,
noGroup
:
'
无分组
'
,
searchGroup
:
'
搜索分组...
'
,
noGroupFound
:
'
未找到匹配的分组
'
,
created
:
'
创建时间
'
,
copyToClipboard
:
'
复制到剪贴板
'
,
copied
:
'
已复制!
'
,
...
...
frontend/src/views/user/KeysView.vue
View file @
1624523c
...
...
@@ -101,8 +101,9 @@
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
keys.noGroup
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
keys.selectGroup
'
)
}}
</span>
<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-
6
0 transition-opacity group-hover/dropdown:opacity-100"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -385,6 +386,8 @@
v-model=
"formData.group_id"
:options=
"groupOptions"
:placeholder=
"t('keys.selectGroup')"
:searchable=
"true"
:search-placeholder=
"t('keys.searchGroup')"
data-tour=
"key-form-group"
>
<
template
#selected=
"{ option }"
>
...
...
@@ -955,17 +958,38 @@
<div
v-if=
"groupSelectorKeyId !== null && dropdownPosition"
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=
"{ 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
v-for=
"option in
g
roupOptions"
v-for=
"option in
filteredG
roupOptions"
:key=
"option.value ?? 'null'"
@
click=
"changeGroup(selectedKeyForGroup!, option.value)"
: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 === null)
? 'bg-primary-50 dark:bg-primary-900/20'
...
...
@@ -986,6 +1010,10 @@
"
/>
</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>
</Teleport>
...
...
@@ -1085,7 +1113,7 @@ const copiedKeyId = ref<number | null>(null)
const
groupSelectorKeyId
=
ref
<
number
|
null
>
(
null
)
const
publicSettings
=
ref
<
PublicSettings
|
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
())
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
=>
{
if
(
key
.
length
<=
12
)
return
key
return
`
${
key
.
slice
(
0
,
8
)}
...
${
key
.
slice
(
-
4
)}
`
...
...
@@ -1348,12 +1387,26 @@ const openGroupSelector = (key: ApiKey) => {
const
buttonEl
=
groupButtonRefs
.
value
.
get
(
key
.
id
)
if
(
buttonEl
)
{
const
rect
=
buttonEl
.
getBoundingClientRect
()
dropdownPosition
.
value
=
{
top
:
rect
.
bottom
+
4
,
left
:
rect
.
left
const
dropdownEstHeight
=
400
// estimated max dropdown height
const
spaceBelow
=
window
.
innerHeight
-
rect
.
bottom
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
groupSearchQuery
.
value
=
''
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment