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
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/users.ts
View file @
35291484
...
@@ -21,6 +21,7 @@ export async function list(
...
@@ -21,6 +21,7 @@ export async function list(
status
?:
'
active
'
|
'
disabled
'
status
?:
'
active
'
|
'
disabled
'
role
?:
'
admin
'
|
'
user
'
role
?:
'
admin
'
|
'
user
'
search
?:
string
search
?:
string
group_name
?:
string
// fuzzy filter by allowed group name
attributes
?:
Record
<
number
,
string
>
// attributeId -> value
attributes
?:
Record
<
number
,
string
>
// attributeId -> value
include_subscriptions
?:
boolean
include_subscriptions
?:
boolean
},
},
...
@@ -35,6 +36,7 @@ export async function list(
...
@@ -35,6 +36,7 @@ export async function list(
status
:
filters
?.
status
,
status
:
filters
?.
status
,
role
:
filters
?.
role
,
role
:
filters
?.
role
,
search
:
filters
?.
search
,
search
:
filters
?.
search
,
group_name
:
filters
?.
group_name
,
include_subscriptions
:
filters
?.
include_subscriptions
include_subscriptions
:
filters
?.
include_subscriptions
}
}
...
@@ -223,6 +225,25 @@ export async function getUserBalanceHistory(
...
@@ -223,6 +225,25 @@ export async function getUserBalanceHistory(
return
data
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
=
{
export
const
usersAPI
=
{
list
,
list
,
getById
,
getById
,
...
@@ -234,7 +255,8 @@ export const usersAPI = {
...
@@ -234,7 +255,8 @@ export const usersAPI = {
toggleStatus
,
toggleStatus
,
getUserApiKeys
,
getUserApiKeys
,
getUserUsageStats
,
getUserUsageStats
,
getUserBalanceHistory
getUserBalanceHistory
,
replaceGroup
}
}
export
default
usersAPI
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 @@
...
@@ -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',
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(),
getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
{ '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)"
@
click=
"column.sortable && handleSort(column.key)"
>
>
...
@@ -168,7 +169,8 @@
...
@@ -168,7 +169,8 @@
:class=
"[
:class=
"[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
getStickyColumnClass(column, colIndex),
column.class
]"
]"
>
>
<slot
:name=
"`cell-$
{column.key}`"
<slot
:name=
"`cell-$
{column.key}`"
...
...
frontend/src/components/common/Select.vue
View file @
35291484
...
@@ -77,7 +77,13 @@
...
@@ -77,7 +77,13 @@
]"
]"
>
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<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
<Icon
v-if=
"isSelected(option)"
v-if=
"isSelected(option)"
name=
"check"
name=
"check"
...
@@ -127,6 +133,8 @@ interface Props {
...
@@ -127,6 +133,8 @@ interface Props {
emptyText
?:
string
emptyText
?:
string
valueKey
?:
string
valueKey
?:
string
labelKey
?:
string
labelKey
?:
string
creatable
?:
boolean
creatablePrefix
?:
string
}
}
interface
Emits
{
interface
Emits
{
...
@@ -138,6 +146,8 @@ const props = withDefaults(defineProps<Props>(), {
...
@@ -138,6 +146,8 @@ const props = withDefaults(defineProps<Props>(), {
disabled
:
false
,
disabled
:
false
,
error
:
false
,
error
:
false
,
searchable
:
false
,
searchable
:
false
,
creatable
:
false
,
creatablePrefix
:
''
,
valueKey
:
'
value
'
,
valueKey
:
'
value
'
,
labelKey
:
'
label
'
labelKey
:
'
label
'
})
})
...
@@ -217,6 +227,10 @@ const selectedLabel = computed(() => {
...
@@ -217,6 +227,10 @@ const selectedLabel = computed(() => {
if
(
selectedOption
.
value
)
{
if
(
selectedOption
.
value
)
{
return
getOptionLabel
(
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
return
placeholderText
.
value
})
})
...
@@ -231,6 +245,12 @@ const filteredOptions = computed(() => {
...
@@ -231,6 +245,12 @@ const filteredOptions = computed(() => {
if
(
opt
.
description
&&
String
(
opt
.
description
).
toLowerCase
().
includes
(
query
))
return
true
if
(
opt
.
description
&&
String
(
opt
.
description
).
toLowerCase
().
includes
(
query
))
return
true
return
false
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
return
opts
})
})
...
...
frontend/src/components/common/types.ts
View file @
35291484
...
@@ -6,5 +6,6 @@ export interface Column {
...
@@ -6,5 +6,6 @@ export interface Column {
key
:
string
key
:
string
label
:
string
label
:
string
sortable
?:
boolean
sortable
?:
boolean
class
?:
string
formatter
?:
(
value
:
any
,
row
:
any
)
=>
string
formatter
?:
(
value
:
any
,
row
:
any
)
=>
string
}
}
frontend/src/components/icons/Icon.vue
View file @
35291484
...
@@ -86,6 +86,7 @@ const icons = {
...
@@ -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
'
,
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
'
,
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
'
,
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
'
,
sort
:
'
M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9
'
,
// Security
// Security
...
...
frontend/src/i18n/locales/en.ts
View file @
35291484
...
@@ -1289,6 +1289,9 @@ export default {
...
@@ -1289,6 +1289,9 @@ export default {
searchUsers
:
'
Search by email, username, notes, or API key...
'
,
searchUsers
:
'
Search by email, username, notes, or API key...
'
,
allRoles
:
'
All Roles
'
,
allRoles
:
'
All Roles
'
,
allStatus
:
'
All Status
'
,
allStatus
:
'
All Status
'
,
allGroups
:
'
All Groups
'
,
searchGroups
:
'
Search groups...
'
,
fuzzySearch
:
'
Fuzzy search
'
,
admin
:
'
Admin
'
,
admin
:
'
Admin
'
,
user
:
'
User
'
,
user
:
'
User
'
,
disabled
:
'
Disabled
'
,
disabled
:
'
Disabled
'
,
...
@@ -1313,6 +1316,7 @@ export default {
...
@@ -1313,6 +1316,7 @@ export default {
username
:
'
Username
'
,
username
:
'
Username
'
,
notes
:
'
Notes
'
,
notes
:
'
Notes
'
,
role
:
'
Role
'
,
role
:
'
Role
'
,
groups
:
'
Groups
'
,
subscriptions
:
'
Subscriptions
'
,
subscriptions
:
'
Subscriptions
'
,
balance
:
'
Balance
'
,
balance
:
'
Balance
'
,
usage
:
'
Usage
'
,
usage
:
'
Usage
'
,
...
@@ -1324,6 +1328,9 @@ export default {
...
@@ -1324,6 +1328,9 @@ export default {
today
:
'
Today
'
,
today
:
'
Today
'
,
total
:
'
Last 30d
'
,
total
:
'
Last 30d
'
,
noSubscription
:
'
No subscription
'
,
noSubscription
:
'
No subscription
'
,
publicGroupCount
:
'
+{count} public
'
,
exclusiveLabel
:
'
exclusive
'
,
publicLabel
:
'
public
'
,
daysRemaining
:
'
{days}d
'
,
daysRemaining
:
'
{days}d
'
,
expired
:
'
Expired
'
,
expired
:
'
Expired
'
,
disable
:
'
Disable
'
,
disable
:
'
Disable
'
,
...
@@ -1379,6 +1386,14 @@ export default {
...
@@ -1379,6 +1386,14 @@ export default {
useDefaultRate
:
'
Use Default
'
,
useDefaultRate
:
'
Use Default
'
,
customRatePlaceholder
:
'
Leave empty for default
'
,
customRatePlaceholder
:
'
Leave empty for default
'
,
groupConfigUpdated
:
'
Group configuration updated successfully
'
,
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
'
,
deposit
:
'
Deposit
'
,
withdraw
:
'
Withdraw
'
,
withdraw
:
'
Withdraw
'
,
depositAmount
:
'
Deposit Amount
'
,
depositAmount
:
'
Deposit Amount
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
35291484
...
@@ -1314,6 +1314,9 @@ export default {
...
@@ -1314,6 +1314,9 @@ export default {
roleFilter
:
'
角色筛选
'
,
roleFilter
:
'
角色筛选
'
,
allRoles
:
'
全部角色
'
,
allRoles
:
'
全部角色
'
,
allStatus
:
'
全部状态
'
,
allStatus
:
'
全部状态
'
,
allGroups
:
'
全部分组
'
,
searchGroups
:
'
搜索分组...
'
,
fuzzySearch
:
'
模糊搜索
'
,
statusFilter
:
'
状态筛选
'
,
statusFilter
:
'
状态筛选
'
,
allStatuses
:
'
全部状态
'
,
allStatuses
:
'
全部状态
'
,
admin
:
'
管理员
'
,
admin
:
'
管理员
'
,
...
@@ -1340,6 +1343,7 @@ export default {
...
@@ -1340,6 +1343,7 @@ export default {
username
:
'
用户名
'
,
username
:
'
用户名
'
,
notes
:
'
备注
'
,
notes
:
'
备注
'
,
role
:
'
角色
'
,
role
:
'
角色
'
,
groups
:
'
分组
'
,
subscriptions
:
'
订阅分组
'
,
subscriptions
:
'
订阅分组
'
,
balance
:
'
余额
'
,
balance
:
'
余额
'
,
usage
:
'
用量
'
,
usage
:
'
用量
'
,
...
@@ -1351,6 +1355,9 @@ export default {
...
@@ -1351,6 +1355,9 @@ export default {
today
:
'
今日
'
,
today
:
'
今日
'
,
total
:
'
近30天
'
,
total
:
'
近30天
'
,
noSubscription
:
'
暂无订阅
'
,
noSubscription
:
'
暂无订阅
'
,
publicGroupCount
:
'
+{count} 公开
'
,
exclusiveLabel
:
'
专属
'
,
publicLabel
:
'
公开
'
,
daysRemaining
:
'
{days}天
'
,
daysRemaining
:
'
{days}天
'
,
expired
:
'
已过期
'
,
expired
:
'
已过期
'
,
disable
:
'
禁用
'
,
disable
:
'
禁用
'
,
...
@@ -1442,6 +1449,14 @@ export default {
...
@@ -1442,6 +1449,14 @@ export default {
useDefaultRate
:
'
使用默认
'
,
useDefaultRate
:
'
使用默认
'
,
customRatePlaceholder
:
'
留空使用默认
'
,
customRatePlaceholder
:
'
留空使用默认
'
,
groupConfigUpdated
:
'
分组配置更新成功
'
,
groupConfigUpdated
:
'
分组配置更新成功
'
,
replaceGroup
:
'
替换分组
'
,
clickToReplace
:
'
点击替换分组
'
,
replaceGroupTitle
:
'
替换专属分组
'
,
replaceGroupHint
:
'
选择新分组替换「{old}」,将自动迁移绑定的 Key 并更新分组权限
'
,
replaceGroupConfirm
:
'
确认替换
'
,
replaceGroupSuccess
:
'
分组替换成功,已迁移 {count} 个 Key
'
,
selectNewGroup
:
'
请选择目标分组
'
,
noOtherGroups
:
'
没有其他可用的专属分组
'
,
deposit
:
'
充值
'
,
deposit
:
'
充值
'
,
withdraw
:
'
退款
'
,
withdraw
:
'
退款
'
,
depositAmount
:
'
充值金额
'
,
depositAmount
:
'
充值金额
'
,
...
...
frontend/src/views/admin/UsersView.vue
View file @
35291484
...
@@ -48,6 +48,19 @@
...
@@ -48,6 +48,19 @@
/>
/>
</div>
</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 -->
<!-- Dynamic Attribute Filters -->
<template
v-for=
"(value, attrId) in activeAttributeFilters"
:key=
"attrId"
>
<template
v-for=
"(value, attrId) in activeAttributeFilters"
:key=
"attrId"
>
<div
<div
...
@@ -275,6 +288,71 @@
...
@@ -275,6 +288,71 @@
</span>
</span>
</
template
>
</
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 }"
>
<
template
#cell-subscriptions=
"{ row }"
>
<div
<div
v-if=
"row.subscriptions && row.subscriptions.length > 0"
v-if=
"row.subscriptions && row.subscriptions.length > 0"
...
@@ -513,6 +591,7 @@
...
@@ -513,6 +591,7 @@
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
success=
"loadUsers"
/>
<UserBalanceHistoryModal
:show=
"showBalanceHistoryModal"
:user=
"balanceHistoryUser"
@
close=
"closeBalanceHistoryModal"
@
deposit=
"handleDepositFromHistory"
@
withdraw=
"handleWithdrawFromHistory"
/>
<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"
/>
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</AppLayout>
</template>
</template>
...
@@ -527,7 +606,7 @@ import Icon from '@/components/icons/Icon.vue'
...
@@ -527,7 +606,7 @@ import Icon from '@/components/icons/Icon.vue'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
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
{
BatchUserUsageStats
}
from
'
@/api/admin/dashboard
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
...
@@ -546,6 +625,7 @@ import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
...
@@ -546,6 +625,7 @@ import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
import
GroupReplaceModal
from
'
@/components/admin/user/GroupReplaceModal.vue
'
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
...
@@ -604,6 +684,7 @@ const allColumns = computed<Column[]>(() => [
...
@@ -604,6 +684,7 @@ const allColumns = computed<Column[]>(() => [
// Dynamic attribute columns
// Dynamic attribute columns
...
attributeColumns
.
value
,
...
attributeColumns
.
value
,
{
key
:
'
role
'
,
label
:
t
(
'
admin.users.columns.role
'
),
sortable
:
true
},
{
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
:
'
subscriptions
'
,
label
:
t
(
'
admin.users.columns.subscriptions
'
),
sortable
:
false
},
{
key
:
'
balance
'
,
label
:
t
(
'
admin.users.columns.balance
'
),
sortable
:
true
},
{
key
:
'
balance
'
,
label
:
t
(
'
admin.users.columns.balance
'
),
sortable
:
true
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.users.columns.usage
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.users.columns.usage
'
),
sortable
:
false
},
...
@@ -623,7 +704,7 @@ const toggleableColumns = computed(() =>
...
@@ -623,7 +704,7 @@ const toggleableColumns = computed(() =>
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
// Default hidden columns (columns hidden by default on first load)
// 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
// localStorage key for column settings
const
HIDDEN_COLUMNS_KEY
=
'
user-hidden-columns
'
const
HIDDEN_COLUMNS_KEY
=
'
user-hidden-columns
'
...
@@ -669,12 +750,16 @@ const toggleColumn = (key: string) => {
...
@@ -669,12 +750,16 @@ const toggleColumn = (key: string) => {
if
(
key
===
'
subscriptions
'
)
{
if
(
key
===
'
subscriptions
'
)
{
loadUsers
()
loadUsers
()
}
}
if
(
wasHidden
&&
key
===
'
groups
'
)
{
loadAllGroups
()
}
}
}
// Check if column is visible (not in hidden set)
// Check if column is visible (not in hidden set)
const
isColumnVisible
=
(
key
:
string
)
=>
!
hiddenColumns
.
has
(
key
)
const
isColumnVisible
=
(
key
:
string
)
=>
!
hiddenColumns
.
has
(
key
)
const
hasVisibleUsageColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
usage
'
))
const
hasVisibleUsageColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
usage
'
))
const
hasVisibleSubscriptionsColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
subscriptions
'
))
const
hasVisibleSubscriptionsColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
subscriptions
'
))
const
hasVisibleGroupsColumn
=
computed
(()
=>
!
hiddenColumns
.
has
(
'
groups
'
))
const
hasVisibleAttributeColumns
=
computed
(()
=>
const
hasVisibleAttributeColumns
=
computed
(()
=>
attributeDefinitions
.
value
.
some
((
def
)
=>
def
.
enabled
&&
!
hiddenColumns
.
has
(
`attr_
${
def
.
id
}
`
))
attributeDefinitions
.
value
.
some
((
def
)
=>
def
.
enabled
&&
!
hiddenColumns
.
has
(
`attr_
${
def
.
id
}
`
))
)
)
...
@@ -690,10 +775,50 @@ const users = ref<AdminUser[]>([])
...
@@ -690,10 +775,50 @@ const users = ref<AdminUser[]>([])
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
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)
// Filter values (role, status, and custom attributes)
const
filters
=
reactive
({
const
filters
=
reactive
({
role
:
''
,
role
:
''
,
status
:
''
status
:
''
,
group
:
''
// group name for fuzzy match, '' = all
})
})
const
activeAttributeFilters
=
reactive
<
Record
<
number
,
string
>>
({})
const
activeAttributeFilters
=
reactive
<
Record
<
number
,
string
>>
({})
...
@@ -721,7 +846,8 @@ const filterableAttributes = computed(() =>
...
@@ -721,7 +846,8 @@ const filterableAttributes = computed(() =>
// Built-in filter definitions
// Built-in filter definitions
const
builtInFilters
=
computed
(()
=>
[
const
builtInFilters
=
computed
(()
=>
[
{
key
:
'
role
'
,
name
:
t
(
'
admin.users.columns.role
'
),
type
:
'
select
'
as
const
},
{
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
// Load saved filters from localStorage
...
@@ -739,6 +865,7 @@ const loadSavedFilters = () => {
...
@@ -739,6 +865,7 @@ const loadSavedFilters = () => {
const
parsed
=
JSON
.
parse
(
savedValues
)
const
parsed
=
JSON
.
parse
(
savedValues
)
if
(
parsed
.
role
)
filters
.
role
=
parsed
.
role
if
(
parsed
.
role
)
filters
.
role
=
parsed
.
role
if
(
parsed
.
status
)
filters
.
status
=
parsed
.
status
if
(
parsed
.
status
)
filters
.
status
=
parsed
.
status
if
(
parsed
.
group
)
filters
.
group
=
parsed
.
group
if
(
parsed
.
attributes
)
{
if
(
parsed
.
attributes
)
{
Object
.
assign
(
activeAttributeFilters
,
parsed
.
attributes
)
Object
.
assign
(
activeAttributeFilters
,
parsed
.
attributes
)
}
}
...
@@ -757,6 +884,7 @@ const saveFiltersToStorage = () => {
...
@@ -757,6 +884,7 @@ const saveFiltersToStorage = () => {
const
values
=
{
const
values
=
{
role
:
filters
.
role
,
role
:
filters
.
role
,
status
:
filters
.
status
,
status
:
filters
.
status
,
group
:
filters
.
group
,
attributes
:
activeAttributeFilters
attributes
:
activeAttributeFilters
}
}
localStorage
.
setItem
(
FILTER_VALUES_KEY
,
JSON
.
stringify
(
values
))
localStorage
.
setItem
(
FILTER_VALUES_KEY
,
JSON
.
stringify
(
values
))
...
@@ -920,12 +1048,27 @@ const handleClickOutside = (event: MouseEvent) => {
...
@@ -920,12 +1048,27 @@ const handleClickOutside = (event: MouseEvent) => {
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
showColumnDropdown
.
value
=
false
}
}
// Close expanded group dropdown when clicking outside
if
(
expandedGroupUserId
.
value
!==
null
)
{
expandedGroupUserId
.
value
=
null
}
}
}
// Allowed groups modal state
// Allowed groups modal state
const
showAllowedGroupsModal
=
ref
(
false
)
const
showAllowedGroupsModal
=
ref
(
false
)
const
allowedGroupsUser
=
ref
<
AdminUser
|
null
>
(
null
)
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
// Balance (Deposit/Withdraw) modal state
const
showBalanceModal
=
ref
(
false
)
const
showBalanceModal
=
ref
(
false
)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
...
@@ -980,6 +1123,7 @@ const loadUsers = async () => {
...
@@ -980,6 +1123,7 @@ const loadUsers = async () => {
role
:
filters
.
role
as
any
,
role
:
filters
.
role
as
any
,
status
:
filters
.
status
as
any
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
,
search
:
searchQuery
.
value
||
undefined
,
group_name
:
filters
.
group
||
undefined
,
attributes
:
Object
.
keys
(
attrFilters
).
length
>
0
?
attrFilters
:
undefined
,
attributes
:
Object
.
keys
(
attrFilters
).
length
>
0
?
attrFilters
:
undefined
,
include_subscriptions
:
hasVisibleSubscriptionsColumn
.
value
include_subscriptions
:
hasVisibleSubscriptionsColumn
.
value
},
},
...
@@ -1052,8 +1196,10 @@ const toggleBuiltInFilter = (key: string) => {
...
@@ -1052,8 +1196,10 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters
.
delete
(
key
)
visibleFilters
.
delete
(
key
)
if
(
key
===
'
role
'
)
filters
.
role
=
''
if
(
key
===
'
role
'
)
filters
.
role
=
''
if
(
key
===
'
status
'
)
filters
.
status
=
''
if
(
key
===
'
status
'
)
filters
.
status
=
''
if
(
key
===
'
group
'
)
filters
.
group
=
''
}
else
{
}
else
{
visibleFilters
.
add
(
key
)
visibleFilters
.
add
(
key
)
if
(
key
===
'
group
'
)
loadAllGroups
()
}
}
saveFiltersToStorage
()
saveFiltersToStorage
()
pagination
.
page
=
1
pagination
.
page
=
1
...
@@ -1129,6 +1275,19 @@ const closeAllowedGroupsModal = () => {
...
@@ -1129,6 +1275,19 @@ const closeAllowedGroupsModal = () => {
allowedGroupsUser
.
value
=
null
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
)
=>
{
const
handleDelete
=
(
user
:
AdminUser
)
=>
{
deletingUser
.
value
=
user
deletingUser
.
value
=
user
showDeleteDialog
.
value
=
true
showDeleteDialog
.
value
=
true
...
@@ -1199,6 +1358,9 @@ onMounted(async () => {
...
@@ -1199,6 +1358,9 @@ onMounted(async () => {
loadSavedFilters
()
loadSavedFilters
()
loadSavedColumns
()
loadSavedColumns
()
loadUsers
()
loadUsers
()
if
(
hasVisibleGroupsColumn
.
value
||
visibleFilters
.
has
(
'
group
'
))
{
loadAllGroups
()
}
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
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