feat: add user creation and ban/unban for agent backend

- Backend: POST /agent/users - create user with optional card recharge
- Backend: PUT /agent/users/:id/status - ban/unban user
- Frontend: add create user page for agent (select app, card type)
- Frontend: add ban/unban action in user list with confirm dialog
- Frontend: add actions dropdown column in user data table
- Fix: cloud variables records page i18n key (agent.finance.to -> agent.cloudVariables.records.to)
- Add i18n keys for agent user management (create, ban, unban)
This commit is contained in:
2026-05-11 08:24:30 +08:00
parent 3cea807353
commit 57e1d10689
9 changed files with 793 additions and 7 deletions
@@ -368,7 +368,7 @@ onMounted(async () => {
:placeholder="t('agent.cloudVariables.records.startDate')"
class="w-[180px]"
/>
<span class="text-muted-foreground text-sm">{{ t('agent.finance.to') }}</span>
<span class="text-muted-foreground text-sm">{{ t('agent.cloudVariables.records.to') }}</span>
<DateTimePicker
v-model="dateRange.to"
:placeholder="t('agent.cloudVariables.records.endDate')"
@@ -1,13 +1,23 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, MoreHorizontal } from 'lucide-vue-next'
import { h } from 'vue'
import type { User } from '../data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(t: Composer['t']): ColumnDef<User>[] {
export function getColumns(actions: {
onToggleStatus: (row: User) => void
}, t: Composer['t']): ColumnDef<User>[] {
return [
{
accessorKey: 'username',
@@ -90,5 +100,40 @@ export function getColumns(t: Composer['t']): ColumnDef<User>[] {
}
},
},
{
id: 'actions',
header: () => h('span', { class: 'sr-only' }, t('common.actions')),
cell: ({ row }) => {
const user = row.original
const isBanned = user.status === 'banned'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
]),
),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(user) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isBanned ? t('common.unban') : t('common.ban'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -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']
onToggleStatus: (row: User) => void
}>()
const emit = defineEmits<{
@@ -28,7 +29,9 @@ const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<User>,
...getColumns(t),
...getColumns({
onToggleStatus: props.onToggleStatus,
}, t),
])
const table = generateVueTable<User>({
+319
View File
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { CreditCard, Eye, Loader2, UserPlus } from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: number
name: string
}
interface CardType {
id: number
name: string
billing_type: string
price: number
value: number
duration_days: number
}
const saving = ref(false)
const applications = ref<Application[]>([])
const cardTypes = ref<CardType[]>([])
const form = ref({
username: '',
email: '',
password: '',
application_id: '',
card_type_id: 'none',
card_quantity: 1,
})
async function fetchApplications() {
try {
const data = await api.get<{ apps: Application[] }>('/agent/apps')
applications.value = Array.isArray(data?.apps) ? data.apps : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchCardTypes(applicationId: string) {
if (!applicationId) {
cardTypes.value = []
return
}
try {
const data = await api.get<{ cardTypes: CardType[] }>(`/agent/apps/${applicationId}`)
cardTypes.value = Array.isArray(data?.cardTypes) ? data.cardTypes : []
}
catch (error) {
console.error('获取卡密类型失败:', error)
cardTypes.value = []
}
}
watch(() => form.value.application_id, (newVal) => {
form.value.card_type_id = 'none'
fetchCardTypes(newVal)
})
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
const selectedCardType = computed(() => {
if (form.value.card_type_id && form.value.card_type_id !== 'none') {
return cardTypes.value.find(ct => String(ct.id) === form.value.card_type_id)
}
return null
})
function formatCardTypeValue(ct: CardType) {
if (ct.value === -1)
return t('agent.users.create.permanent')
if (ct.billing_type === 'subscription') {
return `${ct.value} ${t('agent.users.create.days')}`
}
return `${ct.value} ${t('agent.users.create.points')}`
}
const isFormValid = computed(() => {
return form.value.username && form.value.password && form.value.application_id
})
async function handleSave() {
if (!form.value.username) {
toast.error(t('agent.users.create.usernameRequired'))
return
}
if (!form.value.password) {
toast.error(t('agent.users.create.passwordRequired'))
return
}
if (!form.value.application_id) {
toast.error(t('agent.users.create.applicationRequired'))
return
}
saving.value = true
try {
const payload: any = {
username: form.value.username,
email: form.value.email,
password: form.value.password,
application_id: Number(form.value.application_id),
}
if (form.value.card_type_id && form.value.card_type_id !== 'none') {
payload.card_type_id = Number(form.value.card_type_id)
payload.card_quantity = form.value.card_quantity || 1
}
await api.post('/agent/users', payload)
toast.success(t('agent.users.create.success'))
router.push('/agent/users')
}
catch (error: any) {
console.error('创建用户失败:', error)
toast.error(error.message || t('agent.users.create.failed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('agent.users.create.title')"
:description="t('agent.users.create.description')"
:breadcrumbs="[
{ title: t('agent.users.title'), href: '/agent/users' },
{ title: t('agent.users.create.title') },
]"
sticky
>
<div 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.create.basicInfo') }}
</UiCardTitle>
<UiCardDescription>{{ t('agent.users.create.basicInfoDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="application">
{{ t('agent.users.create.application') }}
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('agent.users.create.selectApplication')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="username">
{{ t('agent.users.create.username') }}
</UiLabel>
<UiInput id="username" v-model="form.username" :placeholder="t('agent.users.create.usernamePlaceholder')" />
</div>
<div class="space-y-2">
<UiLabel for="password">
{{ t('agent.users.create.password') }}
</UiLabel>
<UiInput id="password" v-model="form.password" type="password" :placeholder="t('agent.users.create.passwordPlaceholder')" />
</div>
<div class="space-y-2">
<UiLabel for="email">
{{ t('agent.users.create.email') }}
</UiLabel>
<UiInput id="email" v-model="form.email" type="email" :placeholder="t('agent.users.create.emailPlaceholder')" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<CreditCard class="size-5" />
{{ t('agent.users.create.rechargeCard') }}
</UiCardTitle>
<UiCardDescription>{{ t('agent.users.create.rechargeCardDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="card_type">
{{ t('agent.users.create.cardType') }}
</UiLabel>
<UiSelect v-model="form.card_type_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="cardTypes.length > 0 ? t('agent.users.create.selectCardType') : t('agent.users.create.selectApplicationFirst')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="none">
{{ t('agent.users.create.noRecharge') }}
</UiSelectItem>
<UiSelectItem v-for="ct in cardTypes" :key="ct.id" :value="String(ct.id)">
{{ ct.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div v-if="selectedCardType" class="space-y-2">
<UiLabel for="card_quantity">
{{ t('agent.users.create.cardQuantity') }}
</UiLabel>
<UiNumberField v-model="form.card_quantity" :min="1" :max="100" class="max-w-[200px]">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
</div>
<div v-if="selectedCardType" class="rounded-lg border bg-muted/50 p-3 space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.create.cardType') }}</span>
<span>{{ selectedCardType.name }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.create.cardValue') }}</span>
<span>{{ formatCardTypeValue(selectedCardType) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.create.cardQuantity') }}</span>
<span>{{ form.card_quantity }}</span>
</div>
</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.create.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.create.app') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.create.usernameLabel') }}</span>
<span>{{ form.username || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.create.emailLabel') }}</span>
<span>{{ form.email || '-' }}</span>
</div>
<div v-if="selectedCardType" class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('agent.users.create.rechargeCard') }}</span>
<span class="text-primary">{{ selectedCardType.name }} x{{ form.card_quantity }}</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 || !isFormValid"
@click="handleSave"
>
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
<UserPlus v-else class="mr-2 h-4 w-4" />
{{ t('agent.users.create.submit') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('agent.users.create.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
+64 -2
View File
@@ -1,15 +1,19 @@
<script setup lang="ts">
import { Loader2, Users } from 'lucide-vue-next'
import { Ban, Loader2, Plus, Users } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { User } from '@/pages/agent/users/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/agent/users/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
const router = useRouter()
const loading = ref(true)
const users = ref<User[]>([])
@@ -22,6 +26,10 @@ const activeCount = ref(0)
const disabledCount = ref(0)
const bannedCount = ref(0)
const statusDialogOpen = ref(false)
const statusTarget = ref<User | null>(null)
const statusAction = ref<'ban' | 'unban'>('ban')
const serverPagination = computed(() => ({
page: currentPage.value,
pageSize: pageSize.value,
@@ -63,6 +71,34 @@ async function fetchUsers() {
}
}
function goToCreate() {
router.push('/agent/users/create')
}
function toggleUserStatus(user: User) {
statusTarget.value = user
statusAction.value = user.status === 'banned' ? 'unban' : 'ban'
statusDialogOpen.value = true
}
async function handleToggleStatus() {
if (!statusTarget.value) return
const newStatus = statusAction.value === 'ban' ? 'banned' : 'active'
try {
await api.put(`/agent/users/${statusTarget.value.id}/status`, { status: newStatus })
toast.success(t('agent.users.statusUpdateSuccess'))
fetchUsers()
}
catch (error: any) {
console.error('切换用户状态失败:', error)
toast.error(error.message || t('agent.users.statusUpdateFailed'))
}
finally {
statusTarget.value = null
}
}
onMounted(() => {
fetchUsers()
})
@@ -78,6 +114,13 @@ onMounted(() => {
]"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Plus class="mr-2 h-4 w-4" />
{{ t('agent.users.addUser') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
@@ -127,7 +170,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('agent.users.status.banned') }}
</UiCardTitle>
<Users class="size-4 text-muted-foreground" />
<Ban class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -150,11 +193,30 @@ onMounted(() => {
:data="users"
:server-pagination="serverPagination"
:search-filter="searchFilter"
:on-toggle-status="toggleUserStatus"
@refresh="fetchUsers"
@update:search-filter="searchFilter = $event; currentPage = 1; fetchUsers()"
/>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="statusDialogOpen"
:destructive="statusAction === 'ban'"
:confirm-button-text="statusAction === 'ban' ? t('common.ban') : t('common.unban')"
:cancel-button-text="t('common.cancel')"
@confirm="handleToggleStatus"
>
<template #title>
{{ statusAction === 'ban' ? t('agent.users.banUser') : t('agent.users.unbanUser') }}
</template>
<template #description>
{{ statusAction === 'ban'
? t('agent.users.banConfirm', { username: statusTarget?.username })
: t('agent.users.unbanConfirm', { username: statusTarget?.username })
}}
</template>
</ConfirmDialog>
</BasicPage>
</template>
+45 -1
View File
@@ -2767,8 +2767,15 @@
"lastLogin": "Last Login",
"active": "Active",
"totalUsers": "Total Users",
"addUser": "Add User",
"searchPlaceholder": "Search username or email...",
"select": "Select",
"banUser": "Ban User",
"unbanUser": "Unban User",
"banConfirm": "Are you sure you want to ban user {username}?",
"unbanConfirm": "Are you sure you want to unban user {username}?",
"statusUpdateSuccess": "User status updated successfully",
"statusUpdateFailed": "Failed to update user status",
"columns": {
"username": "Username",
"email": "Email",
@@ -2780,6 +2787,42 @@
"active": "Active",
"disabled": "Disabled",
"banned": "Banned"
},
"create": {
"title": "Create User",
"description": "Create a new user under your application",
"basicInfo": "Basic Information",
"basicInfoDesc": "Fill in user basic information",
"application": "Application",
"selectApplication": "Select application",
"username": "Username",
"usernamePlaceholder": "Enter username",
"usernameRequired": "Username is required",
"password": "Password",
"passwordPlaceholder": "Enter password",
"passwordRequired": "Password is required",
"email": "Email",
"emailPlaceholder": "Enter email (optional)",
"applicationRequired": "Please select an application",
"rechargeCard": "Recharge Card",
"rechargeCardDesc": "Recharge with card on creation (optional)",
"cardType": "Card Type",
"selectCardType": "Select card type",
"selectApplicationFirst": "Select application first",
"noRecharge": "No Recharge",
"cardQuantity": "Card Quantity",
"cardValue": "Card Value",
"permanent": "Permanent",
"days": "days",
"points": "points",
"preview": "Preview",
"app": "Application",
"usernameLabel": "Username",
"emailLabel": "Email",
"submit": "Create User",
"cancel": "Cancel",
"success": "User created successfully",
"failed": "Failed to create user"
}
},
"apps": {
@@ -2929,7 +2972,8 @@
"createdAt": "Created At",
"searchPlaceholder": "Search records...",
"startDate": "Start Date",
"endDate": "End Date"
"endDate": "End Date",
"to": "to"
}
}
},
+46 -1
View File
@@ -2117,6 +2117,7 @@
"searchPlaceholder": "搜索记录...",
"startDate": "开始日期",
"endDate": "结束日期",
"to": "至",
"total": "共 {count} 条记录",
"totalRecords": "总记录数",
"maxRecords": "最大 {count} 条",
@@ -2768,8 +2769,15 @@
"lastLogin": "最后登录",
"active": "正常",
"totalUsers": "用户总数",
"addUser": "添加用户",
"searchPlaceholder": "搜索用户名或邮箱...",
"select": "选择",
"banUser": "封禁用户",
"unbanUser": "解封用户",
"banConfirm": "确定要封禁用户 {username} 吗?",
"unbanConfirm": "确定要解封用户 {username} 吗?",
"statusUpdateSuccess": "用户状态更新成功",
"statusUpdateFailed": "用户状态更新失败",
"columns": {
"username": "用户名",
"email": "邮箱",
@@ -2781,6 +2789,42 @@
"active": "正常",
"disabled": "禁用",
"banned": "封禁"
},
"create": {
"title": "创建用户",
"description": "在您的应用下创建新用户",
"basicInfo": "基本信息",
"basicInfoDesc": "填写用户基本信息",
"application": "所属应用",
"selectApplication": "请选择应用",
"username": "用户名",
"usernamePlaceholder": "请输入用户名",
"usernameRequired": "用户名不能为空",
"password": "密码",
"passwordPlaceholder": "请输入密码",
"passwordRequired": "密码不能为空",
"email": "邮箱",
"emailPlaceholder": "请输入邮箱(选填)",
"applicationRequired": "请选择所属应用",
"rechargeCard": "充值卡密",
"rechargeCardDesc": "创建时同时充值卡密(选填)",
"cardType": "卡密类型",
"selectCardType": "请选择卡密类型",
"selectApplicationFirst": "请先选择应用",
"noRecharge": "不充值",
"cardQuantity": "卡密数量",
"cardValue": "卡密面值",
"permanent": "永久",
"days": "天",
"points": "点数",
"preview": "预览",
"app": "应用",
"usernameLabel": "用户名",
"emailLabel": "邮箱",
"submit": "创建用户",
"cancel": "取消",
"success": "用户创建成功",
"failed": "用户创建失败"
}
},
"apps": {
@@ -2930,7 +2974,8 @@
"createdAt": "创建时间",
"searchPlaceholder": "搜索记录...",
"startDate": "开始日期",
"endDate": "结束日期"
"endDate": "结束日期",
"to": "至"
}
}
},
+6
View File
@@ -444,6 +444,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/agent/users/index.vue'),
meta: { title: '用户管理 - 代理商后台' },
},
{
path: 'users/create',
name: 'AgentUserCreate',
component: () => import('@/pages/agent/users/create.vue'),
meta: { title: '创建用户 - 代理商后台' },
},
{
path: 'finance',
name: 'AgentFinance',