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
4587c3e5
Unverified
Commit
4587c3e5
authored
Feb 28, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 28, 2026
Browse files
Merge pull request #670 from DaydreamCoding/feat/admin-apikey-group-update
feat(admin): 添加管理员直接修改用户 API Key 分组的功能
parents
be18bc6f
6f9e6903
Changes
26
Show whitespace changes
Inline
Side-by-side
frontend/src/api/admin/apiKeys.ts
0 → 100644
View file @
4587c3e5
/**
* Admin API Keys API endpoints
* Handles API key management for administrators
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
ApiKey
}
from
'
@/types
'
export
interface
UpdateApiKeyGroupResult
{
api_key
:
ApiKey
auto_granted_group_access
:
boolean
granted_group_id
?:
number
granted_group_name
?:
string
}
/**
* Update an API key's group binding
* @param id - API Key ID
* @param groupId - Group ID (0 to unbind, positive to bind, null/undefined to skip)
* @returns Updated API key with auto-grant info
*/
export
async
function
updateApiKeyGroup
(
id
:
number
,
groupId
:
number
|
null
):
Promise
<
UpdateApiKeyGroupResult
>
{
const
{
data
}
=
await
apiClient
.
put
<
UpdateApiKeyGroupResult
>
(
`/admin/api-keys/
${
id
}
`
,
{
group_id
:
groupId
===
null
?
0
:
groupId
})
return
data
}
export
const
apiKeysAPI
=
{
updateApiKeyGroup
}
export
default
apiKeysAPI
frontend/src/api/admin/index.ts
View file @
4587c3e5
...
@@ -21,6 +21,7 @@ import userAttributesAPI from './userAttributes'
...
@@ -21,6 +21,7 @@ import userAttributesAPI from './userAttributes'
import
opsAPI
from
'
./ops
'
import
opsAPI
from
'
./ops
'
import
errorPassthroughAPI
from
'
./errorPassthrough
'
import
errorPassthroughAPI
from
'
./errorPassthrough
'
import
dataManagementAPI
from
'
./dataManagement
'
import
dataManagementAPI
from
'
./dataManagement
'
import
apiKeysAPI
from
'
./apiKeys
'
/**
/**
* Unified admin API object for convenient access
* Unified admin API object for convenient access
...
@@ -43,7 +44,8 @@ export const adminAPI = {
...
@@ -43,7 +44,8 @@ export const adminAPI = {
userAttributes
:
userAttributesAPI
,
userAttributes
:
userAttributesAPI
,
ops
:
opsAPI
,
ops
:
opsAPI
,
errorPassthrough
:
errorPassthroughAPI
,
errorPassthrough
:
errorPassthroughAPI
,
dataManagement
:
dataManagementAPI
dataManagement
:
dataManagementAPI
,
apiKeys
:
apiKeysAPI
}
}
export
{
export
{
...
@@ -64,7 +66,8 @@ export {
...
@@ -64,7 +66,8 @@ export {
userAttributesAPI
,
userAttributesAPI
,
opsAPI
,
opsAPI
,
errorPassthroughAPI
,
errorPassthroughAPI
,
dataManagementAPI
dataManagementAPI
,
apiKeysAPI
}
}
export
default
adminAPI
export
default
adminAPI
...
...
frontend/src/api/admin/users.ts
View file @
4587c3e5
...
@@ -4,7 +4,7 @@
...
@@ -4,7 +4,7 @@
*/
*/
import
{
apiClient
}
from
'
../client
'
import
{
apiClient
}
from
'
../client
'
import
type
{
AdminUser
,
UpdateUserRequest
,
PaginatedResponse
}
from
'
@/types
'
import
type
{
AdminUser
,
UpdateUserRequest
,
PaginatedResponse
,
ApiKey
}
from
'
@/types
'
/**
/**
* List all users with pagination
* List all users with pagination
...
@@ -145,8 +145,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
...
@@ -145,8 +145,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
* @param id - User ID
* @param id - User ID
* @returns List of user's API keys
* @returns List of user's API keys
*/
*/
export
async
function
getUserApiKeys
(
id
:
number
):
Promise
<
PaginatedResponse
<
an
y
>>
{
export
async
function
getUserApiKeys
(
id
:
number
):
Promise
<
PaginatedResponse
<
ApiKe
y
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
an
y
>>
(
`/admin/users/
${
id
}
/api-keys`
)
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
ApiKe
y
>>
(
`/admin/users/
${
id
}
/api-keys`
)
return
data
return
data
}
}
...
...
frontend/src/components/admin/user/UserApiKeysModal.vue
View file @
4587c3e5
<
template
>
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.userApiKeys')"
width=
"wide"
@
close=
"
$emit('c
lose
')
"
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.userApiKeys')"
width=
"wide"
@
close=
"
handleC
lose"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
...
@@ -9,7 +9,7 @@
...
@@ -9,7 +9,7 @@
</div>
</div>
<div
v-if=
"loading"
class=
"flex justify-center py-8"
><svg
class=
"h-8 w-8 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
><circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle><path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg></div>
<div
v-if=
"loading"
class=
"flex justify-center py-8"
><svg
class=
"h-8 w-8 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
><circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle><path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg></div>
<div
v-else-if=
"apiKeys.length === 0"
class=
"py-8 text-center"
><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.noApiKeys
'
)
}}
</p></div>
<div
v-else-if=
"apiKeys.length === 0"
class=
"py-8 text-center"
><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.noApiKeys
'
)
}}
</p></div>
<div
v-else
class=
"max-h-96 space-y-3 overflow-y-auto"
>
<div
v-else
ref=
"scrollContainerRef"
class=
"max-h-96 space-y-3 overflow-y-auto"
@
scroll=
"closeGroupSelector"
>
<div
v-for=
"key in apiKeys"
:key=
"key.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
v-for=
"key in apiKeys"
:key=
"key.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between"
>
<div
class=
"flex items-start justify-between"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"min-w-0 flex-1"
>
...
@@ -18,30 +18,237 @@
...
@@ -18,30 +18,237 @@
</div>
</div>
</div>
</div>
<div
class=
"mt-3 flex flex-wrap gap-4 text-xs text-gray-500"
>
<div
class=
"mt-3 flex flex-wrap gap-4 text-xs text-gray-500"
>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.group
'
)
}}
:
{{
key
.
group
?.
name
||
t
(
'
admin.users.none
'
)
}}
</span></div>
<div
class=
"flex items-center gap-1"
>
<span>
{{
t
(
'
admin.users.group
'
)
}}
:
</span>
<button
:ref=
"(el) => setGroupButtonRef(key.id, el)"
@
click=
"openGroupSelector(key)"
class=
"-mx-1 -my-0.5 flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:disabled=
"updatingKeyIds.has(key.id)"
>
<GroupBadge
v-if=
"key.group_id && key.group"
:name=
"key.group.name"
:platform=
"key.group.platform"
:subscription-type=
"key.group.subscription_type"
:rate-multiplier=
"key.group.rate_multiplier"
/>
<span
v-else
class=
"text-gray-400 italic"
>
{{
t
(
'
admin.users.none
'
)
}}
</span>
<svg
v-if=
"updatingKeyIds.has(key.id)"
class=
"h-3 w-3 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
><circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle><path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg>
<svg
v-else
class=
"h-3 w-3 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/></svg>
</button>
</div>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.columns.created
'
)
}}
:
{{
formatDateTime
(
key
.
created_at
)
}}
</span></div>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.columns.created
'
)
}}
:
{{
formatDateTime
(
key
.
created_at
)
}}
</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</BaseDialog>
</BaseDialog>
<!-- Group Selector Dropdown -->
<Teleport
to=
"body"
>
<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"
:style=
"
{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div
class=
"max-h-64 overflow-y-auto p-1.5"
>
<!-- Unbind option -->
<button
@
click=
"changeGroup(selectedKeyForGroup!, null)"
:class=
"[
'flex w-full items-center rounded-lg px-3 py-2 text-sm transition-colors',
!selectedKeyForGroup?.group_id
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<span
class=
"text-gray-500 italic"
>
{{
t
(
'
admin.users.none
'
)
}}
</span>
<svg
v-if=
"!selectedKeyForGroup?.group_id"
class=
"ml-auto 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>
</button>
<!-- Group options -->
<button
v-for=
"group in allGroups"
:key=
"group.id"
@
click=
"changeGroup(selectedKeyForGroup!, group.id)"
:class=
"[
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors',
selectedKeyForGroup?.group_id === group.id
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<GroupOptionItem
:name=
"group.name"
:platform=
"group.platform"
:subscription-type=
"group.subscription_type"
:rate-multiplier=
"group.rate_multiplier"
:description=
"group.description"
:selected=
"selectedKeyForGroup?.group_id === group.id"
/>
</button>
</div>
</div>
</Teleport>
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
ref
,
computed
,
watch
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
AdminUser
,
ApiKey
}
from
'
@/types
'
import
type
{
AdminUser
,
AdminGroup
,
ApiKey
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
const
props
=
defineProps
<
{
show
:
boolean
;
user
:
AdminUser
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
])
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
apiKeys
=
ref
<
ApiKey
[]
>
([])
const
allGroups
=
ref
<
AdminGroup
[]
>
([])
const
loading
=
ref
(
false
)
const
updatingKeyIds
=
ref
(
new
Set
<
number
>
())
const
groupSelectorKeyId
=
ref
<
number
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
scrollContainerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
groupButtonRefs
=
ref
<
Map
<
number
,
HTMLElement
>>
(
new
Map
())
const
selectedKeyForGroup
=
computed
(()
=>
{
if
(
groupSelectorKeyId
.
value
===
null
)
return
null
return
apiKeys
.
value
.
find
((
k
)
=>
k
.
id
===
groupSelectorKeyId
.
value
)
||
null
})
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
AdminUser
|
null
}
>
()
const
setGroupButtonRef
=
(
keyId
:
number
,
el
:
Element
|
ComponentPublicInstance
|
null
)
=>
{
defineEmits
([
'
close
'
]);
const
{
t
}
=
useI18n
()
if
(
el
instanceof
HTMLElement
)
{
const
apiKeys
=
ref
<
ApiKey
[]
>
([]);
const
loading
=
ref
(
false
)
groupButtonRefs
.
value
.
set
(
keyId
,
el
)
}
else
{
groupButtonRefs
.
value
.
delete
(
keyId
)
}
}
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
{
load
()
loadGroups
()
}
else
{
closeGroupSelector
()
}
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
load
()
})
const
load
=
async
()
=>
{
const
load
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
loading
.
value
=
true
if
(
!
props
.
user
)
return
try
{
const
res
=
await
adminAPI
.
users
.
getUserApiKeys
(
props
.
user
.
id
);
apiKeys
.
value
=
res
.
items
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load API keys:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
loading
.
value
=
true
groupButtonRefs
.
value
.
clear
()
try
{
const
res
=
await
adminAPI
.
users
.
getUserApiKeys
(
props
.
user
.
id
)
apiKeys
.
value
=
res
.
items
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load API keys:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
const
loadGroups
=
async
()
=>
{
try
{
const
groups
=
await
adminAPI
.
groups
.
getAll
()
// 过滤掉订阅类型分组(需通过订阅管理流程绑定)
allGroups
.
value
=
groups
.
filter
((
g
)
=>
g
.
subscription_type
!==
'
subscription
'
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to load groups:
'
,
error
)
}
}
const
DROPDOWN_HEIGHT
=
272
// max-h-64 = 16rem = 256px + padding
const
DROPDOWN_GAP
=
4
const
openGroupSelector
=
(
key
:
ApiKey
)
=>
{
if
(
groupSelectorKeyId
.
value
===
key
.
id
)
{
closeGroupSelector
()
}
else
{
const
buttonEl
=
groupButtonRefs
.
value
.
get
(
key
.
id
)
if
(
buttonEl
)
{
const
rect
=
buttonEl
.
getBoundingClientRect
()
const
spaceBelow
=
window
.
innerHeight
-
rect
.
bottom
const
openUpward
=
spaceBelow
<
DROPDOWN_HEIGHT
&&
rect
.
top
>
spaceBelow
dropdownPosition
.
value
=
{
top
:
openUpward
?
rect
.
top
-
DROPDOWN_HEIGHT
-
DROPDOWN_GAP
:
rect
.
bottom
+
DROPDOWN_GAP
,
left
:
rect
.
left
}
}
groupSelectorKeyId
.
value
=
key
.
id
}
}
}
const
closeGroupSelector
=
()
=>
{
groupSelectorKeyId
.
value
=
null
dropdownPosition
.
value
=
null
}
const
changeGroup
=
async
(
key
:
ApiKey
,
newGroupId
:
number
|
null
)
=>
{
closeGroupSelector
()
if
(
key
.
group_id
===
newGroupId
||
(
!
key
.
group_id
&&
newGroupId
===
null
))
return
updatingKeyIds
.
value
.
add
(
key
.
id
)
try
{
const
result
=
await
adminAPI
.
apiKeys
.
updateApiKeyGroup
(
key
.
id
,
newGroupId
)
// Update local data
const
idx
=
apiKeys
.
value
.
findIndex
((
k
)
=>
k
.
id
===
key
.
id
)
if
(
idx
!==
-
1
)
{
apiKeys
.
value
[
idx
]
=
result
.
api_key
}
if
(
result
.
auto_granted_group_access
&&
result
.
granted_group_name
)
{
appStore
.
showSuccess
(
t
(
'
admin.users.groupChangedWithGrant
'
,
{
group
:
result
.
granted_group_name
}))
}
else
{
appStore
.
showSuccess
(
t
(
'
admin.users.groupChangedSuccess
'
))
}
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.users.groupChangeFailed
'
))
}
finally
{
updatingKeyIds
.
value
.
delete
(
key
.
id
)
}
}
const
handleKeyDown
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
groupSelectorKeyId
.
value
!==
null
)
{
event
.
stopPropagation
()
closeGroupSelector
()
}
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
dropdownRef
.
value
&&
!
dropdownRef
.
value
.
contains
(
target
))
{
// Check if the click is on one of the group trigger buttons
for
(
const
el
of
groupButtonRefs
.
value
.
values
())
{
if
(
el
.
contains
(
target
))
return
}
closeGroupSelector
()
}
}
const
handleClose
=
()
=>
{
closeGroupSelector
()
emit
(
'
close
'
)
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleKeyDown
,
true
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleKeyDown
,
true
)
})
</
script
>
</
script
>
frontend/src/i18n/locales/en.ts
View file @
4587c3e5
...
@@ -1076,6 +1076,9 @@ export default {
...
@@ -1076,6 +1076,9 @@ export default {
noApiKeys
:
'
This user has no API keys
'
,
noApiKeys
:
'
This user has no API keys
'
,
group
:
'
Group
'
,
group
:
'
Group
'
,
none
:
'
None
'
,
none
:
'
None
'
,
groupChangedSuccess
:
'
Group updated successfully
'
,
groupChangedWithGrant
:
'
Group updated. User auto-granted access to "{group}"
'
,
groupChangeFailed
:
'
Failed to update group
'
,
noUsersYet
:
'
No users yet
'
,
noUsersYet
:
'
No users yet
'
,
createFirstUser
:
'
Create your first user to get started.
'
,
createFirstUser
:
'
Create your first user to get started.
'
,
userCreated
:
'
User created successfully
'
,
userCreated
:
'
User created successfully
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
4587c3e5
...
@@ -1104,6 +1104,9 @@ export default {
...
@@ -1104,6 +1104,9 @@ export default {
noApiKeys
:
'
此用户暂无 API 密钥
'
,
noApiKeys
:
'
此用户暂无 API 密钥
'
,
group
:
'
分组
'
,
group
:
'
分组
'
,
none
:
'
无
'
,
none
:
'
无
'
,
groupChangedSuccess
:
'
分组修改成功
'
,
groupChangedWithGrant
:
'
分组修改成功,已自动为用户添加「{group}」分组权限
'
,
groupChangeFailed
:
'
分组修改失败
'
,
noUsersYet
:
'
暂无用户
'
,
noUsersYet
:
'
暂无用户
'
,
createFirstUser
:
'
创建您的第一个用户以开始使用系统
'
,
createFirstUser
:
'
创建您的第一个用户以开始使用系统
'
,
userCreated
:
'
用户创建成功
'
,
userCreated
:
'
用户创建成功
'
,
...
...
Prev
1
2
Next
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