feat: add agent user edit, card type permission check and balance deduction

- Backend: check AgentApplicationCardType.can_generate before allowing card recharge
- Backend: deduct agent balance and record consumption when creating user with card
- Backend: add GET /agent/users/:id and PUT /agent/users/:id for user edit
- Backend: extract checkAgentUserPermission helper, refactor handleUpdateUserStatus
- Frontend: add user edit page ([id].vue) with username/email/password
- Frontend: add edit action in user list dropdown menu
- Frontend: add route for agent user edit page
- Add i18n keys for agent user edit
This commit is contained in:
2026-05-11 08:48:02 +08:00
parent 57e1d10689
commit a76a30ee78
8 changed files with 374 additions and 22 deletions
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { Check, Eye, Loader2, UserPlus } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const userId = computed(() => route.params.id as string)
const loading = ref(true)
const saving = ref(false)
const form = ref({
username: '',
email: '',
password: '',
})
async function fetchUser() {
loading.value = true
try {
const data = await api.get<any>(`/agent/users/${userId.value}`)
const user = data?.user || data
if (user) {
form.value.username = user.username || ''
form.value.email = user.email || ''
}
}
catch (error) {
console.error('获取用户信息失败:', error)
toast.error(t('agent.users.edit.failed'))
}
finally {
loading.value = false
}
}
async function handleSave() {
if (!form.value.username) {
toast.error(t('agent.users.edit.usernameRequired'))
return
}
saving.value = true
try {
const payload: any = {
username: form.value.username,
email: form.value.email,
}
if (form.value.password) {
payload.password = form.value.password
}
await api.put(`/agent/users/${userId.value}`, payload)
toast.success(t('agent.users.edit.success'))
router.push('/agent/users')
}
catch (error: any) {
console.error('更新用户失败:', error)
toast.error(error.message || t('agent.users.edit.failed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchUser()
})
</script>
<template>
<BasicPage
:title="t('agent.users.edit.title')"
:description="t('agent.users.edit.basicInfoDesc')"
:breadcrumbs="[
{ title: t('agent.users.title'), href: '/agent/users' },
{ title: t('agent.users.edit.title') },
]"
sticky
>
<div v-if="loading" class="flex items-center justify-center py-12">
<Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="space-y-6">
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<UserPlus class="size-5" />
{{ t('agent.users.edit.basicInfo') }}
</UiCardTitle>
<UiCardDescription>{{ t('agent.users.edit.basicInfoDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="username">
{{ t('agent.users.edit.username') }}
</UiLabel>
<UiInput id="username" v-model="form.username" :placeholder="t('agent.users.edit.usernamePlaceholder')" />
</div>
<div class="space-y-2">
<UiLabel for="email">
{{ t('agent.users.edit.email') }}
</UiLabel>
<UiInput id="email" v-model="form.email" type="email" :placeholder="t('agent.users.edit.emailPlaceholder')" />
</div>
<div class="space-y-2">
<UiLabel for="password">
{{ t('agent.users.edit.password') }}
</UiLabel>
<UiInput id="password" v-model="form.password" type="password" :placeholder="t('agent.users.edit.passwordPlaceholder')" />
<p class="text-xs text-muted-foreground">
{{ t('agent.users.edit.passwordHint') }}
</p>
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Eye class="size-5" />
{{ t('agent.users.edit.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.edit.usernameLabel') }}</span>
<span>{{ form.username || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.edit.emailLabel') }}</span>
<span>{{ form.email || '-' }}</span>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardContent class="pt-6">
<div class="flex flex-col gap-3">
<UiButton
class="w-full"
size="lg"
:disabled="saving || !form.username"
@click="handleSave"
>
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
<Check v-else class="mr-2 h-4 w-4" />
{{ t('agent.users.edit.saveBtn') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('agent.users.edit.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,7 +1,7 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, MoreHorizontal } from 'lucide-vue-next'
import { Ban, MoreHorizontal, Pencil } from 'lucide-vue-next'
import { h } from 'vue'
import type { User } from '../data/schema'
@@ -16,6 +16,7 @@ import {
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onEdit: (row: User) => void
onToggleStatus: (row: User) => void
}, t: Composer['t']): ColumnDef<User>[] {
return [
@@ -122,6 +123,10 @@ export function getColumns(actions: {
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onEdit(user) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
t('common.edit'),
]),
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(user) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isBanned ? t('common.unban') : t('common.ban'),
@@ -17,6 +17,7 @@ import DataTableToolbar from '@/pages/agent/users/components/data-table-toolbar.
const props = defineProps<Omit<DataTableProps<User>, 'columns'> & {
searchFilter?: string
serverPagination?: DataTableProps<User>['serverPagination']
onEdit: (row: User) => void
onToggleStatus: (row: User) => void
}>()
@@ -30,6 +31,7 @@ const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<User>,
...getColumns({
onEdit: props.onEdit,
onToggleStatus: props.onToggleStatus,
}, t),
])
+5
View File
@@ -75,6 +75,10 @@ function goToCreate() {
router.push('/agent/users/create')
}
function goToEdit(user: User) {
router.push(`/agent/users/${user.id}`)
}
function toggleUserStatus(user: User) {
statusTarget.value = user
statusAction.value = user.status === 'banned' ? 'unban' : 'ban'
@@ -193,6 +197,7 @@ onMounted(() => {
:data="users"
:server-pagination="serverPagination"
:search-filter="searchFilter"
:on-edit="goToEdit"
:on-toggle-status="toggleUserStatus"
@refresh="fetchUsers"
@update:search-filter="searchFilter = $event; currentPage = 1; fetchUsers()"
+20
View File
@@ -2823,6 +2823,26 @@
"cancel": "Cancel",
"success": "User created successfully",
"failed": "Failed to create user"
},
"edit": {
"title": "Edit User",
"basicInfo": "Basic Information",
"basicInfoDesc": "Modify user basic information",
"username": "Username",
"usernamePlaceholder": "Enter username",
"usernameRequired": "Username is required",
"email": "Email",
"emailPlaceholder": "Enter email (optional)",
"password": "Password",
"passwordPlaceholder": "Leave empty to keep unchanged",
"passwordHint": "Leave empty to keep the current password",
"preview": "Preview",
"usernameLabel": "Username",
"emailLabel": "Email",
"saveBtn": "Save Changes",
"cancel": "Cancel",
"success": "User updated successfully",
"failed": "Failed to load user info"
}
},
"apps": {
+20
View File
@@ -2825,6 +2825,26 @@
"cancel": "取消",
"success": "用户创建成功",
"failed": "用户创建失败"
},
"edit": {
"title": "编辑用户",
"basicInfo": "基本信息",
"basicInfoDesc": "修改用户基本信息",
"username": "用户名",
"usernamePlaceholder": "请输入用户名",
"usernameRequired": "用户名不能为空",
"email": "邮箱",
"emailPlaceholder": "请输入邮箱(选填)",
"password": "密码",
"passwordPlaceholder": "留空则不修改",
"passwordHint": "留空则不修改密码",
"preview": "预览",
"usernameLabel": "用户名",
"emailLabel": "邮箱",
"saveBtn": "保存修改",
"cancel": "取消",
"success": "用户信息更新成功",
"failed": "获取用户信息失败"
}
},
"apps": {
+6
View File
@@ -450,6 +450,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/agent/users/create.vue'),
meta: { title: '创建用户 - 代理商后台' },
},
{
path: 'users/:id',
name: 'AgentUserEdit',
component: () => import('@/pages/agent/users/[id].vue'),
meta: { title: '编辑用户 - 代理商后台' },
},
{
path: 'finance',
name: 'AgentFinance',