Files
wordpress-theme-aksrt/assets/admin/src/pages/payment/PaymentChargeCard.vue
T
kate522 d34c954b83 feat(pay): card-pass redeem for VIP and balance (zibll-style)
Rewrite charge-card into typed single-use card_pass inventory with public redeem REST, user-center UI for balance/VIP, admin type-aware create/batch, and CAS mark-used fulfillment via Credits/activate_vip.
2026-07-14 20:13:46 +08:00

531 lines
16 KiB
Vue

<template>
<div class="aksrt-page-container">
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
<div class="aksrt-charge-card-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="操作台">
<n-space justify="space-between" wrap>
<n-space>
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
<n-button @click="batchVisible = true">批量生成</n-button>
</n-space>
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
</n-space>
</n-card>
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-space>
</n-card>
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
<n-form :model="createForm" label-placement="top">
<n-form-item label="卡类型">
<n-select v-model:value="createForm.type" :options="typeOptions" />
</n-form-item>
<n-form-item label="卡密">
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡密" />
</n-form-item>
<n-form-item v-if="createForm.type === 'balance'" label="面额">
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="VIP 等级">
<n-select v-model:value="createForm.level" :options="levelOptions" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="天数(0=永久)">
<n-input-number v-model:value="createForm.days" :min="0" :max="3650" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item v-if="createForm.type === 'credits'" label="积分">
<n-input-number v-model:value="createForm.points" :min="1" style="width: 100%;" />
</n-form-item>
<n-form-item label="过期时间">
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="createVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
</n-space>
</n-form>
</n-modal>
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
<n-form :model="batchForm" label-placement="top">
<n-form-item label="卡类型">
<n-select v-model:value="batchForm.type" :options="typeOptions" />
</n-form-item>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="数量">
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item v-if="batchForm.type === 'balance'">
<n-form-item label="面额">
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item v-if="batchForm.type === 'credits'">
<n-form-item label="积分">
<n-input-number v-model:value="batchForm.points" :min="1" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid v-if="batchForm.type === 'vip'" cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="VIP 等级">
<n-select v-model:value="batchForm.level" :options="levelOptions" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="天数(0=永久)">
<n-input-number v-model:value="batchForm.days" :min="0" :max="3650" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="前缀">
<n-input v-model:value="batchForm.prefix" placeholder="AK" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="随机长度">
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="过期时间">
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="batchVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
</n-space>
</n-form>
</n-modal>
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const rows = ref([])
const createVisible = ref(false)
const batchVisible = ref(false)
const drawerVisible = ref(false)
const currentCardId = ref(null)
const submittingCreate = ref(false)
const submittingBatch = ref(false)
const stats = reactive({
total: 0,
active: 0,
used: 0,
total_amount: 0,
used_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
dateRange: null,
})
const createForm = reactive({
type: 'balance',
code: '',
amount: 10,
level: 1,
days: 30,
points: 100,
expiresAt: null,
note: '',
})
const batchForm = reactive({
type: 'balance',
count: 20,
amount: 10,
level: 1,
days: 30,
points: 100,
prefix: 'AK',
length: 12,
expiresAt: null,
note: '',
})
const typeOptions = [
{ label: '余额卡', value: 'balance' },
{ label: '会员卡', value: 'vip' },
{ label: '积分卡', value: 'credits' },
]
const levelOptions = [
{ label: 'VIP1', value: 1 },
{ label: 'VIP2', value: 2 },
]
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
})
const statCards = [
{ key: 'total', label: '总卡数' },
{ key: 'active', label: '未使用' },
{ key: 'used', label: '已使用' },
{ key: 'total_amount', label: '总面额' },
{ key: 'used_amount', label: '已兑换金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '未使用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已过期', value: 'expired' },
{ label: '已禁用', value: 'disabled' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '卡密', key: 'code', minWidth: 170, ellipsis: { tooltip: true } },
{
title: '类型',
key: 'type_label',
width: 90,
render: (row) => row.type_label || row.type || '-',
},
{
title: '面额/权益',
key: 'amount',
width: 120,
render: (row) => {
if (row.type === 'vip') {
const days = Number(row.days || 0)
return `VIP${row.level || 1} / ${days > 0 ? `${days}天` : '永久'}`
}
if (row.type === 'credits') return `${row.points || row.amount || 0} 积分`
return row.amount
},
},
{
title: '状态',
key: 'status_label',
width: 100,
render: (row) => {
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '使用人',
key: 'used_by_user',
width: 160,
render: (row) => row.used_by_user?.display_name || '-',
},
{ title: '创建时间', key: 'created_at', width: 170 },
{
title: '过期时间',
key: 'expires_at',
width: 170,
render: (row) => row.expires_at || '-',
},
{
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
},
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => {
const actions = [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]
if (row.status === 'active') {
actions.push(h(NButton, {
size: 'tiny',
type: 'warning',
quaternary: true,
onClick: () => handleDisable(row),
}, () => '禁用'))
}
return actions
}),
},
]
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
const seconds = `${date.getSeconds()}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function buildParams(page = pager.page, pageSize = pager.pageSize) {
const params = {
page,
per_page: pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
const [start, end] = filters.dateRange
params.date_from = formatDateValue(start).slice(0, 10)
params.date_to = formatDateValue(end).slice(0, 10)
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/charge-cards', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
} catch (error) {
message.error(error?.message || '加载充值卡台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.dateRange = null
pager.page = 1
loadData()
}
async function handleCreate() {
submittingCreate.value = true
try {
await api.request('/payment/charge-cards', {
method: 'POST',
data: {
type: createForm.type,
code: createForm.code,
password: createForm.code,
amount: createForm.amount,
level: createForm.level,
days: createForm.days,
points: createForm.points,
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
note: createForm.note,
},
})
message.success('充值卡已创建')
createVisible.value = false
createForm.code = ''
createForm.note = ''
await loadData()
} catch (error) {
message.error(error?.message || '创建失败')
} finally {
submittingCreate.value = false
}
}
async function handleBatchCreate() {
submittingBatch.value = true
try {
await api.request('/payment/charge-cards/batch', {
method: 'POST',
data: {
type: batchForm.type,
count: batchForm.count,
amount: batchForm.amount,
level: batchForm.level,
days: batchForm.days,
points: batchForm.points,
prefix: batchForm.prefix,
length: batchForm.length,
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
note: batchForm.note,
},
})
message.success('充值卡已批量生成')
batchVisible.value = false
await loadData()
} catch (error) {
message.error(error?.message || '批量生成失败')
} finally {
submittingBatch.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用充值卡 ${row.code} 吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
message.success('充值卡已禁用')
await loadData()
} catch (error) {
message.error(error?.message || '禁用失败')
}
},
})
}
function openDrawer(id) {
currentCardId.value = id
drawerVisible.value = true
}
async function handleExport() {
try {
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
const items = res?.data?.items || []
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
const lines = items.map((row) => [
row.id,
row.code,
row.amount,
row.balance,
row.status_label,
row.used_by_user?.display_name || '',
row.created_at || '',
row.expires_at || '',
row.note || '',
])
downloadCsv('charge-cards.csv', [header, ...lines])
message.success('导出完成')
} catch (error) {
message.error(error?.message || '导出失败')
}
}
function downloadCsv(filename, rows) {
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.aksrt-charge-card-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-charge-card-page__card {
margin-top: 16px;
}
</style>