refactor: migrate wechat to user attributes and enhance users list
Migrate the hardcoded wechat field to the new extensible user attributes system and improve the users management UI. Migration: - Add migration 019 to move wechat data to user_attribute_values - Remove wechat field from User entity, DTOs, and API contracts - Clean up wechat-related code from backend and frontend UsersView enhancements: - Add text labels to action buttons (Filter Settings, Column Settings, Attributes Config) for better UX - Change status column to show colored dot + Chinese text instead of English text - Add dynamic attribute columns support with batch loading - Add column visibility settings with localStorage persistence - Add filter settings modal for search and filter preferences - Update i18n translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
|
||||
* List all users with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @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)
|
||||
* @returns Paginated list of users
|
||||
*/
|
||||
@@ -21,17 +21,32 @@ export async function list(
|
||||
status?: 'active' | 'disabled'
|
||||
role?: 'admin' | 'user'
|
||||
search?: string
|
||||
attributes?: Record<number, string> // attributeId -> value
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): 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', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
|
||||
@@ -22,7 +22,6 @@ export async function getProfile(): Promise<User> {
|
||||
*/
|
||||
export async function updateProfile(profile: {
|
||||
username?: string
|
||||
wechat?: string
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile)
|
||||
return data
|
||||
|
||||
@@ -434,9 +434,7 @@ export default {
|
||||
administrator: 'Administrator',
|
||||
user: 'User',
|
||||
username: 'Username',
|
||||
wechat: 'WeChat ID',
|
||||
enterUsername: 'Enter username',
|
||||
enterWechat: 'Enter WeChat ID',
|
||||
editProfile: 'Edit Profile',
|
||||
updateProfile: 'Update Profile',
|
||||
updating: 'Updating...',
|
||||
@@ -565,12 +563,10 @@ export default {
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
username: 'Username',
|
||||
wechat: 'WeChat ID',
|
||||
notes: 'Notes',
|
||||
enterEmail: 'Enter email',
|
||||
enterPassword: 'Enter password',
|
||||
enterUsername: 'Enter username (optional)',
|
||||
enterWechat: 'Enter WeChat ID (optional)',
|
||||
enterNotes: 'Enter notes (admin only)',
|
||||
notesHint: 'This note is only visible to administrators',
|
||||
enterNewPassword: 'Enter new password (optional)',
|
||||
@@ -582,7 +578,6 @@ export default {
|
||||
columns: {
|
||||
user: 'User',
|
||||
username: 'Username',
|
||||
wechat: 'WeChat ID',
|
||||
notes: 'Notes',
|
||||
role: 'Role',
|
||||
subscriptions: 'Subscriptions',
|
||||
@@ -653,7 +648,67 @@ export default {
|
||||
failedToDeposit: 'Failed to deposit',
|
||||
failedToWithdraw: 'Failed to withdraw',
|
||||
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
|
||||
|
||||
@@ -430,9 +430,7 @@ export default {
|
||||
administrator: '管理员',
|
||||
user: '用户',
|
||||
username: '用户名',
|
||||
wechat: '微信号',
|
||||
enterUsername: '输入用户名',
|
||||
enterWechat: '输入微信号',
|
||||
editProfile: '编辑个人资料',
|
||||
updateProfile: '更新资料',
|
||||
updating: '更新中...',
|
||||
@@ -583,12 +581,10 @@ export default {
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
username: '用户名',
|
||||
wechat: '微信号',
|
||||
notes: '备注',
|
||||
enterEmail: '请输入邮箱',
|
||||
enterPassword: '请输入密码',
|
||||
enterUsername: '请输入用户名(选填)',
|
||||
enterWechat: '请输入微信号(选填)',
|
||||
enterNotes: '请输入备注(仅管理员可见)',
|
||||
notesHint: '此备注仅对管理员可见',
|
||||
enterNewPassword: '请输入新密码(选填)',
|
||||
@@ -601,7 +597,6 @@ export default {
|
||||
user: '用户',
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
wechat: '微信号',
|
||||
notes: '备注',
|
||||
role: '角色',
|
||||
subscriptions: '订阅分组',
|
||||
@@ -655,8 +650,6 @@ export default {
|
||||
emailPlaceholder: '请输入邮箱',
|
||||
usernameLabel: '用户名',
|
||||
usernamePlaceholder: '请输入用户名(选填)',
|
||||
wechatLabel: '微信号',
|
||||
wechatPlaceholder: '请输入微信号(选填)',
|
||||
notesLabel: '备注',
|
||||
notesPlaceholder: '请输入备注(仅管理员可见)',
|
||||
notesHint: '此备注仅对管理员可见',
|
||||
@@ -711,7 +704,67 @@ export default {
|
||||
failedToDeposit: '充值失败',
|
||||
failedToWithdraw: '退款失败',
|
||||
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
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
wechat: string
|
||||
notes: string
|
||||
email: string
|
||||
role: 'admin' | 'user' // User role for authorization
|
||||
@@ -634,7 +633,6 @@ export interface UpdateUserRequest {
|
||||
email?: string
|
||||
password?: string
|
||||
username?: string
|
||||
wechat?: string
|
||||
notes?: string
|
||||
role?: 'admin' | 'user'
|
||||
balance?: number
|
||||
@@ -771,3 +769,76 @@ export interface AccountUsageStatsResponse {
|
||||
summary: AccountUsageSummary
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,86 +1,289 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<!-- Page Header 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 -->
|
||||
<!-- Single Row: Search, Filters, and Actions -->
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -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="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.users.searchUsers')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<!-- Left: Search + Active Filters -->
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<!-- Search Box -->
|
||||
<div class="relative w-64">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -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="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.users.searchUsers')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Role Filter (visible when enabled) -->
|
||||
<div v-if="visibleFilters.has('role')" class="relative">
|
||||
<select
|
||||
v-model="filters.role"
|
||||
@change="applyFilter"
|
||||
class="input w-32 cursor-pointer appearance-none pr-8"
|
||||
>
|
||||
<option value="">{{ t('admin.users.allRoles') }}</option>
|
||||
<option value="admin">{{ t('admin.users.admin') }}</option>
|
||||
<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 class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.role"
|
||||
:options="roleOptions"
|
||||
:placeholder="t('admin.users.allRoles')"
|
||||
class="w-36"
|
||||
@change="loadUsers"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.users.allStatus')"
|
||||
class="w-36"
|
||||
@change="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Users Table -->
|
||||
@@ -103,10 +306,6 @@
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-wechat="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notes="{ value }">
|
||||
<div class="max-w-xs">
|
||||
<span
|
||||
@@ -120,6 +319,22 @@
|
||||
</div>
|
||||
</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 }">
|
||||
<span :class="['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']">
|
||||
{{ value }}
|
||||
@@ -189,9 +404,17 @@
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||
{{ value }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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 #cell-created_at="{ value }">
|
||||
@@ -471,15 +694,6 @@
|
||||
:placeholder="t('admin.users.enterUsername')"
|
||||
/>
|
||||
</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>
|
||||
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||
<textarea
|
||||
@@ -640,15 +854,6 @@
|
||||
:placeholder="t('admin.users.enterUsername')"
|
||||
/>
|
||||
</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>
|
||||
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||
<textarea
|
||||
@@ -664,6 +869,12 @@
|
||||
<input v-model.number="editForm.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
|
||||
<!-- Custom Attributes -->
|
||||
<UserAttributeForm
|
||||
v-model="editForm.customAttributes"
|
||||
:user-id="editingUser?.id"
|
||||
/>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -1179,6 +1390,12 @@
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- User Attributes Config Modal -->
|
||||
<UserAttributesConfigModal
|
||||
:show="showAttributesModal"
|
||||
@close="handleAttributesModalClose"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1191,7 +1408,7 @@ import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
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 { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -1201,17 +1418,66 @@ import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.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 { 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: '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 },
|
||||
// Dynamic attribute columns
|
||||
...attributeColumns.value,
|
||||
{ key: 'role', label: t('admin.users.columns.role'), sortable: true },
|
||||
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
|
||||
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||
@@ -1222,27 +1488,154 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const roleOptions = computed(() => [
|
||||
{ value: '', label: t('admin.users.allRoles') },
|
||||
{ value: 'admin', label: t('admin.users.admin') },
|
||||
{ value: 'user', label: t('admin.users.user') }
|
||||
])
|
||||
// Columns that can be toggled (exclude email and actions which are always visible)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'email' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.users.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'disabled', label: t('admin.users.disabled') }
|
||||
])
|
||||
// Hidden columns (stored in Set - columns NOT in this set are visible)
|
||||
// This way, new columns are visible by default
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
// Default hidden columns (columns hidden by default on first load)
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'subscriptions', 'usage', 'concurrency']
|
||||
|
||||
// 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 loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Filter values (role, status, and custom attributes)
|
||||
const filters = reactive({
|
||||
role: '',
|
||||
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>>({})
|
||||
// User attribute definitions and values
|
||||
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
|
||||
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
@@ -1254,6 +1647,7 @@ const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingUser = ref<User | null>(null)
|
||||
const deletingUser = ref<User | null>(null)
|
||||
@@ -1317,6 +1711,14 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
|
||||
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
|
||||
@@ -1341,7 +1743,6 @@ const createForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
wechat: '',
|
||||
notes: '',
|
||||
balance: 0,
|
||||
concurrency: 1
|
||||
@@ -1351,9 +1752,9 @@ const editForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
wechat: '',
|
||||
notes: '',
|
||||
concurrency: 1
|
||||
concurrency: 1,
|
||||
customAttributes: {} as UserAttributeValuesMap
|
||||
})
|
||||
const editPasswordCopied = ref(false)
|
||||
|
||||
@@ -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 () => {
|
||||
abortController?.abort()
|
||||
const currentAbortController = new AbortController()
|
||||
@@ -1411,13 +1827,22 @@ const loadUsers = async () => {
|
||||
const { signal } = currentAbortController
|
||||
loading.value = true
|
||||
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(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
role: filters.role as any,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
search: searchQuery.value || undefined,
|
||||
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -1428,9 +1853,10 @@ const loadUsers = async () => {
|
||||
pagination.total = response.total
|
||||
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) {
|
||||
const userIds = response.items.map((u) => u.id)
|
||||
// Load usage stats
|
||||
try {
|
||||
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
|
||||
if (signal.aborted) {
|
||||
@@ -1443,6 +1869,21 @@ const loadUsers = async () => {
|
||||
}
|
||||
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) {
|
||||
const errorInfo = error as { name?: string; code?: string }
|
||||
@@ -1478,12 +1919,54 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
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 = () => {
|
||||
showCreateModal.value = false
|
||||
createForm.email = ''
|
||||
createForm.password = ''
|
||||
createForm.username = ''
|
||||
createForm.wechat = ''
|
||||
createForm.notes = ''
|
||||
createForm.balance = 0
|
||||
createForm.concurrency = 1
|
||||
@@ -1514,9 +1997,9 @@ const handleEdit = (user: User) => {
|
||||
editForm.email = user.email
|
||||
editForm.password = ''
|
||||
editForm.username = user.username || ''
|
||||
editForm.wechat = user.wechat || ''
|
||||
editForm.notes = user.notes || ''
|
||||
editForm.concurrency = user.concurrency
|
||||
editForm.customAttributes = {}
|
||||
editPasswordCopied.value = false
|
||||
showEditModal.value = true
|
||||
}
|
||||
@@ -1525,6 +2008,7 @@ const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingUser.value = null
|
||||
editForm.password = ''
|
||||
editForm.customAttributes = {}
|
||||
editPasswordCopied.value = false
|
||||
}
|
||||
|
||||
@@ -1536,7 +2020,6 @@ const handleUpdateUser = async () => {
|
||||
const updateData: Record<string, any> = {
|
||||
email: editForm.email,
|
||||
username: editForm.username,
|
||||
wechat: editForm.wechat,
|
||||
notes: editForm.notes,
|
||||
concurrency: editForm.concurrency
|
||||
}
|
||||
@@ -1545,6 +2028,15 @@ const handleUpdateUser = async () => {
|
||||
}
|
||||
|
||||
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'))
|
||||
closeEditModal()
|
||||
loadUsers()
|
||||
@@ -1730,7 +2222,10 @@ const handleBalanceSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await loadAttributeDefinitions()
|
||||
loadSavedFilters()
|
||||
loadSavedColumns()
|
||||
loadUsers()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
@@ -89,25 +89,6 @@
|
||||
</svg>
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
</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>
|
||||
@@ -170,19 +151,6 @@
|
||||
/>
|
||||
</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">
|
||||
<button type="submit" :disabled="updatingProfile" class="btn btn-primary">
|
||||
{{ updatingProfile ? t('profile.updating') : t('profile.updateProfile') }}
|
||||
@@ -338,8 +306,7 @@ const passwordForm = ref({
|
||||
})
|
||||
|
||||
const profileForm = ref({
|
||||
username: '',
|
||||
wechat: ''
|
||||
username: ''
|
||||
})
|
||||
|
||||
const changingPassword = ref(false)
|
||||
@@ -354,7 +321,6 @@ onMounted(async () => {
|
||||
// Initialize profile form with current user data
|
||||
if (user.value) {
|
||||
profileForm.value.username = user.value.username || ''
|
||||
profileForm.value.wechat = user.value.wechat || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact info:', error)
|
||||
@@ -407,8 +373,7 @@ const handleUpdateProfile = async () => {
|
||||
updatingProfile.value = true
|
||||
try {
|
||||
const updatedUser = await userAPI.updateProfile({
|
||||
username: profileForm.value.username,
|
||||
wechat: profileForm.value.wechat
|
||||
username: profileForm.value.username
|
||||
})
|
||||
|
||||
// Update auth store with new user data
|
||||
|
||||
Reference in New Issue
Block a user