feat: 用户管理页面添加充值功能

- 后端新增 POST /app-users/:id/recharge API
- 前端新增独立的用户充值页面 (/admin/users/:id/recharge)
- 充值页面风格与添加用户/编辑用户页面一致
- 支持选择卡密类型和数量进行充值
- 永久会员显示提示不允许充值

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-14 11:49:34 +08:00
parent d1228954ea
commit 5bbae23bca
8 changed files with 550 additions and 4 deletions
@@ -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),
])
+7 -2
View File
@@ -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')"
+19 -1
View File
@@ -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": "订单管理",
+6
View File
@@ -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',
+14
View File
@@ -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
}