Commit 404bf0f8 authored by Edric Li's avatar Edric Li
Browse files

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: default avatarClaude Opus 4.5 <noreply@anthropic.com>
parent f44cf642
...@@ -10,7 +10,6 @@ type User struct { ...@@ -10,7 +10,6 @@ type User struct {
ID int64 ID int64
Email string Email string
Username string Username string
Wechat string
Notes string Notes string
PasswordHash string PasswordHash string
Role string Role string
......
...@@ -14,6 +14,14 @@ var ( ...@@ -14,6 +14,14 @@ var (
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions") ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
) )
// UserListFilters contains all filter options for listing users
type UserListFilters struct {
Status string // User status filter
Role string // User role filter
Search string // Search in email, username
Attributes map[int64]string // Custom attribute filters: attributeID -> value
}
type UserRepository interface { type UserRepository interface {
Create(ctx context.Context, user *User) error Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id int64) (*User, error) GetByID(ctx context.Context, id int64) (*User, error)
...@@ -23,7 +31,7 @@ type UserRepository interface { ...@@ -23,7 +31,7 @@ type UserRepository interface {
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error)
ListWithFilters(ctx context.Context, params pagination.PaginationParams, status, role, search string) ([]User, *pagination.PaginationResult, error) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error)
UpdateBalance(ctx context.Context, id int64, amount float64) error UpdateBalance(ctx context.Context, id int64, amount float64) error
DeductBalance(ctx context.Context, id int64, amount float64) error DeductBalance(ctx context.Context, id int64, amount float64) error
...@@ -36,7 +44,6 @@ type UserRepository interface { ...@@ -36,7 +44,6 @@ type UserRepository interface {
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
Email *string `json:"email"` Email *string `json:"email"`
Username *string `json:"username"` Username *string `json:"username"`
Wechat *string `json:"wechat"`
Concurrency *int `json:"concurrency"` Concurrency *int `json:"concurrency"`
} }
...@@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat ...@@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
user.Username = *req.Username user.Username = *req.Username
} }
if req.Wechat != nil {
user.Wechat = *req.Wechat
}
if req.Concurrency != nil { if req.Concurrency != nil {
user.Concurrency = *req.Concurrency user.Concurrency = *req.Concurrency
} }
......
-- Migration: Move wechat field from users table to user_attribute_values
-- This migration:
-- 1. Creates a "wechat" attribute definition
-- 2. Migrates existing wechat data to user_attribute_values
-- 3. Does NOT drop the wechat column (for rollback safety, can be done in a later migration)
-- +goose Up
-- +goose StatementBegin
-- Step 1: Insert wechat attribute definition if not exists
INSERT INTO user_attribute_definitions (key, name, description, type, options, required, validation, placeholder, display_order, enabled, created_at, updated_at)
SELECT 'wechat', '微信', '用户微信号', 'text', '[]'::jsonb, false, '{}'::jsonb, '请输入微信号', 0, true, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL
);
-- Step 2: Migrate existing wechat values to user_attribute_values
-- Only migrate non-empty values
INSERT INTO user_attribute_values (user_id, attribute_id, value, created_at, updated_at)
SELECT
u.id,
(SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1),
u.wechat,
NOW(),
NOW()
FROM users u
WHERE u.wechat IS NOT NULL
AND u.wechat != ''
AND u.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM user_attribute_values uav
WHERE uav.user_id = u.id
AND uav.attribute_id = (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1)
);
-- Step 3: Update display_order to ensure wechat appears first
UPDATE user_attribute_definitions
SET display_order = -1
WHERE key = 'wechat' AND deleted_at IS NULL;
-- Reorder all attributes starting from 0
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY display_order, id) - 1 as new_order
FROM user_attribute_definitions
WHERE deleted_at IS NULL
)
UPDATE user_attribute_definitions
SET display_order = ordered.new_order
FROM ordered
WHERE user_attribute_definitions.id = ordered.id;
-- Step 4: Drop the redundant wechat column from users table
ALTER TABLE users DROP COLUMN IF EXISTS wechat;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Restore wechat column
ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat VARCHAR(100) DEFAULT '';
-- Copy attribute values back to users.wechat column
UPDATE users u
SET wechat = uav.value
FROM user_attribute_values uav
JOIN user_attribute_definitions uad ON uav.attribute_id = uad.id
WHERE uav.user_id = u.id
AND uad.key = 'wechat'
AND uad.deleted_at IS NULL;
-- Delete migrated attribute values
DELETE FROM user_attribute_values
WHERE attribute_id IN (
SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL
);
-- Soft-delete the wechat attribute definition
UPDATE user_attribute_definitions
SET deleted_at = NOW()
WHERE key = 'wechat' AND deleted_at IS NULL;
-- +goose StatementEnd
...@@ -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>> {
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', { // Build params with attribute filters in attr[id]=value format
params: { const params: Record<string, any> = {
page, page,
page_size: pageSize, page_size: pageSize,
...filters 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,
signal: options?.signal signal: options?.signal
}) })
return data return data
......
...@@ -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
......
...@@ -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
......
...@@ -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
......
...@@ -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
}
This diff is collapsed.
...@@ -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
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment