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
106e59b7
Commit
106e59b7
authored
Jan 01, 2026
by
shaw
Browse files
Merge PR #122: feat: 用户自定义属性系统 + Wechat 字段迁移
parents
2c71c8b9
759291db
Changes
71
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/index.ts
View file @
106e59b7
...
@@ -15,6 +15,7 @@ import subscriptionsAPI from './subscriptions'
...
@@ -15,6 +15,7 @@ import subscriptionsAPI from './subscriptions'
import
usageAPI
from
'
./usage
'
import
usageAPI
from
'
./usage
'
import
geminiAPI
from
'
./gemini
'
import
geminiAPI
from
'
./gemini
'
import
antigravityAPI
from
'
./antigravity
'
import
antigravityAPI
from
'
./antigravity
'
import
userAttributesAPI
from
'
./userAttributes
'
/**
/**
* Unified admin API object for convenient access
* Unified admin API object for convenient access
...
@@ -31,7 +32,8 @@ export const adminAPI = {
...
@@ -31,7 +32,8 @@ export const adminAPI = {
subscriptions
:
subscriptionsAPI
,
subscriptions
:
subscriptionsAPI
,
usage
:
usageAPI
,
usage
:
usageAPI
,
gemini
:
geminiAPI
,
gemini
:
geminiAPI
,
antigravity
:
antigravityAPI
antigravity
:
antigravityAPI
,
userAttributes
:
userAttributesAPI
}
}
export
{
export
{
...
@@ -46,7 +48,8 @@ export {
...
@@ -46,7 +48,8 @@ export {
subscriptionsAPI
,
subscriptionsAPI
,
usageAPI
,
usageAPI
,
geminiAPI
,
geminiAPI
,
antigravityAPI
antigravityAPI
,
userAttributesAPI
}
}
export
default
adminAPI
export
default
adminAPI
frontend/src/api/admin/userAttributes.ts
0 → 100644
View file @
106e59b7
/**
* Admin User Attributes API endpoints
* Handles user custom attribute definitions and values
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
UserAttributeDefinition
,
UserAttributeValue
,
CreateUserAttributeRequest
,
UpdateUserAttributeRequest
,
UserAttributeValuesMap
}
from
'
@/types
'
/**
* Get all attribute definitions
*/
export
async
function
listDefinitions
():
Promise
<
UserAttributeDefinition
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserAttributeDefinition
[]
>
(
'
/admin/user-attributes
'
)
return
data
}
/**
* Get enabled attribute definitions only
*/
export
async
function
listEnabledDefinitions
():
Promise
<
UserAttributeDefinition
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserAttributeDefinition
[]
>
(
'
/admin/user-attributes
'
,
{
params
:
{
enabled
:
true
}
})
return
data
}
/**
* Create a new attribute definition
*/
export
async
function
createDefinition
(
request
:
CreateUserAttributeRequest
):
Promise
<
UserAttributeDefinition
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserAttributeDefinition
>
(
'
/admin/user-attributes
'
,
request
)
return
data
}
/**
* Update an attribute definition
*/
export
async
function
updateDefinition
(
id
:
number
,
request
:
UpdateUserAttributeRequest
):
Promise
<
UserAttributeDefinition
>
{
const
{
data
}
=
await
apiClient
.
put
<
UserAttributeDefinition
>
(
`/admin/user-attributes/
${
id
}
`
,
request
)
return
data
}
/**
* Delete an attribute definition
*/
export
async
function
deleteDefinition
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/user-attributes/
${
id
}
`
)
return
data
}
/**
* Reorder attribute definitions
*/
export
async
function
reorderDefinitions
(
ids
:
number
[]):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
put
<
{
message
:
string
}
>
(
'
/admin/user-attributes/reorder
'
,
{
ids
})
return
data
}
/**
* Get user's attribute values
*/
export
async
function
getUserAttributeValues
(
userId
:
number
):
Promise
<
UserAttributeValue
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserAttributeValue
[]
>
(
`/admin/users/
${
userId
}
/attributes`
)
return
data
}
/**
* Update user's attribute values (batch)
*/
export
async
function
updateUserAttributeValues
(
userId
:
number
,
values
:
UserAttributeValuesMap
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
put
<
{
message
:
string
}
>
(
`/admin/users/
${
userId
}
/attributes`
,
{
values
}
)
return
data
}
/**
* Batch response type
*/
export
interface
BatchUserAttributesResponse
{
attributes
:
Record
<
number
,
Record
<
number
,
string
>>
}
/**
* Get attribute values for multiple users
*/
export
async
function
getBatchUserAttributes
(
userIds
:
number
[]
):
Promise
<
BatchUserAttributesResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
BatchUserAttributesResponse
>
(
'
/admin/user-attributes/batch
'
,
{
user_ids
:
userIds
}
)
return
data
}
export
const
userAttributesAPI
=
{
listDefinitions
,
listEnabledDefinitions
,
createDefinition
,
updateDefinition
,
deleteDefinition
,
reorderDefinitions
,
getUserAttributeValues
,
updateUserAttributeValues
,
getBatchUserAttributes
}
export
default
userAttributesAPI
frontend/src/api/admin/users.ts
View file @
106e59b7
...
@@ -10,7 +10,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
...
@@ -10,7 +10,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
* List all users with pagination
* List all users with pagination
* @param page - Page number (default: 1)
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, role, search)
* @param filters - Optional filters (status, role, search
, attributes
)
* @param options - Optional request options (signal)
* @param options - Optional request options (signal)
* @returns Paginated list of users
* @returns Paginated list of users
*/
*/
...
@@ -21,17 +21,32 @@ export async function list(
...
@@ -21,17 +21,32 @@ export async function list(
status
?:
'
active
'
|
'
disabled
'
status
?:
'
active
'
|
'
disabled
'
role
?:
'
admin
'
|
'
user
'
role
?:
'
admin
'
|
'
user
'
search
?:
string
search
?:
string
attributes
?:
Record
<
number
,
string
>
// attributeId -> value
},
},
options
?:
{
options
?:
{
signal
?:
AbortSignal
signal
?:
AbortSignal
}
}
):
Promise
<
PaginatedResponse
<
User
>>
{
):
Promise
<
PaginatedResponse
<
User
>>
{
// Build params with attribute filters in attr[id]=value format
const
params
:
Record
<
string
,
any
>
=
{
page
,
page_size
:
pageSize
,
status
:
filters
?.
status
,
role
:
filters
?.
role
,
search
:
filters
?.
search
}
// Add attribute filters as attr[id]=value
if
(
filters
?.
attributes
)
{
for
(
const
[
attrId
,
value
]
of
Object
.
entries
(
filters
.
attributes
))
{
if
(
value
)
{
params
[
`attr[
${
attrId
}
]`
]
=
value
}
}
}
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
User
>>
(
'
/admin/users
'
,
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
User
>>
(
'
/admin/users
'
,
{
params
:
{
params
,
page
,
page_size
:
pageSize
,
...
filters
},
signal
:
options
?.
signal
signal
:
options
?.
signal
})
})
return
data
return
data
...
...
frontend/src/api/user.ts
View file @
106e59b7
...
@@ -22,7 +22,6 @@ export async function getProfile(): Promise<User> {
...
@@ -22,7 +22,6 @@ export async function getProfile(): Promise<User> {
*/
*/
export
async
function
updateProfile
(
profile
:
{
export
async
function
updateProfile
(
profile
:
{
username
?:
string
username
?:
string
wechat
?:
string
}):
Promise
<
User
>
{
}):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
'
/user
'
,
profile
)
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
'
/user
'
,
profile
)
return
data
return
data
...
...
frontend/src/components/user/UserAttributeForm.vue
0 → 100644
View file @
106e59b7
<
template
>
<div
v-if=
"attributes.length > 0"
class=
"space-y-4"
>
<div
v-for=
"attr in attributes"
:key=
"attr.id"
>
<label
class=
"input-label"
>
{{
attr
.
name
}}
<span
v-if=
"attr.required"
class=
"text-red-500"
>
*
</span>
</label>
<!-- Text Input -->
<input
v-if=
"attr.type === 'text' || attr.type === 'email' || attr.type === 'url'"
v-model=
"localValues[attr.id]"
:type=
"attr.type === 'text' ? 'text' : attr.type"
:required=
"attr.required"
:placeholder=
"attr.placeholder"
class=
"input"
@
input=
"emitChange"
/>
<!-- Number Input -->
<input
v-else-if=
"attr.type === 'number'"
v-model.number=
"localValues[attr.id]"
type=
"number"
:required=
"attr.required"
:placeholder=
"attr.placeholder"
:min=
"attr.validation?.min"
:max=
"attr.validation?.max"
class=
"input"
@
input=
"emitChange"
/>
<!-- Date Input -->
<input
v-else-if=
"attr.type === 'date'"
v-model=
"localValues[attr.id]"
type=
"date"
:required=
"attr.required"
class=
"input"
@
input=
"emitChange"
/>
<!-- Textarea -->
<textarea
v-else-if=
"attr.type === 'textarea'"
v-model=
"localValues[attr.id]"
:required=
"attr.required"
:placeholder=
"attr.placeholder"
rows=
"3"
class=
"input"
@
input=
"emitChange"
/>
<!-- Select -->
<select
v-else-if=
"attr.type === 'select'"
v-model=
"localValues[attr.id]"
:required=
"attr.required"
class=
"input"
@
change=
"emitChange"
>
<option
value=
""
>
{{
t
(
'
common.selectOption
'
)
}}
</option>
<option
v-for=
"opt in attr.options"
:key=
"opt.value"
:value=
"opt.value"
>
{{
opt
.
label
}}
</option>
</select>
<!-- Multi-Select (Checkboxes) -->
<div
v-else-if=
"attr.type === 'multi_select'"
class=
"space-y-2"
>
<label
v-for=
"opt in attr.options"
:key=
"opt.value"
class=
"flex items-center gap-2"
>
<input
type=
"checkbox"
:value=
"opt.value"
:checked=
"isOptionSelected(attr.id, opt.value)"
@
change=
"toggleMultiSelectOption(attr.id, opt.value)"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
opt
.
label
}}
</span>
</label>
</div>
<!-- Description -->
<p
v-if=
"attr.description"
class=
"input-hint"
>
{{
attr
.
description
}}
</p>
</div>
</div>
<!-- Loading State -->
<div
v-else-if=
"loading"
class=
"flex justify-center py-4"
>
<svg
class=
"h-5 w-5 animate-spin text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
/>
<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"
/>
</svg>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserAttributeDefinition
,
UserAttributeValuesMap
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
interface
Props
{
userId
?:
number
modelValue
:
UserAttributeValuesMap
}
interface
Emits
{
(
e
:
'
update:modelValue
'
,
value
:
UserAttributeValuesMap
):
void
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
loading
=
ref
(
false
)
const
attributes
=
ref
<
UserAttributeDefinition
[]
>
([])
const
localValues
=
ref
<
UserAttributeValuesMap
>
({})
const
loadAttributes
=
async
()
=>
{
loading
.
value
=
true
try
{
attributes
.
value
=
await
adminAPI
.
userAttributes
.
listEnabledDefinitions
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load attributes:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
const
loadUserValues
=
async
()
=>
{
if
(
!
props
.
userId
)
return
try
{
const
values
=
await
adminAPI
.
userAttributes
.
getUserAttributeValues
(
props
.
userId
)
const
valuesMap
:
UserAttributeValuesMap
=
{}
values
.
forEach
(
v
=>
{
valuesMap
[
v
.
attribute_id
]
=
v
.
value
})
localValues
.
value
=
{
...
valuesMap
}
emit
(
'
update:modelValue
'
,
localValues
.
value
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to load user attribute values:
'
,
error
)
}
}
const
emitChange
=
()
=>
{
emit
(
'
update:modelValue
'
,
{
...
localValues
.
value
})
}
const
isOptionSelected
=
(
attrId
:
number
,
optionValue
:
string
):
boolean
=>
{
const
value
=
localValues
.
value
[
attrId
]
if
(
!
value
)
return
false
try
{
const
arr
=
JSON
.
parse
(
value
)
return
Array
.
isArray
(
arr
)
&&
arr
.
includes
(
optionValue
)
}
catch
{
return
false
}
}
const
toggleMultiSelectOption
=
(
attrId
:
number
,
optionValue
:
string
)
=>
{
let
arr
:
string
[]
=
[]
const
value
=
localValues
.
value
[
attrId
]
if
(
value
)
{
try
{
arr
=
JSON
.
parse
(
value
)
if
(
!
Array
.
isArray
(
arr
))
arr
=
[]
}
catch
{
arr
=
[]
}
}
const
index
=
arr
.
indexOf
(
optionValue
)
if
(
index
>
-
1
)
{
arr
.
splice
(
index
,
1
)
}
else
{
arr
.
push
(
optionValue
)
}
localValues
.
value
[
attrId
]
=
JSON
.
stringify
(
arr
)
emitChange
()
}
watch
(()
=>
props
.
modelValue
,
(
newVal
)
=>
{
if
(
newVal
&&
Object
.
keys
(
newVal
).
length
>
0
)
{
localValues
.
value
=
{
...
newVal
}
}
},
{
immediate
:
true
})
watch
(()
=>
props
.
userId
,
(
newUserId
)
=>
{
if
(
newUserId
)
{
loadUserValues
()
}
else
{
// Reset for new user
localValues
.
value
=
{}
}
},
{
immediate
:
true
})
onMounted
(()
=>
{
loadAttributes
()
})
</
script
>
frontend/src/components/user/UserAttributesConfigModal.vue
0 → 100644
View file @
106e59b7
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.attributes.title')"
width=
"wide"
@
close=
"emit('close')"
>
<div
class=
"space-y-4"
>
<!-- Header with Add Button -->
<div
class=
"flex items-center justify-between"
>
<p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.users.attributes.description
'
)
}}
</p>
<button
@
click=
"openCreateModal"
class=
"btn btn-primary btn-sm"
>
<svg
class=
"mr-1.5 h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.users.attributes.addAttribute
'
)
}}
</button>
</div>
<!-- Loading State -->
<div
v-if=
"loading"
class=
"flex justify-center py-12"
>
<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"
/>
<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"
/>
</svg>
</div>
<!-- Empty State -->
<div
v-else-if=
"attributes.length === 0"
class=
"py-12 text-center"
>
<svg
class=
"mx-auto h-12 w-12 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 6h.008v.008H6V6z"
/>
</svg>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.users.attributes.noAttributes
'
)
}}
</p>
<p
class=
"text-xs text-gray-400 dark:text-dark-500"
>
{{
t
(
'
admin.users.attributes.noAttributesHint
'
)
}}
</p>
</div>
<!-- Attributes List -->
<div
v-else
class=
"max-h-96 space-y-2 overflow-y-auto"
>
<div
v-for=
"attr in attributes"
:key=
"attr.id"
class=
"flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 dark:border-dark-600 dark:bg-dark-800"
>
<!-- Drag Handle -->
<div
class=
"cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:title=
"t('admin.users.attributes.dragToReorder')"
>
<svg
class=
"h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</div>
<!-- Attribute Info -->
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
attr
.
name
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs text-gray-500 dark:bg-dark-700 dark:text-dark-400"
>
{{
attr
.
key
}}
</span>
<span
v-if=
"attr.required"
class=
"badge badge-danger text-xs"
>
{{
t
(
'
admin.users.attributes.required
'
)
}}
</span>
<span
v-if=
"!attr.enabled"
class=
"badge badge-gray text-xs"
>
{{
t
(
'
common.disabled
'
)
}}
</span>
</div>
<div
class=
"mt-0.5 flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400"
>
<span
class=
"badge badge-gray"
>
{{
t
(
`admin.users.attributes.types.${attr.type
}
`
)
}}
<
/span
>
<
span
v
-
if
=
"
attr.description
"
class
=
"
truncate
"
>
{{
attr
.
description
}}
<
/span
>
<
/div
>
<
/div
>
<!--
Actions
-->
<
div
class
=
"
flex items-center gap-1
"
>
<
button
@
click
=
"
openEditModal(attr)
"
class
=
"
rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
:
title
=
"
t('common.edit')
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
/>
<
/svg
>
<
/button
>
<
button
@
click
=
"
confirmDelete(attr)
"
class
=
"
rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('common.delete')
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
<
/svg
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end
"
>
<
button
@
click
=
"
emit('close')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/div
>
<
/template
>
<
/BaseDialog
>
<!--
Create
/
Edit
Attribute
Modal
-->
<
BaseDialog
:
show
=
"
showEditModal
"
:
title
=
"
editingAttribute ? t('admin.users.attributes.editAttribute') : t('admin.users.attributes.addAttribute')
"
width
=
"
normal
"
@
close
=
"
closeEditModal
"
>
<
form
id
=
"
attribute-form
"
@
submit
.
prevent
=
"
handleSave
"
class
=
"
space-y-4
"
>
<!--
Key
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.key
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.key
"
type
=
"
text
"
required
pattern
=
"
^[a-zA-Z][a-zA-Z0-9_]*$
"
class
=
"
input font-mono
"
:
placeholder
=
"
t('admin.users.attributes.keyHint')
"
:
disabled
=
"
!!editingAttribute
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.users.attributes.keyHint
'
)
}}
<
/p
>
<
/div
>
<!--
Name
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.name
"
type
=
"
text
"
required
class
=
"
input
"
:
placeholder
=
"
t('admin.users.attributes.nameHint')
"
/>
<
/div
>
<!--
Type
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.type
'
)
}}
<
/label
>
<
select
v
-
model
=
"
form.type
"
class
=
"
input
"
required
>
<
option
v
-
for
=
"
type in attributeTypes
"
:
key
=
"
type
"
:
value
=
"
type
"
>
{{
t
(
`admin.users.attributes.types.${type
}
`
)
}}
<
/option
>
<
/select
>
<
/div
>
<!--
Options
(
for
select
/
multi_select
)
-->
<
div
v
-
if
=
"
form.type === 'select' || form.type === 'multi_select'
"
class
=
"
space-y-2
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.options
'
)
}}
<
/label
>
<
div
v
-
for
=
"
(option, index) in form.options
"
:
key
=
"
index
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
option.value
"
type
=
"
text
"
class
=
"
input flex-1 font-mono text-sm
"
:
placeholder
=
"
t('admin.users.attributes.optionValue')
"
required
/>
<
input
v
-
model
=
"
option.label
"
type
=
"
text
"
class
=
"
input flex-1 text-sm
"
:
placeholder
=
"
t('admin.users.attributes.optionLabel')
"
required
/>
<
button
type
=
"
button
"
@
click
=
"
removeOption(index)
"
class
=
"
rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M6 18L18 6M6 6l12 12
"
/>
<
/svg
>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
addOption
"
class
=
"
btn btn-secondary btn-sm
"
>
<
svg
class
=
"
mr-1 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 4.5v15m7.5-7.5h-15
"
/>
<
/svg
>
{{
t
(
'
admin.users.attributes.addOption
'
)
}}
<
/button
>
<
/div
>
<!--
Description
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.fieldDescription
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.description
"
type
=
"
text
"
class
=
"
input
"
:
placeholder
=
"
t('admin.users.attributes.fieldDescriptionHint')
"
/>
<
/div
>
<!--
Placeholder
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.placeholder
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.placeholder
"
type
=
"
text
"
class
=
"
input
"
:
placeholder
=
"
t('admin.users.attributes.placeholderHint')
"
/>
<
/div
>
<!--
Required
&
Enabled
-->
<
div
class
=
"
flex items-center gap-6
"
>
<
label
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
form.required
"
type
=
"
checkbox
"
class
=
"
h-4 w-4 rounded border-gray-300 text-primary-600
"
/>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.users.attributes.required
'
)
}}
<
/span
>
<
/label
>
<
label
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
form.enabled
"
type
=
"
checkbox
"
class
=
"
h-4 w-4 rounded border-gray-300 text-primary-600
"
/>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.users.attributes.enabled
'
)
}}
<
/span
>
<
/label
>
<
/div
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
closeEditModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
type
=
"
submit
"
form
=
"
attribute-form
"
:
disabled
=
"
saving
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
saving
"
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
"
/>
<
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
"
/>
<
/svg
>
{{
saving
?
t
(
'
common.saving
'
)
:
(
editingAttribute
?
t
(
'
common.update
'
)
:
t
(
'
common.create
'
))
}}
<
/button
>
<
/div
>
<
/template
>
<
/BaseDialog
>
<!--
Delete
Confirmation
-->
<
ConfirmDialog
:
show
=
"
showDeleteDialog
"
:
title
=
"
t('admin.users.attributes.deleteAttribute')
"
:
message
=
"
t('admin.users.attributes.deleteConfirm', { name: deletingAttribute?.name
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
handleDelete
"
@
cancel
=
"
showDeleteDialog = false
"
/>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserAttributeDefinition
,
UserAttributeType
,
UserAttributeOption
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
interface
Props
{
show
:
boolean
}
interface
Emits
{
(
e
:
'
close
'
):
void
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
attributeTypes
:
UserAttributeType
[]
=
[
'
text
'
,
'
textarea
'
,
'
number
'
,
'
email
'
,
'
url
'
,
'
date
'
,
'
select
'
,
'
multi_select
'
]
const
loading
=
ref
(
false
)
const
saving
=
ref
(
false
)
const
attributes
=
ref
<
UserAttributeDefinition
[]
>
([])
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
editingAttribute
=
ref
<
UserAttributeDefinition
|
null
>
(
null
)
const
deletingAttribute
=
ref
<
UserAttributeDefinition
|
null
>
(
null
)
const
form
=
reactive
({
key
:
''
,
name
:
''
,
type
:
'
text
'
as
UserAttributeType
,
description
:
''
,
placeholder
:
''
,
required
:
false
,
enabled
:
true
,
options
:
[]
as
UserAttributeOption
[]
}
)
const
loadAttributes
=
async
()
=>
{
loading
.
value
=
true
try
{
attributes
.
value
=
await
adminAPI
.
userAttributes
.
listDefinitions
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.attributes.failedToLoad
'
))
}
finally
{
loading
.
value
=
false
}
}
const
openCreateModal
=
()
=>
{
editingAttribute
.
value
=
null
form
.
key
=
''
form
.
name
=
''
form
.
type
=
'
text
'
form
.
description
=
''
form
.
placeholder
=
''
form
.
required
=
false
form
.
enabled
=
true
form
.
options
=
[]
showEditModal
.
value
=
true
}
const
openEditModal
=
(
attr
:
UserAttributeDefinition
)
=>
{
editingAttribute
.
value
=
attr
form
.
key
=
attr
.
key
form
.
name
=
attr
.
name
form
.
type
=
attr
.
type
form
.
description
=
attr
.
description
||
''
form
.
placeholder
=
attr
.
placeholder
||
''
form
.
required
=
attr
.
required
form
.
enabled
=
attr
.
enabled
form
.
options
=
attr
.
options
?
[...
attr
.
options
]
:
[]
showEditModal
.
value
=
true
}
const
closeEditModal
=
()
=>
{
showEditModal
.
value
=
false
editingAttribute
.
value
=
null
}
const
addOption
=
()
=>
{
form
.
options
.
push
({
value
:
''
,
label
:
''
}
)
}
const
removeOption
=
(
index
:
number
)
=>
{
form
.
options
.
splice
(
index
,
1
)
}
const
handleSave
=
async
()
=>
{
saving
.
value
=
true
try
{
const
data
=
{
key
:
form
.
key
,
name
:
form
.
name
,
type
:
form
.
type
,
description
:
form
.
description
||
undefined
,
placeholder
:
form
.
placeholder
||
undefined
,
required
:
form
.
required
,
enabled
:
form
.
enabled
,
options
:
(
form
.
type
===
'
select
'
||
form
.
type
===
'
multi_select
'
)
?
form
.
options
:
undefined
}
if
(
editingAttribute
.
value
)
{
await
adminAPI
.
userAttributes
.
updateDefinition
(
editingAttribute
.
value
.
id
,
data
)
appStore
.
showSuccess
(
t
(
'
admin.users.attributes.updated
'
))
}
else
{
await
adminAPI
.
userAttributes
.
createDefinition
(
data
)
appStore
.
showSuccess
(
t
(
'
admin.users.attributes.created
'
))
}
closeEditModal
()
loadAttributes
()
}
catch
(
error
:
any
)
{
const
msg
=
editingAttribute
.
value
?
t
(
'
admin.users.attributes.failedToUpdate
'
)
:
t
(
'
admin.users.attributes.failedToCreate
'
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
msg
)
}
finally
{
saving
.
value
=
false
}
}
const
confirmDelete
=
(
attr
:
UserAttributeDefinition
)
=>
{
deletingAttribute
.
value
=
attr
showDeleteDialog
.
value
=
true
}
const
handleDelete
=
async
()
=>
{
if
(
!
deletingAttribute
.
value
)
return
try
{
await
adminAPI
.
userAttributes
.
deleteDefinition
(
deletingAttribute
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.users.attributes.deleted
'
))
showDeleteDialog
.
value
=
false
deletingAttribute
.
value
=
null
loadAttributes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.attributes.failedToDelete
'
))
}
}
watch
(()
=>
props
.
show
,
(
isShow
)
=>
{
if
(
isShow
)
{
loadAttributes
()
}
}
)
<
/script
>
frontend/src/i18n/locales/en.ts
View file @
106e59b7
...
@@ -434,9 +434,7 @@ export default {
...
@@ -434,9 +434,7 @@ export default {
administrator
:
'
Administrator
'
,
administrator
:
'
Administrator
'
,
user
:
'
User
'
,
user
:
'
User
'
,
username
:
'
Username
'
,
username
:
'
Username
'
,
wechat
:
'
WeChat ID
'
,
enterUsername
:
'
Enter username
'
,
enterUsername
:
'
Enter username
'
,
enterWechat
:
'
Enter WeChat ID
'
,
editProfile
:
'
Edit Profile
'
,
editProfile
:
'
Edit Profile
'
,
updateProfile
:
'
Update Profile
'
,
updateProfile
:
'
Update Profile
'
,
updating
:
'
Updating...
'
,
updating
:
'
Updating...
'
,
...
@@ -565,12 +563,10 @@ export default {
...
@@ -565,12 +563,10 @@ export default {
email
:
'
Email
'
,
email
:
'
Email
'
,
password
:
'
Password
'
,
password
:
'
Password
'
,
username
:
'
Username
'
,
username
:
'
Username
'
,
wechat
:
'
WeChat ID
'
,
notes
:
'
Notes
'
,
notes
:
'
Notes
'
,
enterEmail
:
'
Enter email
'
,
enterEmail
:
'
Enter email
'
,
enterPassword
:
'
Enter password
'
,
enterPassword
:
'
Enter password
'
,
enterUsername
:
'
Enter username (optional)
'
,
enterUsername
:
'
Enter username (optional)
'
,
enterWechat
:
'
Enter WeChat ID (optional)
'
,
enterNotes
:
'
Enter notes (admin only)
'
,
enterNotes
:
'
Enter notes (admin only)
'
,
notesHint
:
'
This note is only visible to administrators
'
,
notesHint
:
'
This note is only visible to administrators
'
,
enterNewPassword
:
'
Enter new password (optional)
'
,
enterNewPassword
:
'
Enter new password (optional)
'
,
...
@@ -582,7 +578,6 @@ export default {
...
@@ -582,7 +578,6 @@ export default {
columns
:
{
columns
:
{
user
:
'
User
'
,
user
:
'
User
'
,
username
:
'
Username
'
,
username
:
'
Username
'
,
wechat
:
'
WeChat ID
'
,
notes
:
'
Notes
'
,
notes
:
'
Notes
'
,
role
:
'
Role
'
,
role
:
'
Role
'
,
subscriptions
:
'
Subscriptions
'
,
subscriptions
:
'
Subscriptions
'
,
...
@@ -653,7 +648,67 @@ export default {
...
@@ -653,7 +648,67 @@ export default {
failedToDeposit
:
'
Failed to deposit
'
,
failedToDeposit
:
'
Failed to deposit
'
,
failedToWithdraw
:
'
Failed to withdraw
'
,
failedToWithdraw
:
'
Failed to withdraw
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
insufficientBalance
:
'
Insufficient balance, balance cannot be negative after withdrawal
'
insufficientBalance
:
'
Insufficient balance, balance cannot be negative after withdrawal
'
,
// Settings Dropdowns
filterSettings
:
'
Filter Settings
'
,
columnSettings
:
'
Column Settings
'
,
filterValue
:
'
Enter value
'
,
// User Attributes
attributes
:
{
title
:
'
User Attributes
'
,
description
:
'
Configure custom user attribute fields
'
,
configButton
:
'
Attributes
'
,
addAttribute
:
'
Add Attribute
'
,
editAttribute
:
'
Edit Attribute
'
,
deleteAttribute
:
'
Delete Attribute
'
,
deleteConfirm
:
"
Are you sure you want to delete attribute '{name}'? All user values for this attribute will be deleted.
"
,
noAttributes
:
'
No custom attributes
'
,
noAttributesHint
:
'
Click the button above to add custom attributes
'
,
key
:
'
Attribute Key
'
,
keyHint
:
'
For programmatic reference, only letters, numbers and underscores
'
,
name
:
'
Display Name
'
,
nameHint
:
'
Name shown in forms
'
,
type
:
'
Attribute Type
'
,
fieldDescription
:
'
Description
'
,
fieldDescriptionHint
:
'
Description text for the attribute
'
,
placeholder
:
'
Placeholder
'
,
placeholderHint
:
'
Placeholder text for input field
'
,
required
:
'
Required
'
,
enabled
:
'
Enabled
'
,
options
:
'
Options
'
,
optionsHint
:
'
For select/multi-select types
'
,
addOption
:
'
Add Option
'
,
optionValue
:
'
Option Value
'
,
optionLabel
:
'
Display Text
'
,
validation
:
'
Validation Rules
'
,
minLength
:
'
Min Length
'
,
maxLength
:
'
Max Length
'
,
min
:
'
Min Value
'
,
max
:
'
Max Value
'
,
pattern
:
'
Regex Pattern
'
,
patternMessage
:
'
Validation Error Message
'
,
types
:
{
text
:
'
Text
'
,
textarea
:
'
Textarea
'
,
number
:
'
Number
'
,
email
:
'
Email
'
,
url
:
'
URL
'
,
date
:
'
Date
'
,
select
:
'
Select
'
,
multi_select
:
'
Multi-Select
'
},
created
:
'
Attribute created successfully
'
,
updated
:
'
Attribute updated successfully
'
,
deleted
:
'
Attribute deleted successfully
'
,
reordered
:
'
Attribute order updated successfully
'
,
failedToLoad
:
'
Failed to load attributes
'
,
failedToCreate
:
'
Failed to create attribute
'
,
failedToUpdate
:
'
Failed to update attribute
'
,
failedToDelete
:
'
Failed to delete attribute
'
,
failedToReorder
:
'
Failed to update order
'
,
keyExists
:
'
Attribute key already exists
'
,
dragToReorder
:
'
Drag to reorder
'
}
},
},
// Groups
// Groups
...
...
frontend/src/i18n/locales/zh.ts
View file @
106e59b7
...
@@ -430,9 +430,7 @@ export default {
...
@@ -430,9 +430,7 @@ export default {
administrator
:
'
管理员
'
,
administrator
:
'
管理员
'
,
user
:
'
用户
'
,
user
:
'
用户
'
,
username
:
'
用户名
'
,
username
:
'
用户名
'
,
wechat
:
'
微信号
'
,
enterUsername
:
'
输入用户名
'
,
enterUsername
:
'
输入用户名
'
,
enterWechat
:
'
输入微信号
'
,
editProfile
:
'
编辑个人资料
'
,
editProfile
:
'
编辑个人资料
'
,
updateProfile
:
'
更新资料
'
,
updateProfile
:
'
更新资料
'
,
updating
:
'
更新中...
'
,
updating
:
'
更新中...
'
,
...
@@ -583,12 +581,10 @@ export default {
...
@@ -583,12 +581,10 @@ export default {
email
:
'
邮箱
'
,
email
:
'
邮箱
'
,
password
:
'
密码
'
,
password
:
'
密码
'
,
username
:
'
用户名
'
,
username
:
'
用户名
'
,
wechat
:
'
微信号
'
,
notes
:
'
备注
'
,
notes
:
'
备注
'
,
enterEmail
:
'
请输入邮箱
'
,
enterEmail
:
'
请输入邮箱
'
,
enterPassword
:
'
请输入密码
'
,
enterPassword
:
'
请输入密码
'
,
enterUsername
:
'
请输入用户名(选填)
'
,
enterUsername
:
'
请输入用户名(选填)
'
,
enterWechat
:
'
请输入微信号(选填)
'
,
enterNotes
:
'
请输入备注(仅管理员可见)
'
,
enterNotes
:
'
请输入备注(仅管理员可见)
'
,
notesHint
:
'
此备注仅对管理员可见
'
,
notesHint
:
'
此备注仅对管理员可见
'
,
enterNewPassword
:
'
请输入新密码(选填)
'
,
enterNewPassword
:
'
请输入新密码(选填)
'
,
...
@@ -601,7 +597,6 @@ export default {
...
@@ -601,7 +597,6 @@ export default {
user
:
'
用户
'
,
user
:
'
用户
'
,
email
:
'
邮箱
'
,
email
:
'
邮箱
'
,
username
:
'
用户名
'
,
username
:
'
用户名
'
,
wechat
:
'
微信号
'
,
notes
:
'
备注
'
,
notes
:
'
备注
'
,
role
:
'
角色
'
,
role
:
'
角色
'
,
subscriptions
:
'
订阅分组
'
,
subscriptions
:
'
订阅分组
'
,
...
@@ -655,8 +650,6 @@ export default {
...
@@ -655,8 +650,6 @@ export default {
emailPlaceholder
:
'
请输入邮箱
'
,
emailPlaceholder
:
'
请输入邮箱
'
,
usernameLabel
:
'
用户名
'
,
usernameLabel
:
'
用户名
'
,
usernamePlaceholder
:
'
请输入用户名(选填)
'
,
usernamePlaceholder
:
'
请输入用户名(选填)
'
,
wechatLabel
:
'
微信号
'
,
wechatPlaceholder
:
'
请输入微信号(选填)
'
,
notesLabel
:
'
备注
'
,
notesLabel
:
'
备注
'
,
notesPlaceholder
:
'
请输入备注(仅管理员可见)
'
,
notesPlaceholder
:
'
请输入备注(仅管理员可见)
'
,
notesHint
:
'
此备注仅对管理员可见
'
,
notesHint
:
'
此备注仅对管理员可见
'
,
...
@@ -711,7 +704,67 @@ export default {
...
@@ -711,7 +704,67 @@ export default {
failedToDeposit
:
'
充值失败
'
,
failedToDeposit
:
'
充值失败
'
,
failedToWithdraw
:
'
退款失败
'
,
failedToWithdraw
:
'
退款失败
'
,
useDepositWithdrawButtons
:
'
请使用充值/退款按钮调整余额
'
,
useDepositWithdrawButtons
:
'
请使用充值/退款按钮调整余额
'
,
insufficientBalance
:
'
余额不足,退款后余额不能为负数
'
insufficientBalance
:
'
余额不足,退款后余额不能为负数
'
,
// Settings Dropdowns
filterSettings
:
'
筛选设置
'
,
columnSettings
:
'
列设置
'
,
filterValue
:
'
输入值
'
,
// User Attributes
attributes
:
{
title
:
'
用户属性配置
'
,
description
:
'
配置用户的自定义属性字段
'
,
configButton
:
'
属性配置
'
,
addAttribute
:
'
添加属性
'
,
editAttribute
:
'
编辑属性
'
,
deleteAttribute
:
'
删除属性
'
,
deleteConfirm
:
"
确定要删除属性 '{name}' 吗?所有用户的该属性值将被删除。
"
,
noAttributes
:
'
暂无自定义属性
'
,
noAttributesHint
:
'
点击上方按钮添加自定义属性
'
,
key
:
'
属性键
'
,
keyHint
:
'
用于程序引用,只能包含字母、数字和下划线
'
,
name
:
'
显示名称
'
,
nameHint
:
'
在表单中显示的名称
'
,
type
:
'
属性类型
'
,
fieldDescription
:
'
描述
'
,
fieldDescriptionHint
:
'
属性的说明文字
'
,
placeholder
:
'
占位符
'
,
placeholderHint
:
'
输入框的提示文字
'
,
required
:
'
必填
'
,
enabled
:
'
启用
'
,
options
:
'
选项配置
'
,
optionsHint
:
'
用于单选/多选类型
'
,
addOption
:
'
添加选项
'
,
optionValue
:
'
选项值
'
,
optionLabel
:
'
显示文本
'
,
validation
:
'
验证规则
'
,
minLength
:
'
最小长度
'
,
maxLength
:
'
最大长度
'
,
min
:
'
最小值
'
,
max
:
'
最大值
'
,
pattern
:
'
正则表达式
'
,
patternMessage
:
'
验证失败提示
'
,
types
:
{
text
:
'
单行文本
'
,
textarea
:
'
多行文本
'
,
number
:
'
数字
'
,
email
:
'
邮箱
'
,
url
:
'
链接
'
,
date
:
'
日期
'
,
select
:
'
单选
'
,
multi_select
:
'
多选
'
},
created
:
'
属性创建成功
'
,
updated
:
'
属性更新成功
'
,
deleted
:
'
属性删除成功
'
,
reordered
:
'
属性排序更新成功
'
,
failedToLoad
:
'
加载属性列表失败
'
,
failedToCreate
:
'
创建属性失败
'
,
failedToUpdate
:
'
更新属性失败
'
,
failedToDelete
:
'
删除属性失败
'
,
failedToReorder
:
'
更新排序失败
'
,
keyExists
:
'
属性键已存在
'
,
dragToReorder
:
'
拖拽排序
'
}
},
},
// Groups Management
// Groups Management
...
...
frontend/src/types/index.ts
View file @
106e59b7
...
@@ -7,7 +7,6 @@
...
@@ -7,7 +7,6 @@
export
interface
User
{
export
interface
User
{
id
:
number
id
:
number
username
:
string
username
:
string
wechat
:
string
notes
:
string
notes
:
string
email
:
string
email
:
string
role
:
'
admin
'
|
'
user
'
// User role for authorization
role
:
'
admin
'
|
'
user
'
// User role for authorization
...
@@ -634,7 +633,6 @@ export interface UpdateUserRequest {
...
@@ -634,7 +633,6 @@ export interface UpdateUserRequest {
email
?:
string
email
?:
string
password
?:
string
password
?:
string
username
?:
string
username
?:
string
wechat
?:
string
notes
?:
string
notes
?:
string
role
?:
'
admin
'
|
'
user
'
role
?:
'
admin
'
|
'
user
'
balance
?:
number
balance
?:
number
...
@@ -771,3 +769,76 @@ export interface AccountUsageStatsResponse {
...
@@ -771,3 +769,76 @@ export interface AccountUsageStatsResponse {
summary
:
AccountUsageSummary
summary
:
AccountUsageSummary
models
:
ModelStat
[]
models
:
ModelStat
[]
}
}
// ==================== User Attribute Types ====================
export
type
UserAttributeType
=
'
text
'
|
'
textarea
'
|
'
number
'
|
'
email
'
|
'
url
'
|
'
date
'
|
'
select
'
|
'
multi_select
'
export
interface
UserAttributeOption
{
value
:
string
label
:
string
}
export
interface
UserAttributeValidation
{
min_length
?:
number
max_length
?:
number
min
?:
number
max
?:
number
pattern
?:
string
message
?:
string
}
export
interface
UserAttributeDefinition
{
id
:
number
key
:
string
name
:
string
description
:
string
type
:
UserAttributeType
options
:
UserAttributeOption
[]
required
:
boolean
validation
:
UserAttributeValidation
placeholder
:
string
display_order
:
number
enabled
:
boolean
created_at
:
string
updated_at
:
string
}
export
interface
UserAttributeValue
{
id
:
number
user_id
:
number
attribute_id
:
number
value
:
string
created_at
:
string
updated_at
:
string
}
export
interface
CreateUserAttributeRequest
{
key
:
string
name
:
string
description
?:
string
type
:
UserAttributeType
options
?:
UserAttributeOption
[]
required
?:
boolean
validation
?:
UserAttributeValidation
placeholder
?:
string
display_order
?:
number
enabled
?:
boolean
}
export
interface
UpdateUserAttributeRequest
{
key
?:
string
name
?:
string
description
?:
string
type
?:
UserAttributeType
options
?:
UserAttributeOption
[]
required
?:
boolean
validation
?:
UserAttributeValidation
placeholder
?:
string
display_order
?:
number
enabled
?:
boolean
}
export
interface
UserAttributeValuesMap
{
[
attributeId
:
number
]:
string
}
frontend/src/views/admin/UsersView.vue
View file @
106e59b7
<
template
>
<
template
>
<AppLayout>
<AppLayout>
<TablePageLayout>
<TablePageLayout>
<!-- Page Header Actions -->
<!-- Single Row: Search, Filters, and Actions -->
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadUsers"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.users.createUser
'
)
}}
</button>
</div>
</
template
>
<!-- Search and Filters -->
<template
#filters
>
<template
#filters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<!-- Left: Search + Active Filters -->
<svg
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
<!-- Search Box -->
fill=
"none"
<div
class=
"relative w-64"
>
stroke=
"currentColor"
<svg
viewBox=
"0 0 24 24"
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
stroke-width=
"1.5"
fill=
"none"
>
stroke=
"currentColor"
<path
viewBox=
"0 0 24 24"
stroke-linecap=
"round"
stroke-width=
"1.5"
stroke-linejoin=
"round"
>
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
<path
/>
stroke-linecap=
"round"
</svg>
stroke-linejoin=
"round"
<input
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
v-model=
"searchQuery"
/>
type=
"text"
</svg>
:placeholder=
"t('admin.users.searchUsers')"
<input
class=
"input pl-10"
v-model=
"searchQuery"
@
input=
"handleSearch"
type=
"text"
/>
:placeholder=
"t('admin.users.searchUsers')"
</div>
class=
"input pl-10"
<div
class=
"flex flex-wrap gap-3"
>
@
input=
"handleSearch"
<Select
/>
v-model=
"filters.role"
</div>
:options=
"roleOptions"
:placeholder=
"t('admin.users.allRoles')"
<!-- Role Filter (visible when enabled) -->
class=
"w-36"
<div
v-if=
"visibleFilters.has('role')"
class=
"relative"
>
@
change=
"loadUsers"
<select
/>
v-model=
"filters.role"
<Select
@
change=
"applyFilter"
v-model=
"filters.status"
class=
"input w-32 cursor-pointer appearance-none pr-8"
:options=
"statusOptions"
>
:placeholder=
"t('admin.users.allStatus')"
<option
value=
""
>
{{
t
(
'
admin.users.allRoles
'
)
}}
</option>
class=
"w-36"
<option
value=
"admin"
>
{{
t
(
'
admin.users.admin
'
)
}}
</option>
@
change=
"loadUsers"
<option
value=
"user"
>
{{
t
(
'
admin.users.user
'
)
}}
</option>
/>
</select>
<svg
class=
"pointer-events-none absolute right-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=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</div>
<!-- Status Filter (visible when enabled) -->
<div
v-if=
"visibleFilters.has('status')"
class=
"relative"
>
<select
v-model=
"filters.status"
@
change=
"applyFilter"
class=
"input w-32 cursor-pointer appearance-none pr-8"
>
<option
value=
""
>
{{
t
(
'
admin.users.allStatus
'
)
}}
</option>
<option
value=
"active"
>
{{
t
(
'
common.active
'
)
}}
</option>
<option
value=
"disabled"
>
{{
t
(
'
admin.users.disabled
'
)
}}
</option>
</select>
<svg
class=
"pointer-events-none absolute right-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=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</div>
<!-- Dynamic Attribute Filters -->
<template
v-for=
"(value, attrId) in activeAttributeFilters"
:key=
"attrId"
>
<div
v-if=
"visibleFilters.has(`attr_$
{attrId}`)" class="relative">
<!-- Text/Email/URL/Textarea/Date type: styled input -->
<input
v-if=
"['text', 'textarea', 'email', 'url', 'date'].includes(getAttributeDefinition(Number(attrId))?.type || 'text')"
:value=
"value"
@
input=
"(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@
keyup.enter=
"applyFilter"
:placeholder=
"getAttributeDefinitionName(Number(attrId))"
class=
"input w-36"
/>
<!-- Number type: number input -->
<input
v-else-if=
"getAttributeDefinition(Number(attrId))?.type === 'number'"
:value=
"value"
type=
"number"
@
input=
"(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@
keyup.enter=
"applyFilter"
:placeholder=
"getAttributeDefinitionName(Number(attrId))"
class=
"input w-32"
/>
<!-- Select/Multi-select type -->
<template
v-else-if=
"['select', 'multi_select'].includes(getAttributeDefinition(Number(attrId))?.type || '')"
>
<select
:value=
"value"
@
change=
"(e) =>
{ updateAttributeFilter(Number(attrId), (e.target as HTMLSelectElement).value); applyFilter() }"
class="input w-36 cursor-pointer appearance-none pr-8"
>
<option
value=
""
>
{{
getAttributeDefinitionName
(
Number
(
attrId
))
}}
</option>
<option
v-for=
"opt in getAttributeDefinition(Number(attrId))?.options || []"
:key=
"opt.value"
:value=
"opt.value"
>
{{
opt
.
label
}}
</option>
</select>
<svg
class=
"pointer-events-none absolute right-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=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</
template
>
<!-- Fallback -->
<input
v-else
:value=
"value"
@
input=
"(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@
keyup.enter=
"applyFilter"
:placeholder=
"getAttributeDefinitionName(Number(attrId))"
class=
"input w-36"
/>
</div>
</template>
</div>
<!-- Right: Actions and Settings -->
<div
class=
"flex items-center gap-3"
>
<!-- Refresh Button -->
<button
@
click=
"loadUsers"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<!-- Filter Settings Dropdown -->
<div
class=
"relative"
ref=
"filterDropdownRef"
>
<button
@
click=
"showFilterDropdown = !showFilterDropdown"
class=
"btn btn-secondary"
>
<svg
class=
"mr-1.5 h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"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"
/>
</svg>
{{ t('admin.users.filterSettings') }}
</button>
<!-- Dropdown menu -->
<div
v-if=
"showFilterDropdown"
class=
"absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<!-- Built-in filters -->
<button
v-for=
"filter in builtInFilters"
:key=
"filter.key"
@
click=
"toggleBuiltInFilter(filter.key)"
class=
"flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>
{{ filter.name }}
</span>
<svg
v-if=
"visibleFilters.has(filter.key)"
class=
"h-4 w-4 text-primary-500"
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>
<!-- Divider if custom attributes exist -->
<div
v-if=
"filterableAttributes.length > 0"
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Custom attribute filters -->
<button
v-for=
"attr in filterableAttributes"
:key=
"attr.id"
@
click=
"toggleAttributeFilter(attr)"
class=
"flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>
{{ attr.name }}
</span>
<svg
v-if=
"visibleFilters.has(`attr_${attr.id}`)"
class=
"h-4 w-4 text-primary-500"
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>
</div>
</div>
<!-- Column Settings Dropdown -->
<div
class=
"relative"
ref=
"columnDropdownRef"
>
<button
@
click=
"showColumnDropdown = !showColumnDropdown"
class=
"btn btn-secondary"
>
<svg
class=
"mr-1.5 h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
{{ t('admin.users.columnSettings') }}
</button>
<!-- Dropdown menu -->
<div
v-if=
"showColumnDropdown"
class=
"absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for=
"col in toggleableColumns"
:key=
"col.key"
@
click=
"toggleColumn(col.key)"
class=
"flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>
{{ col.label }}
</span>
<svg
v-if=
"isColumnVisible(col.key)"
class=
"h-4 w-4 text-primary-500"
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>
</div>
</div>
<!-- Attributes Config Button -->
<button
@
click=
"showAttributesModal = true"
class=
"btn btn-secondary"
>
<svg
class=
"mr-1.5 h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{{ t('admin.users.attributes.configButton') }}
</button>
<!-- Create User Button -->
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{ t('admin.users.createUser') }}
</button>
</div>
</div>
</div>
</div>
</template>
</template>
<!-- Users Table -->
<!-- Users Table -->
...
@@ -103,10 +306,6 @@
...
@@ -103,10 +306,6 @@
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
||
'
-
'
}}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
||
'
-
'
}}
</span>
</
template
>
</
template
>
<
template
#cell-wechat=
"{ value }"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
||
'
-
'
}}
</span>
</
template
>
<
template
#cell-notes=
"{ value }"
>
<
template
#cell-notes=
"{ value }"
>
<div
class=
"max-w-xs"
>
<div
class=
"max-w-xs"
>
<span
<span
...
@@ -120,6 +319,22 @@
...
@@ -120,6 +319,22 @@
</div>
</div>
</
template
>
</
template
>
<!-- Dynamic attribute columns -->
<
template
v-for=
"def in attributeDefinitions.filter(d => d.enabled)"
:key=
"def.id"
#
[`
cell-attr_
${
def.id
}`
]=
"{ row }"
>
<div
class=
"max-w-xs"
>
<span
class=
"block truncate text-sm text-gray-700 dark:text-gray-300"
:title=
"getAttributeValue(row.id, def.id)"
>
{{
getAttributeValue
(
row
.
id
,
def
.
id
)
}}
</span>
</div>
</
template
>
<
template
#cell-role=
"{ value }"
>
<
template
#cell-role=
"{ value }"
>
<span
:class=
"['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']"
>
<span
:class=
"['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']"
>
{{
value
}}
{{
value
}}
...
@@ -189,9 +404,17 @@
...
@@ -189,9 +404,17 @@
</
template
>
</
template
>
<
template
#cell-status=
"{ value }"
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-danger']"
>
<div
class=
"flex items-center gap-1.5"
>
{{
value
}}
<span
</span>
:class=
"[
'inline-block h-2 w-2 rounded-full',
value === 'active' ? 'bg-green-500' : 'bg-red-500'
]"
></span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
===
'
active
'
?
t
(
'
common.active
'
)
:
t
(
'
admin.users.disabled
'
)
}}
</span>
</div>
</
template
>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<
template
#cell-created_at=
"{ value }"
>
...
@@ -471,15 +694,6 @@
...
@@ -471,15 +694,6 @@
:placeholder=
"t('admin.users.enterUsername')"
:placeholder=
"t('admin.users.enterUsername')"
/>
/>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.wechat') }}
</label>
<input
v-model=
"createForm.wechat"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterWechat')"
/>
</div>
<div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<textarea
<textarea
...
@@ -640,15 +854,6 @@
...
@@ -640,15 +854,6 @@
:placeholder=
"t('admin.users.enterUsername')"
:placeholder=
"t('admin.users.enterUsername')"
/>
/>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.wechat') }}
</label>
<input
v-model=
"editForm.wechat"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterWechat')"
/>
</div>
<div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<textarea
<textarea
...
@@ -664,6 +869,12 @@
...
@@ -664,6 +869,12 @@
<input
v-model.number=
"editForm.concurrency"
type=
"number"
class=
"input"
/>
<input
v-model.number=
"editForm.concurrency"
type=
"number"
class=
"input"
/>
</div>
</div>
<!-- Custom Attributes -->
<UserAttributeForm
v-model=
"editForm.customAttributes"
:user-id=
"editingUser?.id"
/>
</form>
</form>
<
template
#footer
>
<
template
#footer
>
...
@@ -1179,6 +1390,12 @@
...
@@ -1179,6 +1390,12 @@
@
confirm=
"confirmDelete"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
@
cancel=
"showDeleteDialog = false"
/>
/>
<!-- User Attributes Config Modal -->
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</AppLayout>
</template>
</template>
...
@@ -1191,7 +1408,7 @@ import { formatDateTime } from '@/utils/format'
...
@@ -1191,7 +1408,7 @@ import { formatDateTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
ApiKey
,
Group
}
from
'
@/types
'
import
type
{
User
,
ApiKey
,
Group
,
UserAttributeValuesMap
,
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
'
...
@@ -1201,17 +1418,66 @@ import Pagination from '@/components/common/Pagination.vue'
...
@@ -1201,17 +1418,66 @@ import Pagination from '@/components/common/Pagination.vue'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
UserAttributesConfigModal
from
'
@/components/user/UserAttributesConfigModal.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
const
columns
=
computed
<
Column
[]
>
(()
=>
[
// Generate dynamic attribute columns from enabled definitions
const
attributeColumns
=
computed
<
Column
[]
>
(()
=>
attributeDefinitions
.
value
.
filter
(
def
=>
def
.
enabled
)
.
map
(
def
=>
({
key
:
`attr_
${
def
.
id
}
`
,
label
:
def
.
name
,
sortable
:
false
}))
)
// Get formatted attribute value for display in table
const
getAttributeValue
=
(
userId
:
number
,
attrId
:
number
):
string
=>
{
const
userAttrs
=
userAttributeValues
.
value
[
userId
]
if
(
!
userAttrs
)
return
'
-
'
const
value
=
userAttrs
[
attrId
]
if
(
!
value
)
return
'
-
'
// Find definition for this attribute
const
def
=
attributeDefinitions
.
value
.
find
(
d
=>
d
.
id
===
attrId
)
if
(
!
def
)
return
value
// Format based on type
if
(
def
.
type
===
'
multi_select
'
&&
value
)
{
try
{
const
arr
=
JSON
.
parse
(
value
)
if
(
Array
.
isArray
(
arr
))
{
// Map values to labels
return
arr
.
map
(
v
=>
{
const
opt
=
def
.
options
?.
find
(
o
=>
o
.
value
===
v
)
return
opt
?.
label
||
v
}).
join
(
'
,
'
)
}
}
catch
{
return
value
}
}
if
(
def
.
type
===
'
select
'
&&
value
&&
def
.
options
)
{
const
opt
=
def
.
options
.
find
(
o
=>
o
.
value
===
value
)
return
opt
?.
label
||
value
}
return
value
}
// All possible columns (for column settings)
const
allColumns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
email
'
,
label
:
t
(
'
admin.users.columns.user
'
),
sortable
:
true
},
{
key
:
'
email
'
,
label
:
t
(
'
admin.users.columns.user
'
),
sortable
:
true
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
),
sortable
:
true
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
),
sortable
:
true
},
{
key
:
'
wechat
'
,
label
:
t
(
'
admin.users.columns.wechat
'
),
sortable
:
false
},
{
key
:
'
notes
'
,
label
:
t
(
'
admin.users.columns.notes
'
),
sortable
:
false
},
{
key
:
'
notes
'
,
label
:
t
(
'
admin.users.columns.notes
'
),
sortable
:
false
},
// Dynamic attribute columns
...
attributeColumns
.
value
,
{
key
:
'
role
'
,
label
:
t
(
'
admin.users.columns.role
'
),
sortable
:
true
},
{
key
:
'
role
'
,
label
:
t
(
'
admin.users.columns.role
'
),
sortable
:
true
},
{
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
},
...
@@ -1222,27 +1488,154 @@ const columns = computed<Column[]>(() => [
...
@@ -1222,27 +1488,154 @@ const columns = computed<Column[]>(() => [
{
key
:
'
actions
'
,
label
:
t
(
'
admin.users.columns.actions
'
),
sortable
:
false
}
{
key
:
'
actions
'
,
label
:
t
(
'
admin.users.columns.actions
'
),
sortable
:
false
}
])
])
// Filter options
// Columns that can be toggled (exclude email and actions which are always visible)
const
roleOptions
=
computed
(()
=>
[
const
toggleableColumns
=
computed
(()
=>
{
value
:
''
,
label
:
t
(
'
admin.users.allRoles
'
)
},
allColumns
.
value
.
filter
(
col
=>
col
.
key
!==
'
email
'
&&
col
.
key
!==
'
actions
'
)
{
value
:
'
admin
'
,
label
:
t
(
'
admin.users.admin
'
)
},
)
{
value
:
'
user
'
,
label
:
t
(
'
admin.users.user
'
)
}
])
const
statusOptions
=
computed
(()
=>
[
// Hidden columns (stored in Set - columns NOT in this set are visible)
{
value
:
''
,
label
:
t
(
'
admin.users.allStatus
'
)
},
// This way, new columns are visible by default
{
value
:
'
active
'
,
label
:
t
(
'
common.active
'
)
},
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.users.disabled
'
)
}
])
// Default hidden columns (columns hidden by default on first load)
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
notes
'
,
'
subscriptions
'
,
'
usage
'
,
'
concurrency
'
]
// localStorage key for column settings
const
HIDDEN_COLUMNS_KEY
=
'
user-hidden-columns
'
// Load saved column settings
const
loadSavedColumns
=
()
=>
{
try
{
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
if
(
saved
)
{
const
parsed
=
JSON
.
parse
(
saved
)
as
string
[]
parsed
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
else
{
// Use default hidden columns on first load
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load saved columns:
'
,
e
)
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
}
// Save column settings to localStorage
const
saveColumnsToStorage
=
()
=>
{
try
{
localStorage
.
setItem
(
HIDDEN_COLUMNS_KEY
,
JSON
.
stringify
([...
hiddenColumns
]))
}
catch
(
e
)
{
console
.
error
(
'
Failed to save columns:
'
,
e
)
}
}
// Toggle column visibility
const
toggleColumn
=
(
key
:
string
)
=>
{
if
(
hiddenColumns
.
has
(
key
))
{
hiddenColumns
.
delete
(
key
)
}
else
{
hiddenColumns
.
add
(
key
)
}
saveColumnsToStorage
()
}
// Check if column is visible (not in hidden set)
const
isColumnVisible
=
(
key
:
string
)
=>
!
hiddenColumns
.
has
(
key
)
// Filtered columns based on visibility
const
columns
=
computed
<
Column
[]
>
(()
=>
allColumns
.
value
.
filter
(
col
=>
col
.
key
===
'
email
'
||
col
.
key
===
'
actions
'
||
!
hiddenColumns
.
has
(
col
.
key
)
)
)
const
users
=
ref
<
User
[]
>
([])
const
users
=
ref
<
User
[]
>
([])
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
searchQuery
=
ref
(
''
)
// Filter values (role, status, and custom attributes)
const
filters
=
reactive
({
const
filters
=
reactive
({
role
:
''
,
role
:
''
,
status
:
''
status
:
''
})
})
const
activeAttributeFilters
=
reactive
<
Record
<
number
,
string
>>
({})
// Visible filters tracking (which filters are shown in the UI)
// Keys: 'role', 'status', 'attr_${id}'
const
visibleFilters
=
reactive
<
Set
<
string
>>
(
new
Set
())
// Dropdown states
const
showFilterDropdown
=
ref
(
false
)
const
showColumnDropdown
=
ref
(
false
)
// Dropdown refs for click outside detection
const
filterDropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
columnDropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
// localStorage keys
const
FILTER_VALUES_KEY
=
'
user-filter-values
'
const
VISIBLE_FILTERS_KEY
=
'
user-visible-filters
'
// All filterable attribute definitions (enabled attributes)
const
filterableAttributes
=
computed
(()
=>
attributeDefinitions
.
value
.
filter
(
def
=>
def
.
enabled
)
)
// 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
}
])
// Load saved filters from localStorage
const
loadSavedFilters
=
()
=>
{
try
{
// Load visible filters
const
savedVisible
=
localStorage
.
getItem
(
VISIBLE_FILTERS_KEY
)
if
(
savedVisible
)
{
const
parsed
=
JSON
.
parse
(
savedVisible
)
as
string
[]
parsed
.
forEach
(
key
=>
visibleFilters
.
add
(
key
))
}
// Load filter values
const
savedValues
=
localStorage
.
getItem
(
FILTER_VALUES_KEY
)
if
(
savedValues
)
{
const
parsed
=
JSON
.
parse
(
savedValues
)
if
(
parsed
.
role
)
filters
.
role
=
parsed
.
role
if
(
parsed
.
status
)
filters
.
status
=
parsed
.
status
if
(
parsed
.
attributes
)
{
Object
.
assign
(
activeAttributeFilters
,
parsed
.
attributes
)
}
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load saved filters:
'
,
e
)
}
}
// Save filters to localStorage
const
saveFiltersToStorage
=
()
=>
{
try
{
// Save visible filters
localStorage
.
setItem
(
VISIBLE_FILTERS_KEY
,
JSON
.
stringify
([...
visibleFilters
]))
// Save filter values
const
values
=
{
role
:
filters
.
role
,
status
:
filters
.
status
,
attributes
:
activeAttributeFilters
}
localStorage
.
setItem
(
FILTER_VALUES_KEY
,
JSON
.
stringify
(
values
))
}
catch
(
e
)
{
console
.
error
(
'
Failed to save filters:
'
,
e
)
}
}
// Get attribute definition by ID
const
getAttributeDefinition
=
(
attrId
:
number
):
UserAttributeDefinition
|
undefined
=>
{
return
attributeDefinitions
.
value
.
find
(
d
=>
d
.
id
===
attrId
)
}
const
usageStats
=
ref
<
Record
<
string
,
BatchUserUsageStats
>>
({})
const
usageStats
=
ref
<
Record
<
string
,
BatchUserUsageStats
>>
({})
// User attribute definitions and values
const
attributeDefinitions
=
ref
<
UserAttributeDefinition
[]
>
([])
const
userAttributeValues
=
ref
<
Record
<
number
,
Record
<
number
,
string
>>>
({})
const
pagination
=
reactive
({
const
pagination
=
reactive
({
page
:
1
,
page
:
1
,
page_size
:
20
,
page_size
:
20
,
...
@@ -1254,6 +1647,7 @@ const showCreateModal = ref(false)
...
@@ -1254,6 +1647,7 @@ const showCreateModal = ref(false)
const
showEditModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showApiKeysModal
=
ref
(
false
)
const
showApiKeysModal
=
ref
(
false
)
const
showAttributesModal
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
editingUser
=
ref
<
User
|
null
>
(
null
)
const
editingUser
=
ref
<
User
|
null
>
(
null
)
const
deletingUser
=
ref
<
User
|
null
>
(
null
)
const
deletingUser
=
ref
<
User
|
null
>
(
null
)
...
@@ -1317,6 +1711,14 @@ const handleClickOutside = (event: MouseEvent) => {
...
@@ -1317,6 +1711,14 @@ const handleClickOutside = (event: MouseEvent) => {
if
(
!
target
.
closest
(
'
.action-menu-trigger
'
)
&&
!
target
.
closest
(
'
.action-menu-content
'
))
{
if
(
!
target
.
closest
(
'
.action-menu-trigger
'
)
&&
!
target
.
closest
(
'
.action-menu-content
'
))
{
closeActionMenu
()
closeActionMenu
()
}
}
// Close filter dropdown when clicking outside
if
(
filterDropdownRef
.
value
&&
!
filterDropdownRef
.
value
.
contains
(
target
))
{
showFilterDropdown
.
value
=
false
}
// Close column dropdown when clicking outside
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
}
}
}
// Allowed groups modal state
// Allowed groups modal state
...
@@ -1341,7 +1743,6 @@ const createForm = reactive({
...
@@ -1341,7 +1743,6 @@ const createForm = reactive({
email
:
''
,
email
:
''
,
password
:
''
,
password
:
''
,
username
:
''
,
username
:
''
,
wechat
:
''
,
notes
:
''
,
notes
:
''
,
balance
:
0
,
balance
:
0
,
concurrency
:
1
concurrency
:
1
...
@@ -1351,9 +1752,9 @@ const editForm = reactive({
...
@@ -1351,9 +1752,9 @@ const editForm = reactive({
email
:
''
,
email
:
''
,
password
:
''
,
password
:
''
,
username
:
''
,
username
:
''
,
wechat
:
''
,
notes
:
''
,
notes
:
''
,
concurrency
:
1
concurrency
:
1
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
})
const
editPasswordCopied
=
ref
(
false
)
const
editPasswordCopied
=
ref
(
false
)
...
@@ -1404,6 +1805,21 @@ const copyEditPassword = async () => {
...
@@ -1404,6 +1805,21 @@ const copyEditPassword = async () => {
}
}
}
}
const
loadAttributeDefinitions
=
async
()
=>
{
try
{
attributeDefinitions
.
value
=
await
adminAPI
.
userAttributes
.
listEnabledDefinitions
()
}
catch
(
e
)
{
console
.
error
(
'
Failed to load attribute definitions:
'
,
e
)
}
}
// Handle attributes modal close - reload definitions and users
const
handleAttributesModalClose
=
async
()
=>
{
showAttributesModal
.
value
=
false
await
loadAttributeDefinitions
()
loadUsers
()
}
const
loadUsers
=
async
()
=>
{
const
loadUsers
=
async
()
=>
{
abortController
?.
abort
()
abortController
?.
abort
()
const
currentAbortController
=
new
AbortController
()
const
currentAbortController
=
new
AbortController
()
...
@@ -1411,13 +1827,22 @@ const loadUsers = async () => {
...
@@ -1411,13 +1827,22 @@ const loadUsers = async () => {
const
{
signal
}
=
currentAbortController
const
{
signal
}
=
currentAbortController
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
// Build attribute filters from active filters
const
attrFilters
:
Record
<
number
,
string
>
=
{}
for
(
const
[
attrId
,
value
]
of
Object
.
entries
(
activeAttributeFilters
))
{
if
(
value
)
{
attrFilters
[
Number
(
attrId
)]
=
value
}
}
const
response
=
await
adminAPI
.
users
.
list
(
const
response
=
await
adminAPI
.
users
.
list
(
pagination
.
page
,
pagination
.
page
,
pagination
.
page_size
,
pagination
.
page_size
,
{
{
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
,
attributes
:
Object
.
keys
(
attrFilters
).
length
>
0
?
attrFilters
:
undefined
},
},
{
signal
}
{
signal
}
)
)
...
@@ -1428,9 +1853,10 @@ const loadUsers = async () => {
...
@@ -1428,9 +1853,10 @@ const loadUsers = async () => {
pagination
.
total
=
response
.
total
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
pagination
.
pages
=
response
.
pages
// Load usage stats for all users in the list
// Load usage stats
and attribute values
for all users in the list
if
(
response
.
items
.
length
>
0
)
{
if
(
response
.
items
.
length
>
0
)
{
const
userIds
=
response
.
items
.
map
((
u
)
=>
u
.
id
)
const
userIds
=
response
.
items
.
map
((
u
)
=>
u
.
id
)
// Load usage stats
try
{
try
{
const
usageResponse
=
await
adminAPI
.
dashboard
.
getBatchUsersUsage
(
userIds
)
const
usageResponse
=
await
adminAPI
.
dashboard
.
getBatchUsersUsage
(
userIds
)
if
(
signal
.
aborted
)
{
if
(
signal
.
aborted
)
{
...
@@ -1443,6 +1869,21 @@ const loadUsers = async () => {
...
@@ -1443,6 +1869,21 @@ const loadUsers = async () => {
}
}
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
}
}
// Load attribute values
if
(
attributeDefinitions
.
value
.
length
>
0
)
{
try
{
const
attrResponse
=
await
adminAPI
.
userAttributes
.
getBatchUserAttributes
(
userIds
)
if
(
signal
.
aborted
)
{
return
}
userAttributeValues
.
value
=
attrResponse
.
attributes
}
catch
(
e
)
{
if
(
signal
.
aborted
)
{
return
}
console
.
error
(
'
Failed to load user attribute values:
'
,
e
)
}
}
}
}
}
catch
(
error
)
{
}
catch
(
error
)
{
const
errorInfo
=
error
as
{
name
?:
string
;
code
?:
string
}
const
errorInfo
=
error
as
{
name
?:
string
;
code
?:
string
}
...
@@ -1478,12 +1919,54 @@ const handlePageSizeChange = (pageSize: number) => {
...
@@ -1478,12 +1919,54 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsers
()
loadUsers
()
}
}
// Filter helpers
const
getAttributeDefinitionName
=
(
attrId
:
number
):
string
=>
{
const
def
=
attributeDefinitions
.
value
.
find
(
d
=>
d
.
id
===
attrId
)
return
def
?.
name
||
String
(
attrId
)
}
// Toggle a built-in filter (role/status)
const
toggleBuiltInFilter
=
(
key
:
string
)
=>
{
if
(
visibleFilters
.
has
(
key
))
{
visibleFilters
.
delete
(
key
)
if
(
key
===
'
role
'
)
filters
.
role
=
''
if
(
key
===
'
status
'
)
filters
.
status
=
''
}
else
{
visibleFilters
.
add
(
key
)
}
saveFiltersToStorage
()
loadUsers
()
}
// Toggle a custom attribute filter
const
toggleAttributeFilter
=
(
attr
:
UserAttributeDefinition
)
=>
{
const
key
=
`attr_
${
attr
.
id
}
`
if
(
visibleFilters
.
has
(
key
))
{
visibleFilters
.
delete
(
key
)
delete
activeAttributeFilters
[
attr
.
id
]
}
else
{
visibleFilters
.
add
(
key
)
activeAttributeFilters
[
attr
.
id
]
=
''
}
saveFiltersToStorage
()
loadUsers
()
}
const
updateAttributeFilter
=
(
attrId
:
number
,
value
:
string
)
=>
{
activeAttributeFilters
[
attrId
]
=
value
}
// Apply filter and save to localStorage
const
applyFilter
=
()
=>
{
saveFiltersToStorage
()
loadUsers
()
}
const
closeCreateModal
=
()
=>
{
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
showCreateModal
.
value
=
false
createForm
.
email
=
''
createForm
.
email
=
''
createForm
.
password
=
''
createForm
.
password
=
''
createForm
.
username
=
''
createForm
.
username
=
''
createForm
.
wechat
=
''
createForm
.
notes
=
''
createForm
.
notes
=
''
createForm
.
balance
=
0
createForm
.
balance
=
0
createForm
.
concurrency
=
1
createForm
.
concurrency
=
1
...
@@ -1514,9 +1997,9 @@ const handleEdit = (user: User) => {
...
@@ -1514,9 +1997,9 @@ const handleEdit = (user: User) => {
editForm
.
email
=
user
.
email
editForm
.
email
=
user
.
email
editForm
.
password
=
''
editForm
.
password
=
''
editForm
.
username
=
user
.
username
||
''
editForm
.
username
=
user
.
username
||
''
editForm
.
wechat
=
user
.
wechat
||
''
editForm
.
notes
=
user
.
notes
||
''
editForm
.
notes
=
user
.
notes
||
''
editForm
.
concurrency
=
user
.
concurrency
editForm
.
concurrency
=
user
.
concurrency
editForm
.
customAttributes
=
{}
editPasswordCopied
.
value
=
false
editPasswordCopied
.
value
=
false
showEditModal
.
value
=
true
showEditModal
.
value
=
true
}
}
...
@@ -1525,6 +2008,7 @@ const closeEditModal = () => {
...
@@ -1525,6 +2008,7 @@ const closeEditModal = () => {
showEditModal
.
value
=
false
showEditModal
.
value
=
false
editingUser
.
value
=
null
editingUser
.
value
=
null
editForm
.
password
=
''
editForm
.
password
=
''
editForm
.
customAttributes
=
{}
editPasswordCopied
.
value
=
false
editPasswordCopied
.
value
=
false
}
}
...
@@ -1536,7 +2020,6 @@ const handleUpdateUser = async () => {
...
@@ -1536,7 +2020,6 @@ const handleUpdateUser = async () => {
const
updateData
:
Record
<
string
,
any
>
=
{
const
updateData
:
Record
<
string
,
any
>
=
{
email
:
editForm
.
email
,
email
:
editForm
.
email
,
username
:
editForm
.
username
,
username
:
editForm
.
username
,
wechat
:
editForm
.
wechat
,
notes
:
editForm
.
notes
,
notes
:
editForm
.
notes
,
concurrency
:
editForm
.
concurrency
concurrency
:
editForm
.
concurrency
}
}
...
@@ -1545,6 +2028,15 @@ const handleUpdateUser = async () => {
...
@@ -1545,6 +2028,15 @@ const handleUpdateUser = async () => {
}
}
await
adminAPI
.
users
.
update
(
editingUser
.
value
.
id
,
updateData
)
await
adminAPI
.
users
.
update
(
editingUser
.
value
.
id
,
updateData
)
// Save custom attributes if any
if
(
Object
.
keys
(
editForm
.
customAttributes
).
length
>
0
)
{
await
adminAPI
.
userAttributes
.
updateUserAttributeValues
(
editingUser
.
value
.
id
,
editForm
.
customAttributes
)
}
appStore
.
showSuccess
(
t
(
'
admin.users.userUpdated
'
))
appStore
.
showSuccess
(
t
(
'
admin.users.userUpdated
'
))
closeEditModal
()
closeEditModal
()
loadUsers
()
loadUsers
()
...
@@ -1730,7 +2222,10 @@ const handleBalanceSubmit = async () => {
...
@@ -1730,7 +2222,10 @@ const handleBalanceSubmit = async () => {
}
}
}
}
onMounted
(()
=>
{
onMounted
(
async
()
=>
{
await
loadAttributeDefinitions
()
loadSavedFilters
()
loadSavedColumns
()
loadUsers
()
loadUsers
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
})
...
...
frontend/src/views/user/ProfileView.vue
View file @
106e59b7
...
@@ -89,25 +89,6 @@
...
@@ -89,25 +89,6 @@
</svg>
</svg>
<span
class=
"truncate"
>
{{
user
.
username
}}
</span>
<span
class=
"truncate"
>
{{
user
.
username
}}
</span>
</div>
</div>
<div
v-if=
"user?.wechat"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
<span
class=
"truncate"
>
{{
user
.
wechat
}}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
...
@@ -170,19 +151,6 @@
...
@@ -170,19 +151,6 @@
/>
/>
</div>
</div>
<div>
<label
for=
"wechat"
class=
"input-label"
>
{{
t
(
'
profile.wechat
'
)
}}
</label>
<input
id=
"wechat"
v-model=
"profileForm.wechat"
type=
"text"
class=
"input"
:placeholder=
"t('profile.enterWechat')"
/>
</div>
<div
class=
"flex justify-end pt-4"
>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"updatingProfile"
class=
"btn btn-primary"
>
<button
type=
"submit"
:disabled=
"updatingProfile"
class=
"btn btn-primary"
>
{{
updatingProfile
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
{{
updatingProfile
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
...
@@ -338,8 +306,7 @@ const passwordForm = ref({
...
@@ -338,8 +306,7 @@ const passwordForm = ref({
})
})
const
profileForm
=
ref
({
const
profileForm
=
ref
({
username
:
''
,
username
:
''
wechat
:
''
})
})
const
changingPassword
=
ref
(
false
)
const
changingPassword
=
ref
(
false
)
...
@@ -354,7 +321,6 @@ onMounted(async () => {
...
@@ -354,7 +321,6 @@ onMounted(async () => {
// Initialize profile form with current user data
// Initialize profile form with current user data
if
(
user
.
value
)
{
if
(
user
.
value
)
{
profileForm
.
value
.
username
=
user
.
value
.
username
||
''
profileForm
.
value
.
username
=
user
.
value
.
username
||
''
profileForm
.
value
.
wechat
=
user
.
value
.
wechat
||
''
}
}
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
'
Failed to load contact info:
'
,
error
)
console
.
error
(
'
Failed to load contact info:
'
,
error
)
...
@@ -407,8 +373,7 @@ const handleUpdateProfile = async () => {
...
@@ -407,8 +373,7 @@ const handleUpdateProfile = async () => {
updatingProfile
.
value
=
true
updatingProfile
.
value
=
true
try
{
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
const
updatedUser
=
await
userAPI
.
updateProfile
({
username
:
profileForm
.
value
.
username
,
username
:
profileForm
.
value
.
username
wechat
:
profileForm
.
value
.
wechat
})
})
// Update auth store with new user data
// Update auth store with new user data
...
...
Prev
1
2
3
4
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