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
35291484
Unverified
Commit
35291484
authored
Mar 20, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 20, 2026
Browse files
Merge pull request #1151 from DaydreamCoding/feat/admin-user-group-filter
feat(admin): 用户管理新增分组列、分组筛选与专属分组一键替换
parents
01d8286b
ba7d2aec
Changes
29
Show whitespace changes
Inline
Side-by-side
frontend/src/api/admin/users.ts
View file @
35291484
...
...
@@ -21,6 +21,7 @@ export async function list(
status
?:
'
active
'
|
'
disabled
'
role
?:
'
admin
'
|
'
user
'
search
?:
string
group_name
?:
string
// fuzzy filter by allowed group name
attributes
?:
Record
<
number
,
string
>
// attributeId -> value
include_subscriptions
?:
boolean
},
...
...
@@ -35,6 +36,7 @@ export async function list(
status
:
filters
?.
status
,
role
:
filters
?.
role
,
search
:
filters
?.
search
,
group_name
:
filters
?.
group_name
,
include_subscriptions
:
filters
?.
include_subscriptions
}
...
...
@@ -223,6 +225,25 @@ export async function getUserBalanceHistory(
return
data
}
/**
* Replace user's exclusive group
* @param userId - User ID
* @param oldGroupId - Current group ID to replace
* @param newGroupId - New group ID to replace with
* @returns Number of migrated keys
*/
export
async
function
replaceGroup
(
userId
:
number
,
oldGroupId
:
number
,
newGroupId
:
number
):
Promise
<
{
migrated_keys
:
number
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
migrated_keys
:
number
}
>
(
`/admin/users/
${
userId
}
/replace-group`
,
{
old_group_id
:
oldGroupId
,
new_group_id
:
newGroupId
}
)
return
data
}
export
const
usersAPI
=
{
list
,
getById
,
...
...
@@ -234,7 +255,8 @@ export const usersAPI = {
toggleStatus
,
getUserApiKeys
,
getUserUsageStats
,
getUserBalanceHistory
getUserBalanceHistory
,
replaceGroup
}
export
default
usersAPI
frontend/src/components/admin/user/GroupReplaceModal.vue
0 → 100644
View file @
35291484
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.replaceGroupTitle')"
width=
"narrow"
@
close=
"$emit('close')"
>
<div
v-if=
"oldGroup"
class=
"space-y-4"
>
<!-- 提示信息 -->
<p
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.users.replaceGroupHint
'
,
{
old
:
oldGroup
.
name
}
)
}}
<
/p
>
<!--
当前分组
-->
<
div
class
=
"
rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800
"
>
<
div
class
=
"
flex items-center gap-2
"
>
<
Icon
name
=
"
shield
"
size
=
"
sm
"
class
=
"
text-purple-500
"
/>
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
oldGroup
.
name
}}
<
/span
>
<
Icon
name
=
"
arrowRight
"
size
=
"
sm
"
class
=
"
ml-auto text-gray-400
"
/>
<
span
v
-
if
=
"
selectedGroupId
"
class
=
"
font-medium text-primary-600 dark:text-primary-400
"
>
{{
availableGroups
.
find
(
g
=>
g
.
id
===
selectedGroupId
)?.
name
}}
<
/span
>
<
span
v
-
else
class
=
"
text-sm text-gray-400
"
>
?
<
/span
>
<
/div
>
<
/div
>
<!--
可选分组列表
-->
<
div
v
-
if
=
"
availableGroups.length > 0
"
class
=
"
max-h-64 space-y-2 overflow-y-auto
"
>
<
label
v
-
for
=
"
group in availableGroups
"
:
key
=
"
group.id
"
class
=
"
flex cursor-pointer items-center gap-3 rounded-lg border-2 p-3 transition-all
"
:
class
=
"
selectedGroupId === group.id
? 'border-primary-400 bg-primary-50/50 dark:border-primary-500 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-dark-600 dark:hover:border-dark-500'
"
>
<
input
type
=
"
radio
"
:
value
=
"
group.id
"
v
-
model
=
"
selectedGroupId
"
class
=
"
sr-only
"
/>
<
div
class
=
"
flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all
"
:
class
=
"
selectedGroupId === group.id
? 'border-primary-500 bg-primary-500'
: 'border-gray-300 dark:border-dark-500'
"
>
<
div
v
-
if
=
"
selectedGroupId === group.id
"
class
=
"
h-2 w-2 rounded-full bg-white
"
><
/div
>
<
/div
>
<
div
class
=
"
flex-1
"
>
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
group
.
name
}}
<
/span
>
<
span
class
=
"
ml-2 text-xs text-gray-400
"
>
{{
group
.
platform
}}
<
/span
>
<
/div
>
<
/label
>
<
/div
>
<!--
无可选分组
-->
<
div
v
-
else
class
=
"
py-6 text-center text-sm text-gray-400
"
>
{{
t
(
'
admin.users.noOtherGroups
'
)
}}
<
/div
>
<
/div
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
$emit('close')
"
class
=
"
btn btn-secondary px-5
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
@
click
=
"
handleReplace
"
:
disabled
=
"
!selectedGroupId || submitting
"
class
=
"
btn btn-primary px-6
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
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
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
admin.users.replaceGroupConfirm
'
)
}}
<
/button
>
<
/div
>
<
/template
>
<
/BaseDialog
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
watch
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
AdminUser
,
AdminGroup
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
interface
Props
{
show
:
boolean
user
:
AdminUser
|
null
oldGroup
:
{
id
:
number
;
name
:
string
}
|
null
allGroups
:
AdminGroup
[]
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
])
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
selectedGroupId
=
ref
<
number
|
null
>
(
null
)
const
submitting
=
ref
(
false
)
// 可选的专属标准分组(排除当前 oldGroup)
const
availableGroups
=
computed
(()
=>
{
if
(
!
props
.
oldGroup
)
return
[]
return
props
.
allGroups
.
filter
(
g
=>
g
.
status
===
'
active
'
&&
g
.
is_exclusive
&&
g
.
subscription_type
===
'
standard
'
&&
g
.
id
!==
props
.
oldGroup
!
.
id
)
}
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
{
selectedGroupId
.
value
=
null
}
}
)
const
handleReplace
=
async
()
=>
{
if
(
!
props
.
user
||
!
props
.
oldGroup
||
!
selectedGroupId
.
value
)
return
submitting
.
value
=
true
try
{
const
result
=
await
adminAPI
.
users
.
replaceGroup
(
props
.
user
.
id
,
props
.
oldGroup
.
id
,
selectedGroupId
.
value
)
appStore
.
showSuccess
(
t
(
'
admin.users.replaceGroupSuccess
'
,
{
count
:
result
.
migrated_keys
}
))
emit
(
'
success
'
)
emit
(
'
close
'
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to replace group:
'
,
error
)
}
finally
{
submitting
.
value
=
false
}
}
<
/script
>
frontend/src/components/common/DataTable.vue
View file @
35291484
...
...
@@ -79,7 +79,8 @@
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index)
getStickyColumnClass(column, index),
column.class
]"
@
click=
"column.sortable && handleSort(column.key)"
>
...
...
@@ -168,7 +169,8 @@
:class=
"[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
getStickyColumnClass(column, colIndex),
column.class
]"
>
<slot
:name=
"`cell-$
{column.key}`"
...
...
frontend/src/components/common/Select.vue
View file @
35291484
...
...
@@ -77,7 +77,13 @@
]"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
<Icon
v-if=
"option._creatable"
name=
"search"
size=
"sm"
class=
"flex-shrink-0 text-gray-400"
/>
<span
class=
"select-option-label"
:class=
"option._creatable && 'italic text-gray-500 dark:text-dark-300'"
>
{{
getOptionLabel
(
option
)
}}
</span>
<Icon
v-if=
"isSelected(option)"
name=
"check"
...
...
@@ -127,6 +133,8 @@ interface Props {
emptyText
?:
string
valueKey
?:
string
labelKey
?:
string
creatable
?:
boolean
creatablePrefix
?:
string
}
interface
Emits
{
...
...
@@ -138,6 +146,8 @@ const props = withDefaults(defineProps<Props>(), {
disabled
:
false
,
error
:
false
,
searchable
:
false
,
creatable
:
false
,
creatablePrefix
:
''
,
valueKey
:
'
value
'
,
labelKey
:
'
label
'
})
...
...
@@ -217,6 +227,10 @@ const selectedLabel = computed(() => {
if
(
selectedOption
.
value
)
{
return
getOptionLabel
(
selectedOption
.
value
)
}
// In creatable mode, show the raw value if no matching option
if
(
props
.
creatable
&&
props
.
modelValue
)
{
return
String
(
props
.
modelValue
)
}
return
placeholderText
.
value
})
...
...
@@ -231,6 +245,12 @@ const filteredOptions = computed(() => {
if
(
opt
.
description
&&
String
(
opt
.
description
).
toLowerCase
().
includes
(
query
))
return
true
return
false
})
// In creatable mode, always prepend a fuzzy search option
if
(
props
.
creatable
&&
searchQuery
.
value
.
trim
())
{
const
trimmed
=
searchQuery
.
value
.
trim
()
const
prefix
=
props
.
creatablePrefix
||
t
(
'
common.search
'
)
opts
=
[{
[
props
.
valueKey
]:
trimmed
,
[
props
.
labelKey
]:
`
${
prefix
}
"
${
trimmed
}
"`
,
_creatable
:
true
},
...
opts
]
}
}
return
opts
})
...
...
frontend/src/components/common/types.ts
View file @
35291484
...
...
@@ -6,5 +6,6 @@ export interface Column {
key
:
string
label
:
string
sortable
?:
boolean
class
?:
string
formatter
?:
(
value
:
any
,
row
:
any
)
=>
string
}
frontend/src/components/icons/Icon.vue
View file @
35291484
...
...
@@ -86,6 +86,7 @@ const icons = {
download
:
'
M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4
'
,
upload
:
'
M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5
'
,
filter
:
'
M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z
'
,
globe
:
'
M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418
'
,
sort
:
'
M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9
'
,
// Security
...
...
frontend/src/i18n/locales/en.ts
View file @
35291484
...
...
@@ -1289,6 +1289,9 @@ export default {
searchUsers
:
'
Search by email, username, notes, or API key...
'
,
allRoles
:
'
All Roles
'
,
allStatus
:
'
All Status
'
,
allGroups
:
'
All Groups
'
,
searchGroups
:
'
Search groups...
'
,
fuzzySearch
:
'
Fuzzy search
'
,
admin
:
'
Admin
'
,
user
:
'
User
'
,
disabled
:
'
Disabled
'
,
...
...
@@ -1313,6 +1316,7 @@ export default {
username
:
'
Username
'
,
notes
:
'
Notes
'
,
role
:
'
Role
'
,
groups
:
'
Groups
'
,
subscriptions
:
'
Subscriptions
'
,
balance
:
'
Balance
'
,
usage
:
'
Usage
'
,
...
...
@@ -1324,6 +1328,9 @@ export default {
today
:
'
Today
'
,
total
:
'
Last 30d
'
,
noSubscription
:
'
No subscription
'
,
publicGroupCount
:
'
+{count} public
'
,
exclusiveLabel
:
'
exclusive
'
,
publicLabel
:
'
public
'
,
daysRemaining
:
'
{days}d
'
,
expired
:
'
Expired
'
,
disable
:
'
Disable
'
,
...
...
@@ -1379,6 +1386,14 @@ export default {
useDefaultRate
:
'
Use Default
'
,
customRatePlaceholder
:
'
Leave empty for default
'
,
groupConfigUpdated
:
'
Group configuration updated successfully
'
,
replaceGroup
:
'
Replace Group
'
,
clickToReplace
:
'
Click to replace
'
,
replaceGroupTitle
:
'
Replace Exclusive Group
'
,
replaceGroupHint
:
'
Select a new group to replace "{old}". Keys will be migrated and permissions updated automatically.
'
,
replaceGroupConfirm
:
'
Confirm Replace
'
,
replaceGroupSuccess
:
'
Group replaced successfully, {count} key(s) migrated
'
,
selectNewGroup
:
'
Select target group
'
,
noOtherGroups
:
'
No other exclusive groups available
'
,
deposit
:
'
Deposit
'
,
withdraw
:
'
Withdraw
'
,
depositAmount
:
'
Deposit Amount
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
35291484
...
...
@@ -1314,6 +1314,9 @@ export default {
roleFilter
:
'
角色筛选
'
,
allRoles
:
'
全部角色
'
,
allStatus
:
'
全部状态
'
,
allGroups
:
'
全部分组
'
,
searchGroups
:
'
搜索分组...
'
,
fuzzySearch
:
'
模糊搜索
'
,
statusFilter
:
'
状态筛选
'
,
allStatuses
:
'
全部状态
'
,
admin
:
'
管理员
'
,
...
...
@@ -1340,6 +1343,7 @@ export default {
username
:
'
用户名
'
,
notes
:
'
备注
'
,
role
:
'
角色
'
,
groups
:
'
分组
'
,
subscriptions
:
'
订阅分组
'
,
balance
:
'
余额
'
,
usage
:
'
用量
'
,
...
...
@@ -1351,6 +1355,9 @@ export default {
today
:
'
今日
'
,
total
:
'
近30天
'
,
noSubscription
:
'
暂无订阅
'
,
publicGroupCount
:
'
+{count} 公开
'
,
exclusiveLabel
:
'
专属
'
,
publicLabel
:
'
公开
'
,
daysRemaining
:
'
{days}天
'
,
expired
:
'
已过期
'
,
disable
:
'
禁用
'
,
...
...
@@ -1442,6 +1449,14 @@ export default {
useDefaultRate
:
'
使用默认
'
,
customRatePlaceholder
:
'
留空使用默认
'
,
groupConfigUpdated
:
'
分组配置更新成功
'
,
replaceGroup
:
'
替换分组
'
,
clickToReplace
:
'
点击替换分组
'
,
replaceGroupTitle
:
'
替换专属分组
'
,
replaceGroupHint
:
'
选择新分组替换「{old}」,将自动迁移绑定的 Key 并更新分组权限
'
,
replaceGroupConfirm
:
'
确认替换
'
,
replaceGroupSuccess
:
'
分组替换成功,已迁移 {count} 个 Key
'
,
selectNewGroup
:
'
请选择目标分组
'
,
noOtherGroups
:
'
没有其他可用的专属分组
'
,
deposit
:
'
充值
'
,
withdraw
:
'
退款
'
,
depositAmount
:
'
充值金额
'
,
...
...
frontend/src/views/admin/UsersView.vue
View file @
35291484
...
...
@@ -48,6 +48,19 @@
/>
</div>
<!-- Group Filter (visible when enabled) -->
<div
v-if=
"visibleFilters.has('group')"
class=
"w-full sm:w-44"
>
<Select
v-model=
"filters.group"
:options=
"groupFilterOptions"
searchable
creatable
:creatable-prefix=
"t('admin.users.fuzzySearch')"
:search-placeholder=
"t('admin.users.searchGroups')"
@
change=
"applyFilter"
/>
</div>
<!-- Dynamic Attribute Filters -->
<template
v-for=
"(value, attrId) in activeAttributeFilters"
:key=
"attrId"
>
<div
...
...
@@ -275,6 +288,71 @@
</span>
</
template
>
<
template
#cell-groups=
"{ row }"
>
<div
v-if=
"allGroups.length > 0"
class=
"flex flex-col gap-1"
>
<!-- 专属分组行 -->
<span
v-if=
"getUserGroups(row).exclusive.length > 0"
class=
"group/ex relative inline-flex cursor-pointer items-center gap-1 whitespace-nowrap text-xs"
@
click.stop=
"toggleExpandedGroup(row.id)"
>
<Icon
name=
"shield"
size=
"xs"
class=
"h-3.5 w-3.5 text-purple-500 dark:text-purple-400"
/>
<span
class=
"font-medium text-purple-600 dark:text-purple-400"
>
{{
getUserGroups
(
row
).
exclusive
.
length
}}
</span>
<span
class=
"text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.users.exclusiveLabel
'
)
}}
</span>
<!-- Hover tooltip(操作菜单未打开时显示) -->
<div
v-if=
"expandedGroupUserId !== row.id"
class=
"pointer-events-none absolute left-0 top-full z-50 mt-1.5 rounded bg-gray-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover/ex:opacity-100 dark:bg-dark-600"
>
<div
class=
"absolute left-4 bottom-full border-4 border-transparent border-b-gray-900 dark:border-b-dark-600"
></div>
<div
class=
"flex flex-col gap-0.5 whitespace-nowrap"
>
<span
v-for=
"g in getUserGroups(row).exclusive"
:key=
"g.id"
>
{{
g
.
name
}}
</span>
</div>
</div>
<!-- 点击展开分组操作菜单 -->
<div
v-if=
"expandedGroupUserId === row.id"
class=
"absolute left-0 top-full z-50 mt-1.5 min-w-[160px] overflow-hidden rounded-lg border border-gray-200 bg-white py-1 text-xs shadow-xl dark:border-dark-600 dark:bg-dark-700"
>
<div
class=
"border-b border-gray-100 px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:border-dark-600 dark:text-dark-400"
>
{{
t
(
'
admin.users.clickToReplace
'
)
}}
</div>
<div
v-for=
"g in getUserGroups(row).exclusive"
:key=
"g.id"
class=
"flex cursor-pointer items-center gap-2 px-3 py-2 text-gray-700 transition-colors hover:bg-primary-50 hover:text-primary-600 dark:text-dark-200 dark:hover:bg-primary-900/30 dark:hover:text-primary-400"
@
click.stop=
"openGroupReplace(row, g)"
>
<Icon
name=
"swap"
size=
"xs"
class=
"h-3.5 w-3.5 flex-shrink-0 opacity-50"
/>
<span
class=
"flex-1"
>
{{
g
.
name
}}
</span>
</div>
</div>
</span>
<!-- 公开分组行 -->
<span
v-if=
"getUserGroups(row).publicGroups.length > 0"
class=
"group/pub relative inline-flex cursor-default items-center gap-1 whitespace-nowrap text-xs"
>
<Icon
name=
"globe"
size=
"xs"
class=
"h-3.5 w-3.5 text-gray-400 dark:text-dark-500"
/>
<span
class=
"font-medium text-gray-600 dark:text-dark-300"
>
{{
getUserGroups
(
row
).
publicGroups
.
length
}}
</span>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
admin.users.publicLabel
'
)
}}
</span>
<!-- Tooltip: 向下弹出 -->
<div
class=
"pointer-events-none absolute left-0 top-full z-50 mt-1.5 rounded bg-gray-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover/pub:opacity-100 dark:bg-dark-600"
>
<div
class=
"absolute left-4 bottom-full border-4 border-transparent border-b-gray-900 dark:border-b-dark-600"
></div>
<div
class=
"flex flex-col gap-0.5 whitespace-nowrap"
>
<span
v-for=
"g in getUserGroups(row).publicGroups"
:key=
"g.id"
>
{{
g
.
name
}}
</span>
</div>
</div>
</span>
<!-- 都没有 -->
<span
v-if=
"getUserGroups(row).exclusive.length === 0 && getUserGroups(row).publicGroups.length === 0"
class=
"text-xs text-gray-400 dark:text-dark-500"
>
-
</span>
</div>
<span
v-else
class=
"text-xs text-gray-400 dark:text-dark-500"
>
-
</span>
</
template
>
<
template
#cell-subscriptions=
"{ row }"
>
<div
v-if=
"row.subscriptions && row.subscriptions.length > 0"
...
...
@@ -513,6 +591,7 @@
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
success=
"loadUsers"
/>
<UserBalanceHistoryModal
:show=
"showBalanceHistoryModal"
:user=
"balanceHistoryUser"
@
close=
"closeBalanceHistoryModal"
@
deposit=
"handleDepositFromHistory"
@
withdraw=
"handleWithdrawFromHistory"
/>
<GroupReplaceModal
:show=
"showGroupReplaceModal"
:user=
"groupReplaceUser"
:old-group=
"groupReplaceOldGroup"
:all-groups=
"allGroups"
@
close=
"closeGroupReplaceModal"
@
success=
"loadUsers"
/>
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</template>
...
...
@@ -527,7 +606,7 @@ import Icon from '@/components/icons/Icon.vue'
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
AdminUser
,
UserAttributeDefinition
}
from
'
@/types
'
import
type
{
AdminUser
,
AdminGroup
,
UserAttributeDefinition
}
from
'
@/types
'
import
type
{
BatchUserUsageStats
}
from
'
@/api/admin/dashboard
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
...
...
@@ -546,6 +625,7 @@ import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
import
GroupReplaceModal
from
'
@/components/admin/user/GroupReplaceModal.vue
'
const
appStore
=
useAppStore
()
...
...
@@ -604,6 +684,7 @@ const allColumns = computed<Column[]>(() => [
// Dynamic attribute columns
...
attributeColumns
.
value
,
{
key
:
'
role
'
,
label
:
t
(
'
admin.users.columns.role
'
),
sortable
:
true
},
{
key
:
'
groups
'
,
label
:
t
(
'
admin.users.columns.groups
'
),
sortable
:
false
},
{
key
:
'
subscriptions
'
,
label
:
t
(
'
admin.users.columns.subscriptions
'
),
sortable
:
false
},
{
key
:
'
balance
'
,
label
:
t
(
'
admin.users.columns.balance
'
),
sortable
:
true
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.users.columns.usage
'
),
sortable
:
false
},
...
...
@@ -623,7 +704,7 @@ const toggleableColumns = computed(() =>
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
// Default hidden columns (columns hidden by default on first load)
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
notes
'
,
'
subscriptions
'
,
'
usage
'
,
'
concurrency
'
]
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
notes
'
,
'
groups
'
,
'
subscriptions
'
,
'
usage
'
,
'
concurrency
'
]
// localStorage key for column settings
const
HIDDEN_COLUMNS_KEY
=
'
user-hidden-columns
'
...
...
@@ -669,12 +750,16 @@ const toggleColumn = (key: string) => {
if
(
key
===
'
subscriptions
'
)
{
loadUsers
()
}
if
(
wasHidden
&&
key
===
'
groups
'
)
{
loadAllGroups
()
}
}
// Check if column is visible (not in hidden set)
const
isColumnVisible
=
(
key
:
string
)
=>
!
hiddenColumns
.
has
(
key
)
const
hasVisibleUsageColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
usage
'
))
const
hasVisibleSubscriptionsColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
subscriptions
'
))
const
hasVisibleGroupsColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
groups
'
))
const
hasVisibleAttributeColumns
=
computed
(()
=>
attributeDefinitions
.
value
.
some
((
def
)
=>
def
.
enabled
&&
!
hiddenColumns
.
has
(
`attr_
${
def
.
id
}
`
))
)
...
...
@@ -690,10 +775,50 @@ const users = ref<AdminUser[]>([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
// Groups data for the groups column
const
allGroups
=
ref
<
AdminGroup
[]
>
([])
const
loadAllGroups
=
async
()
=>
{
if
(
allGroups
.
value
.
length
>
0
)
return
try
{
allGroups
.
value
=
await
adminAPI
.
groups
.
getAll
()
}
catch
(
e
)
{
console
.
error
(
'
Failed to load groups:
'
,
e
)
}
}
// Resolve user's accessible groups: exclusive groups first, then public groups
const
getUserGroups
=
(
user
:
AdminUser
)
=>
{
const
exclusive
:
AdminGroup
[]
=
[]
const
publicGroups
:
AdminGroup
[]
=
[]
for
(
const
g
of
allGroups
.
value
)
{
if
(
g
.
status
!==
'
active
'
||
g
.
subscription_type
!==
'
standard
'
)
continue
if
(
g
.
is_exclusive
)
{
if
(
user
.
allowed_groups
?.
includes
(
g
.
id
))
{
exclusive
.
push
(
g
)
}
}
else
{
publicGroups
.
push
(
g
)
}
}
return
{
exclusive
,
publicGroups
}
}
// Group filter options: "All Groups" + active exclusive groups (value = group name for fuzzy match)
const
groupFilterOptions
=
computed
(()
=>
{
const
options
:
{
value
:
string
;
label
:
string
}[]
=
[
{
value
:
''
,
label
:
t
(
'
admin.users.allGroups
'
)
}
]
for
(
const
g
of
allGroups
.
value
)
{
if
(
g
.
status
!==
'
active
'
||
!
g
.
is_exclusive
||
g
.
subscription_type
!==
'
standard
'
)
continue
options
.
push
({
value
:
g
.
name
,
label
:
g
.
name
})
}
return
options
})
// Filter values (role, status, and custom attributes)
const
filters
=
reactive
({
role
:
''
,
status
:
''
status
:
''
,
group
:
''
// group name for fuzzy match, '' = all
})
const
activeAttributeFilters
=
reactive
<
Record
<
number
,
string
>>
({})
...
...
@@ -721,7 +846,8 @@ const filterableAttributes = computed(() =>
// Built-in filter definitions
const
builtInFilters
=
computed
(()
=>
[
{
key
:
'
role
'
,
name
:
t
(
'
admin.users.columns.role
'
),
type
:
'
select
'
as
const
},
{
key
:
'
status
'
,
name
:
t
(
'
admin.users.columns.status
'
),
type
:
'
select
'
as
const
}
{
key
:
'
status
'
,
name
:
t
(
'
admin.users.columns.status
'
),
type
:
'
select
'
as
const
},
{
key
:
'
group
'
,
name
:
t
(
'
admin.users.columns.groups
'
),
type
:
'
select
'
as
const
}
])
// Load saved filters from localStorage
...
...
@@ -739,6 +865,7 @@ const loadSavedFilters = () => {
const
parsed
=
JSON
.
parse
(
savedValues
)
if
(
parsed
.
role
)
filters
.
role
=
parsed
.
role
if
(
parsed
.
status
)
filters
.
status
=
parsed
.
status
if
(
parsed
.
group
)
filters
.
group
=
parsed
.
group
if
(
parsed
.
attributes
)
{
Object
.
assign
(
activeAttributeFilters
,
parsed
.
attributes
)
}
...
...
@@ -757,6 +884,7 @@ const saveFiltersToStorage = () => {
const
values
=
{
role
:
filters
.
role
,
status
:
filters
.
status
,
group
:
filters
.
group
,
attributes
:
activeAttributeFilters
}
localStorage
.
setItem
(
FILTER_VALUES_KEY
,
JSON
.
stringify
(
values
))
...
...
@@ -920,12 +1048,27 @@ const handleClickOutside = (event: MouseEvent) => {
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
}
// Close expanded group dropdown when clicking outside
if
(
expandedGroupUserId
.
value
!==
null
)
{
expandedGroupUserId
.
value
=
null
}
}
// Allowed groups modal state
const
showAllowedGroupsModal
=
ref
(
false
)
const
allowedGroupsUser
=
ref
<
AdminUser
|
null
>
(
null
)
// Expanded group dropdown state (click to show exclusive groups list)
const
expandedGroupUserId
=
ref
<
number
|
null
>
(
null
)
const
toggleExpandedGroup
=
(
userId
:
number
)
=>
{
expandedGroupUserId
.
value
=
expandedGroupUserId
.
value
===
userId
?
null
:
userId
}
// Group replace modal state
const
showGroupReplaceModal
=
ref
(
false
)
const
groupReplaceUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
groupReplaceOldGroup
=
ref
<
{
id
:
number
;
name
:
string
}
|
null
>
(
null
)
// Balance (Deposit/Withdraw) modal state
const
showBalanceModal
=
ref
(
false
)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
...
...
@@ -980,6 +1123,7 @@ const loadUsers = async () => {
role
:
filters
.
role
as
any
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
,
group_name
:
filters
.
group
||
undefined
,
attributes
:
Object
.
keys
(
attrFilters
).
length
>
0
?
attrFilters
:
undefined
,
include_subscriptions
:
hasVisibleSubscriptionsColumn
.
value
},
...
...
@@ -1052,8 +1196,10 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters
.
delete
(
key
)
if
(
key
===
'
role
'
)
filters
.
role
=
''
if
(
key
===
'
status
'
)
filters
.
status
=
''
if
(
key
===
'
group
'
)
filters
.
group
=
''
}
else
{
visibleFilters
.
add
(
key
)
if
(
key
===
'
group
'
)
loadAllGroups
()
}
saveFiltersToStorage
()
pagination
.
page
=
1
...
...
@@ -1129,6 +1275,19 @@ const closeAllowedGroupsModal = () => {
allowedGroupsUser
.
value
=
null
}
const
openGroupReplace
=
(
user
:
AdminUser
,
group
:
{
id
:
number
;
name
:
string
})
=>
{
expandedGroupUserId
.
value
=
null
groupReplaceUser
.
value
=
user
groupReplaceOldGroup
.
value
=
group
showGroupReplaceModal
.
value
=
true
}
const
closeGroupReplaceModal
=
()
=>
{
showGroupReplaceModal
.
value
=
false
groupReplaceUser
.
value
=
null
groupReplaceOldGroup
.
value
=
null
}
const
handleDelete
=
(
user
:
AdminUser
)
=>
{
deletingUser
.
value
=
user
showDeleteDialog
.
value
=
true
...
...
@@ -1199,6 +1358,9 @@ onMounted(async () => {
loadSavedFilters
()
loadSavedColumns
()
loadUsers
()
if
(
hasVisibleGroupsColumn
.
value
||
visibleFilters
.
has
(
'
group
'
))
{
loadAllGroups
()
}
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
})
...
...
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