Merge PR #15: feat: 增强用户管理功能,添加用户名、微信号和备注字段

This commit is contained in:
shaw
2025-12-23 14:03:07 +08:00
13 changed files with 401 additions and 21 deletions

View File

@@ -11,7 +11,20 @@ import type { User, ChangePasswordRequest } from '@/types';
* @returns User profile data
*/
export async function getProfile(): Promise<User> {
const { data } = await apiClient.get<User>('/users/me');
const { data } = await apiClient.get<User>('/user/profile');
return data;
}
/**
* Update current user profile
* @param profile - Profile data to update
* @returns Updated user profile data
*/
export async function updateProfile(profile: {
username?: string;
wechat?: string;
}): Promise<User> {
const { data } = await apiClient.put<User>('/user', profile);
return data;
}
@@ -29,12 +42,13 @@ export async function changePassword(
new_password: newPassword,
};
const { data } = await apiClient.post<{ message: string }>('/users/me/password', payload);
const { data } = await apiClient.put<{ message: string }>('/user/password', payload);
return data;
}
export const userAPI = {
getProfile,
updateProfile,
changePassword,
};

View File

@@ -335,6 +335,15 @@ export default {
memberSince: 'Member Since',
administrator: 'Administrator',
user: 'User',
username: 'Username',
wechat: 'WeChat ID',
enterUsername: 'Enter username',
enterWechat: 'Enter WeChat ID',
editProfile: 'Edit Profile',
updateProfile: 'Update Profile',
updating: 'Updating...',
updateSuccess: 'Profile updated successfully',
updateFailed: 'Failed to update profile',
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
@@ -446,8 +455,28 @@ export default {
admin: 'Admin',
user: 'User',
disabled: 'Disabled',
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)',
leaveEmptyToKeep: 'Leave empty to keep current password',
generatePassword: 'Generate random password',
copyPassword: 'Copy password',
creating: 'Creating...',
updating: 'Updating...',
columns: {
user: 'User',
username: 'Username',
wechat: 'WeChat ID',
notes: 'Notes',
role: 'Role',
subscriptions: 'Subscriptions',
balance: 'Balance',
@@ -471,16 +500,6 @@ export default {
none: 'None',
noUsersYet: 'No users yet',
createFirstUser: 'Create your first user to get started.',
email: 'Email',
password: 'Password',
enterEmail: 'Enter email',
enterPassword: 'Enter password',
enterNewPassword: 'Enter new password (optional)',
leaveEmptyToKeep: 'Leave empty to keep current password',
generatePassword: 'Generate random password',
copyPassword: 'Copy password',
creating: 'Creating...',
updating: 'Updating...',
userCreated: 'User created successfully',
userUpdated: 'User updated successfully',
userDeleted: 'User deleted successfully',

View File

@@ -335,6 +335,15 @@ export default {
memberSince: '注册时间',
administrator: '管理员',
user: '用户',
username: '用户名',
wechat: '微信号',
enterUsername: '输入用户名',
enterWechat: '输入微信号',
editProfile: '编辑个人资料',
updateProfile: '更新资料',
updating: '更新中...',
updateSuccess: '资料更新成功',
updateFailed: '资料更新失败',
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
@@ -460,12 +469,38 @@ export default {
deleteUser: '删除用户',
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
searchPlaceholder: '搜索用户...',
searchUsers: '搜索用户...',
roleFilter: '角色筛选',
allRoles: '全部角色',
allStatus: '全部状态',
statusFilter: '状态筛选',
allStatuses: '全部状态',
admin: '管理员',
user: '用户',
disabled: '禁用',
email: '邮箱',
password: '密码',
username: '用户名',
wechat: '微信号',
notes: '备注',
enterEmail: '请输入邮箱',
enterPassword: '请输入密码',
enterUsername: '请输入用户名(选填)',
enterWechat: '请输入微信号(选填)',
enterNotes: '请输入备注(仅管理员可见)',
notesHint: '此备注仅对管理员可见',
enterNewPassword: '请输入新密码(选填)',
leaveEmptyToKeep: '留空则保持原密码不变',
generatePassword: '生成随机密码',
copyPassword: '复制密码',
creating: '创建中...',
updating: '更新中...',
columns: {
user: '用户',
email: '邮箱',
username: '用户名',
wechat: '微信号',
notes: '备注',
role: '角色',
subscriptions: '订阅分组',
balance: '余额',
@@ -474,13 +509,33 @@ export default {
status: '状态',
created: '创建时间',
actions: '操作',
user: '用户',
},
today: '今日',
total: '累计',
noSubscription: '暂无订阅',
daysRemaining: '{days}天',
expired: '已过期',
disableUser: '禁用用户',
enableUser: '启用用户',
viewApiKeys: '查看 API 密钥',
userApiKeys: '用户 API 密钥',
noApiKeys: '此用户暂无 API 密钥',
group: '分组',
none: '无',
noUsersYet: '暂无用户',
createFirstUser: '创建您的第一个用户以开始使用系统',
userCreated: '用户创建成功',
userUpdated: '用户更新成功',
userDeleted: '用户删除成功',
userEnabled: '用户已启用',
userDisabled: '用户已禁用',
failedToLoad: '加载用户列表失败',
failedToCreate: '创建用户失败',
failedToUpdate: '更新用户失败',
failedToDelete: '删除用户失败',
failedToToggle: '更新用户状态失败',
failedToLoadApiKeys: '加载用户 API 密钥失败',
deleteConfirm: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
roles: {
admin: '管理员',
user: '用户',
@@ -492,6 +547,13 @@ export default {
form: {
emailLabel: '邮箱',
emailPlaceholder: '请输入邮箱',
usernameLabel: '用户名',
usernamePlaceholder: '请输入用户名(选填)',
wechatLabel: '微信号',
wechatPlaceholder: '请输入微信号(选填)',
notesLabel: '备注',
notesPlaceholder: '请输入备注(仅管理员可见)',
notesHint: '此备注仅对管理员可见',
passwordLabel: '密码',
passwordPlaceholder: '请输入密码(留空则不修改)',
roleLabel: '角色',
@@ -515,9 +577,7 @@ export default {
userDeletedSuccess: '用户删除成功',
balanceAdjustedSuccess: '余额调整成功',
concurrencyAdjustedSuccess: '并发数调整成功',
failedToLoad: '加载用户列表失败',
failedToSave: '保存用户失败',
failedToDelete: '删除用户失败',
failedToAdjust: '调整失败',
setAllowedGroups: '设置允许分组',
allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。',

View File

@@ -7,6 +7,8 @@
export interface User {
id: number;
username: string;
wechat: string;
notes: string;
email: string;
role: 'admin' | 'user'; // User role for authorization
balance: number; // User balance for API usage
@@ -563,6 +565,9 @@ export interface ApiKeyUsageTrendPoint {
export interface UpdateUserRequest {
email?: string;
password?: string;
username?: string;
wechat?: string;
notes?: string;
role?: 'admin' | 'user';
balance?: number;
concurrency?: number;

View File

@@ -73,6 +73,27 @@
</div>
</template>
<template #cell-username="{ value }">
<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
v-if="value"
:title="value.length > 30 ? value : undefined"
class="text-sm text-gray-600 dark:text-gray-400 block truncate"
>
{{ value.length > 30 ? value.substring(0, 25) + '...' : value }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</div>
</template>
<template #cell-role="{ value }">
<span
:class="[
@@ -293,6 +314,34 @@
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input
v-model="createForm.username"
type="text"
class="input"
: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
v-model="createForm.notes"
rows="3"
class="input"
:placeholder="t('admin.users.enterNotes')"
></textarea>
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
@@ -399,6 +448,34 @@
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input
v-model="editForm.username"
type="text"
class="input"
: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
v-model="editForm.notes"
rows="3"
class="input"
:placeholder="t('admin.users.enterNotes')"
></textarea>
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
@@ -689,6 +766,9 @@ const appStore = useAppStore()
const columns = 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 },
{ 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 },
@@ -751,6 +831,9 @@ const savingAllowedGroups = ref(false)
const createForm = reactive({
email: '',
password: '',
username: '',
wechat: '',
notes: '',
balance: 0,
concurrency: 1
})
@@ -758,6 +841,9 @@ const createForm = reactive({
const editForm = reactive({
email: '',
password: '',
username: '',
wechat: '',
notes: '',
balance: 0,
concurrency: 1
})
@@ -881,6 +967,9 @@ const closeCreateModal = () => {
showCreateModal.value = false
createForm.email = ''
createForm.password = ''
createForm.username = ''
createForm.wechat = ''
createForm.notes = ''
createForm.balance = 0
createForm.concurrency = 1
passwordCopied.value = false
@@ -905,6 +994,9 @@ const handleEdit = (user: User) => {
editingUser.value = user
editForm.email = user.email
editForm.password = ''
editForm.username = user.username || ''
editForm.wechat = user.wechat || ''
editForm.notes = user.notes || ''
editForm.balance = user.balance
editForm.concurrency = user.concurrency
editPasswordCopied.value = false
@@ -926,6 +1018,9 @@ const handleUpdateUser = async () => {
// Build update data - only include password if not empty
const updateData: Record<string, any> = {
email: editForm.email,
username: editForm.username,
wechat: editForm.wechat,
notes: editForm.notes,
balance: editForm.balance,
concurrency: editForm.concurrency
}

View File

@@ -55,11 +55,25 @@
</div>
</div>
<div class="px-6 py-4">
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<svg class="w-4 h-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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<span class="truncate">{{ user?.email }}</span>
<div class="space-y-3">
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<svg class="w-4 h-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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<span class="truncate">{{ user?.email }}</span>
</div>
<div v-if="user?.username" class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<svg class="w-4 h-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="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</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="w-4 h-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>
@@ -81,6 +95,52 @@
</div>
</div>
<!-- Edit Profile Section -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">{{ t('profile.editProfile') }}</h2>
</div>
<div class="px-6 py-6">
<form @submit.prevent="handleUpdateProfile" class="space-y-4">
<div>
<label for="username" class="input-label">
{{ t('profile.username') }}
</label>
<input
id="username"
v-model="profileForm.username"
type="text"
class="input"
:placeholder="t('profile.enterUsername')"
/>
</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') }}
</button>
</div>
</form>
</div>
</div>
<!-- Change Password Section -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
@@ -191,13 +251,25 @@ const passwordForm = ref({
confirm_password: ''
})
const profileForm = ref({
username: '',
wechat: ''
})
const changingPassword = ref(false)
const updatingProfile = ref(false)
const contactInfo = ref('')
onMounted(async () => {
try {
const settings = await authAPI.getPublicSettings()
contactInfo.value = settings.contact_info || ''
// 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)
}
@@ -250,4 +322,23 @@ const handleChangePassword = async () => {
changingPassword.value = false
}
}
const handleUpdateProfile = async () => {
updatingProfile.value = true
try {
const updatedUser = await userAPI.updateProfile({
username: profileForm.value.username,
wechat: profileForm.value.wechat
})
// Update auth store with new user data
authStore.user = updatedUser
appStore.showSuccess(t('profile.updateSuccess'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('profile.updateFailed'))
} finally {
updatingProfile.value = false
}
}
</script>