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.
This commit is contained in:
kate522 committed 2026-07-14 20:13:46 +08:00
1 parent 5d7b138c2c
commit d34c954b83
14 files changed
+936 -132

No files matched your search

@@ -51,21 +51,33 @@
<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-input v-model:value="createForm.code" placeholder="手动填写唯一卡号" />
<n-form-item label="卡类型">
<n-select v-model:value="createForm.type" :options="typeOptions" />
</n-form-item>
<n-grid cols="2" :x-gap="12">
<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="面额">
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
<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="过期时间">
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
<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>
@@ -78,22 +90,42 @@
<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>
<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="CARD" />
<n-input v-model:value="batchForm.prefix" placeholder="AK" />
</n-form-item>
</n-grid-item>
<n-grid-item>
@@ -152,21 +184,40 @@ const filters = reactive({
})
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,
prefix: 'CARD',
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,
@@ -214,9 +265,26 @@ const pagination = computed(() => ({
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '卡号', key: 'code', minWidth: 190, ellipsis: { tooltip: true } },
{ title: '面额', key: 'amount', width: 90 },
{ title: '余额', key: 'balance', width: 90 },
{ 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',
@@ -339,8 +407,13 @@ async function handleCreate() {
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,
},
@@ -363,8 +436,12 @@ async function handleBatchCreate() {
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) : '',
@@ -71,9 +71,9 @@ export const rechargeSchema = {
title: '充值卡免扣',
icon: 'fas fa-ticket-alt',
fields: [
{ key: 'pay_balance_pass_charge_s', label: '启用充值卡免扣', type: 'switch', default: false },
{ key: 'pay_balance_pass_charge_desc', label: '免扣说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
{ key: 'pay_balance_pass_charge_only_password', label: '仅密码免扣', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
{ key: 'pay_balance_pass_charge_s', label: '启用余额卡密兑换', type: 'switch', default: false },
{ key: 'pay_balance_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
{ key: 'pay_balance_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
],
},
{
@@ -18,6 +18,7 @@ export const vipSchema = {
pay_download_speed_vip_2: 0,
pay_vip_pass_charge_s: false,
pay_vip_pass_charge_desc: '',
pay_vip_pass_charge_only_password: true,
vip_renew: true,
vip_renew_desc: '',
vip_renew_price_type: 'discount',
@@ -61,6 +62,7 @@ export const vipSchema = {
{ key: 'pay_download_speed_vip_2', label: '二级会员下载速度(KB/s)', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_vip_pass_charge_s', label: '会员卡密兑换', type: 'switch', default: false },
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_pass_charge_s', value: true } },
{ key: 'pay_vip_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
],
},
{
+59
View File
@@ -560,3 +560,62 @@
box-shadow: none;
border-color: var(--color-line, #e5e7eb);
}
/* Card-pass redeem (balance / VIP) */
.uc-card-pass {
margin: 16px 0 0;
padding: 14px 16px;
border: 1px solid var(--color-line, #e5e7eb);
border-radius: 12px;
background: #fff;
}
.uc-card-pass__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
color: #111827;
margin: 0 0 8px;
}
.uc-card-pass__title i { color: #2563eb; }
.uc-card-pass__desc {
margin: 0 0 10px;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
}
.uc-card-pass__input {
display: block;
width: 100%;
margin: 0 0 8px;
padding: 9px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 13px;
box-sizing: border-box;
}
.uc-card-pass__btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: #2563eb;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.uc-card-pass__btn:hover { background: #1d4ed8; }
.uc-card-pass__btn:disabled { opacity: .6; cursor: not-allowed; }
.uc-card-pass__tip {
margin: 8px 0 0;
font-size: 12px;
color: #059669;
min-height: 1.2em;
}
.uc-card-pass__tip.is-error { color: #b91c1c; }
+68 -1
View File
@@ -442,17 +442,84 @@
});
}
// ===== 卡密兑换(余额 / 会员) =====
function initCardPass() {
var roots = document.querySelectorAll('[data-aksrt-card-pass]');
if (!roots || !roots.length) return;
Array.prototype.forEach.call(roots, function (root) {
var tip = root.querySelector('[data-aksrt-card-tip]');
var busy = false;
function setTip(text, isError) {
if (!tip) return;
tip.textContent = text || '';
tip.classList.toggle('is-error', !!isError);
}
var btn = root.querySelector('[data-aksrt-card-redeem]');
if (!btn) return;
btn.addEventListener('click', function () {
if (busy || btn.disabled) return;
var type = btn.getAttribute('data-type') || root.getAttribute('data-aksrt-card-pass') || '';
var pwInput = root.querySelector('[data-aksrt-card-password]');
var cardInput = root.querySelector('[data-aksrt-card-no]');
var password = pwInput ? String(pwInput.value || '').trim() : '';
var card = cardInput ? String(cardInput.value || '').trim() : '';
if (!password) {
setTip('请输入卡密', true);
if (pwInput) pwInput.focus();
return;
}
if (cardInput && !card) {
setTip('请输入卡号', true);
cardInput.focus();
return;
}
busy = true;
btn.disabled = true;
setTip('正在兑换…');
var body = { password: password, type: type };
if (card) body.card = card;
api('/pay/card-pass/redeem', {
method: 'POST',
body: JSON.stringify(body)
}).then(function (r) {
busy = false;
btn.disabled = false;
if (!r.ok || (r.json && r.json.code && !r.json.success)) {
setTip((r.json && r.json.message) || '兑换失败', true);
return;
}
var data = (r.json && r.json.data) ? r.json.data : r.json;
setTip((data && data.message) || '兑换成功,正在刷新…');
window.location.reload();
}).catch(function () {
busy = false;
btn.disabled = false;
setTip('网络错误,请重试', true);
});
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
initSignin();
initInvite();
initVipShop();
initVipPointsExchange();
initVipPointsExchange();
initCardPass();
});
} else {
initSignin();
initInvite();
initVipShop();
initVipPointsExchange();
initCardPass();
}
})();