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>