feat: 用户管理页面添加充值功能
- 后端新增 POST /app-users/:id/recharge API - 前端新增独立的用户充值页面 (/admin/users/:id/recharge) - 充值页面风格与添加用户/编辑用户页面一致 - 支持选择卡密类型和数量进行充值 - 永久会员显示提示不允许充值 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { CreditCard, Eye, Loader2 } 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()
|
||||
|
||||
interface CardType {
|
||||
id: number
|
||||
name: string
|
||||
recharge_type: string
|
||||
value: number
|
||||
value_unit: string
|
||||
price: number
|
||||
description: string
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
balance: number
|
||||
expiry_at: string | null
|
||||
application_id: number
|
||||
application: {
|
||||
id: number
|
||||
name: string
|
||||
billing_type: string
|
||||
}
|
||||
}
|
||||
|
||||
const userId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const user = ref<User | null>(null)
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
|
||||
const form = ref({
|
||||
card_type_id: '',
|
||||
card_quantity: 1,
|
||||
})
|
||||
|
||||
async function fetchUser() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/dev/app-users/${userId.value}`)
|
||||
user.value = data?.user || data
|
||||
if (user.value?.application_id) {
|
||||
fetchCardTypes(user.value.application_id)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
toast.error(t('admin.users.edit.failed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCardTypes(applicationId: number) {
|
||||
try {
|
||||
const data = await api.get<{ card_types: CardType[] }>(`/dev/card-types?application_id=${applicationId}`)
|
||||
cardTypes.value = Array.isArray(data?.card_types) ? data.card_types : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡密类型失败:', error)
|
||||
cardTypes.value = []
|
||||
}
|
||||
}
|
||||
|
||||
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('admin.users.create.permanent')
|
||||
if (ct.recharge_type === 'subscription') {
|
||||
const unitMap: Record<string, string> = {
|
||||
minute: t('admin.users.create.minutes'),
|
||||
hour: t('admin.users.create.hours'),
|
||||
day: t('admin.users.create.days'),
|
||||
month: t('admin.users.create.months'),
|
||||
year: t('admin.users.create.years'),
|
||||
}
|
||||
return `${ct.value} ${unitMap[ct.value_unit] || ct.value_unit}`
|
||||
}
|
||||
return `${ct.value} ${t('admin.users.create.points')}`
|
||||
}
|
||||
|
||||
function formatBalance(user: User) {
|
||||
if (user.balance === -1) {
|
||||
return t('admin.users.expiry.permanent')
|
||||
}
|
||||
if (user.application?.billing_type === 'subscription') {
|
||||
if (!user.expiry_at) return '-'
|
||||
const expiryDate = new Date(user.expiry_at)
|
||||
const now = new Date()
|
||||
if (expiryDate < now) {
|
||||
return t('admin.users.expiry.expired')
|
||||
}
|
||||
const daysLeft = Math.ceil((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return `${daysLeft} ${t('admin.users.expiry.daysLeft')}`
|
||||
}
|
||||
return user.balance?.toFixed(2) || '0.00'
|
||||
}
|
||||
|
||||
async function handleRecharge() {
|
||||
if (!form.value.card_type_id || form.value.card_type_id === 'none') {
|
||||
toast.error(t('admin.users.recharge.cardTypeRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post(`/dev/app-users/${userId.value}/recharge`, {
|
||||
card_type_id: Number(form.value.card_type_id),
|
||||
card_quantity: form.value.card_quantity || 1,
|
||||
})
|
||||
toast.success(t('admin.users.recharge.success'))
|
||||
router.push('/admin/users')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('充值失败:', error)
|
||||
toast.error(error.message || t('admin.users.recharge.failed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.users.recharge.title')"
|
||||
:description="t('admin.users.recharge.pageDesc')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.users.title'), href: '/admin/users' },
|
||||
{ title: user?.username || '', href: `/admin/users/${userId}` },
|
||||
{ title: t('admin.users.recharge.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-if="user" 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">
|
||||
<Eye class="size-5" />
|
||||
{{ t('admin.users.recharge.userInfo') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.users.recharge.userInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.users.edit.usernameLabel') }}</p>
|
||||
<p class="font-medium">{{ user.username }}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.users.edit.emailLabel') }}</p>
|
||||
<p class="font-medium">{{ user.email || '-' }}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.users.edit.app') }}</p>
|
||||
<p class="font-medium">{{ user.application?.name || '-' }}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.users.columns.balance') }}</p>
|
||||
<p class="font-medium" :class="user.balance === -1 ? 'text-green-600' : ''">
|
||||
{{ formatBalance(user) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<CreditCard class="size-5" />
|
||||
{{ t('admin.users.recharge.selectCard') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.users.recharge.selectCardDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div v-if="user.balance === -1" class="rounded-lg border border-yellow-200 bg-yellow-50 dark:border-yellow-900 dark:bg-yellow-950 p-4">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{{ t('admin.users.edit.permanentUserHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="card_type">
|
||||
{{ t('admin.users.recharge.cardType') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.card_type_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="cardTypes.length > 0 ? t('admin.users.recharge.selectCardType') : t('admin.users.recharge.noCardTypes')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="none">
|
||||
{{ t('admin.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('admin.users.recharge.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('admin.users.recharge.cardType') }}</span>
|
||||
<span>{{ selectedCardType.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.recharge.cardValue') }}</span>
|
||||
<span>{{ formatCardTypeValue(selectedCardType) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.recharge.cardQuantity') }}</span>
|
||||
<span>{{ form.card_quantity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Eye class="size-5" />
|
||||
{{ t('admin.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('admin.users.edit.usernameLabel') }}</span>
|
||||
<span>{{ user.username }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.edit.app') }}</span>
|
||||
<span>{{ user.application?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.columns.balance') }}</span>
|
||||
<span>{{ formatBalance(user) }}</span>
|
||||
</div>
|
||||
<div v-if="selectedCardType" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.recharge.title') }}</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
|
||||
v-if="user.balance !== -1"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.card_type_id || form.card_type_id === 'none'"
|
||||
@click="handleRecharge"
|
||||
>
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<CreditCard v-else class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.users.recharge.confirm') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('admin.users.edit.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center py-12">
|
||||
<p class="text-muted-foreground">{{ t('admin.users.edit.fetchFailed') }}</p>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, Monitor, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Ban, CreditCard, Monitor, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { User } from '@/pages/admin/users/data/schema'
|
||||
@@ -22,6 +22,7 @@ export function getColumns(actions: {
|
||||
onToggleStatus: (row: User) => void
|
||||
onDelete: (row: User) => void
|
||||
onManageDevices: (row: User) => void
|
||||
onRecharge: (row: User) => void
|
||||
}, t: Composer['t']): ColumnDef<User>[] {
|
||||
return [
|
||||
{
|
||||
@@ -203,6 +204,10 @@ export function getColumns(actions: {
|
||||
h(Pencil, { class: 'mr-2 h-4 w-4' }),
|
||||
t('common.edit'),
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onRecharge(user) }, () => [
|
||||
h(CreditCard, { class: 'mr-2 h-4 w-4' }),
|
||||
t('common.recharge'),
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onManageDevices(user) }, () => [
|
||||
h(Monitor, { class: 'mr-2 h-4 w-4' }),
|
||||
t('admin.users.devices'),
|
||||
|
||||
@@ -20,6 +20,7 @@ const props = defineProps<Omit<DataTableProps<User>, 'columns'> & {
|
||||
onToggleStatus: (row: User) => void
|
||||
onDelete: (row: User) => void
|
||||
onManageDevices: (row: User) => void
|
||||
onRecharge: (row: User) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,6 +39,7 @@ const columns = computed(() => [
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onDelete: props.onDelete,
|
||||
onManageDevices: props.onManageDevices,
|
||||
onRecharge: props.onRecharge,
|
||||
}, t),
|
||||
])
|
||||
|
||||
|
||||
@@ -138,14 +138,14 @@ async function fetchUsers() {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', String(currentPage.value))
|
||||
params.append('page_size', String(pageSize.value))
|
||||
|
||||
|
||||
if (appFilter.value) {
|
||||
params.append('application_id', appFilter.value)
|
||||
}
|
||||
if (accountStatusFilter.value) {
|
||||
params.append('status', accountStatusFilter.value)
|
||||
}
|
||||
|
||||
|
||||
const data = await api.get<any>(`/dev/app-users?${params.toString()}`)
|
||||
users.value = Array.isArray(data?.users) ? data.users : []
|
||||
total.value = data?.total || 0
|
||||
@@ -174,6 +174,10 @@ function goToUserDevices(user: User) {
|
||||
router.push(`/admin/devices?user_id=${user.id}`)
|
||||
}
|
||||
|
||||
function goToRecharge(user: User) {
|
||||
router.push(`/admin/users/${user.id}/recharge`)
|
||||
}
|
||||
|
||||
async function toggleUserStatus(user: User) {
|
||||
const newStatus = user.status === 'banned' ? 'active' : 'banned'
|
||||
try {
|
||||
@@ -347,6 +351,7 @@ watch([appFilter, statusFilter, accountStatusFilter, lastLoginStartDate, lastLog
|
||||
:on-toggle-status="toggleUserStatus"
|
||||
:on-delete="confirmDeleteUser"
|
||||
:on-manage-devices="goToUserDevices"
|
||||
:on-recharge="goToRecharge"
|
||||
@refresh="fetchUsers"
|
||||
@batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')"
|
||||
|
||||
@@ -438,7 +438,25 @@
|
||||
"expired": "已过期",
|
||||
"daysLeft": "天剩余"
|
||||
},
|
||||
"devices": "设备管理"
|
||||
"devices": "设备管理",
|
||||
"recharge": {
|
||||
"title": "用户充值",
|
||||
"pageDesc": "为用户使用卡密充值",
|
||||
"description": "为用户「{username}」使用卡密充值",
|
||||
"userInfo": "用户信息",
|
||||
"userInfoDesc": "当前用户的基本信息",
|
||||
"selectCard": "选择卡密",
|
||||
"selectCardDesc": "选择卡密类型和数量",
|
||||
"cardType": "卡密类型",
|
||||
"selectCardType": "选择卡密类型",
|
||||
"noCardTypes": "该应用暂无卡密类型",
|
||||
"cardTypeRequired": "请选择卡密类型",
|
||||
"cardQuantity": "充值数量",
|
||||
"cardValue": "充值内容",
|
||||
"confirm": "确认充值",
|
||||
"success": "充值成功",
|
||||
"failed": "充值失败"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "订单管理",
|
||||
|
||||
@@ -155,6 +155,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/admin/users/[id].vue'),
|
||||
meta: { title: '编辑用户 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'users/:id/recharge',
|
||||
name: 'AdminUserRecharge',
|
||||
component: () => import('@/pages/admin/users/[id]/recharge.vue'),
|
||||
meta: { title: '用户充值 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'devices',
|
||||
name: 'AdminDevices',
|
||||
|
||||
Vendored
+14
@@ -453,6 +453,13 @@ declare module 'vue-router/auto-routes' {
|
||||
'/admin/users/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| '/admin/users/[id]/recharge'
|
||||
>,
|
||||
'/admin/users/[id]/recharge': RouteRecordInfo<
|
||||
'/admin/users/[id]/recharge',
|
||||
'/admin/users/:id/recharge',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/admin/users/create': RouteRecordInfo<
|
||||
@@ -1036,6 +1043,13 @@ declare module 'vue-router/auto-routes' {
|
||||
'src/pages/admin/users/[id].vue': {
|
||||
routes:
|
||||
| '/admin/users/[id]'
|
||||
| '/admin/users/[id]/recharge'
|
||||
views:
|
||||
| 'default'
|
||||
}
|
||||
'src/pages/admin/users/[id]/recharge.vue': {
|
||||
routes:
|
||||
| '/admin/users/[id]/recharge'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user