feat(marketing): implement recharge tiers and coupon management across admin, server, and uniapp

这个提交包含在:
xcbyy
2026-10-10 09:22:46 +08:00
父节点 51682fffea
当前提交 d9c8388711
共有 12 个文件被更改,包括 1494 次插入 和 7 次删除

查看文件

@@ -43,6 +43,8 @@ export default {
'menu.finance.records': 'Transaction Ledger',
'menu.finance.withdraw': 'Withdrawal Audit',
'menu.finance.config': 'Rate & Commission',
'menu.finance.rechargeTiers': 'Recharge Tiers',
'menu.finance.coupons': 'Coupons Management',
'menu.safety': 'Safety & Risk Control',
'menu.safety.places': 'Approved Venues',
'menu.safety.auditLog': 'Risk Intercept Log',

查看文件

@@ -43,6 +43,8 @@ export default {
'menu.finance.records': '资金交易流水',
'menu.finance.withdraw': '提现申请审核',
'menu.finance.config': '分销与抽成配置',
'menu.finance.rechargeTiers': '充值赠送阶梯',
'menu.finance.coupons': '平台优惠券管理',
'menu.safety': '履约安全与风控',
'menu.safety.places': '线下商圈场所库',
'menu.safety.auditLog': '场所与内容拦截日志',

查看文件

@@ -42,6 +42,26 @@ const FINANCE: AppRouteRecordRaw = {
roles: ['admin'],
},
},
{
path: 'recharge-tiers',
name: 'FinanceRechargeTiers',
component: () => import('@/views/finance/recharge-tiers/index.vue'),
meta: {
locale: 'menu.finance.rechargeTiers',
requiresAuth: true,
roles: ['admin'],
},
},
{
path: 'coupons',
name: 'FinanceCoupons',
component: () => import('@/views/finance/coupons/index.vue'),
meta: {
locale: 'menu.finance.coupons',
requiresAuth: true,
roles: ['admin'],
},
},
],
};

查看文件

@@ -0,0 +1,371 @@
<template>
<div class="container">
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>财务与分销管理</a-breadcrumb-item>
<a-breadcrumb-item>平台优惠券管理</a-breadcrumb-item>
</a-breadcrumb>
<a-card class="general-card" title="平台营销满减与立减优惠券控制台">
<template #extra>
<a-space>
<a-button type="primary" @click="openCreateModal">
<template #icon><icon-plus /></template>
创建优惠券
</a-button>
<a-button @click="fetchData">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</a-space>
</template>
<a-alert style="margin-bottom: 16px" type="info">
在此处发布的优惠券可供用户在微信小程序端领券中心领取并在下单结算、组局报名支付时抵扣订单金额。支持全品类通用券与品类专属券(美食探店、KTV陪唱等)。
</a-alert>
<a-table
:data="couponList"
:loading="loading"
:pagination="{ pageSize: 10, showTotal: true }"
row-key="id"
>
<template #columns>
<a-table-column title="ID" data-index="id" :width="70" />
<a-table-column title="优惠券名称" :width="220">
<template #cell="{ record }">
<div class="coupon-name-text">{{ record.name }}</div>
<div class="coupon-desc-text">{{ record.description || '(未设置使用说明)' }}</div>
</template>
</a-table-column>
<a-table-column title="减免 / 门槛" :width="180">
<template #cell="{ record }">
<div class="discount-price">¥{{ Number(record.discountAmount).toFixed(2) }}</div>
<div class="threshold-text">
{{ Number(record.minSpend) > 0 ? `满 ¥${Number(record.minSpend).toFixed(2)} 可用` : '无门槛立减' }}
</div>
</template>
</a-table-column>
<a-table-column title="适用范围" :width="130">
<template #cell="{ record }">
<a-tag :color="getCategoryTagColor(record.categoryLimit)">
{{ getCategoryLabel(record.categoryLimit) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="发放 / 领取进度" :width="160">
<template #cell="{ record }">
<div style="font-size: 12px; margin-bottom: 4px">
已领 {{ record.receivedCount }} / 总量 {{ record.totalCount }}
</div>
<a-progress
:percent="Math.min(1, record.receivedCount / (record.totalCount || 1))"
:show-text="false"
size="small"
/>
</template>
</a-table-column>
<a-table-column title="有效期" :width="110">
<template #cell="{ record }">
<span class="valid-days-text">{{ record.validDays }} 天</span>
</template>
</a-table-column>
<a-table-column title="状态" :width="100">
<template #cell="{ record }">
<a-switch
:model-value="record.status === 1"
:loading="statusLoadingId === record.id"
@change="(val) => handleToggleStatus(record, val)"
>
<template #checked>上架</template>
<template #unchecked>停发</template>
</a-switch>
</template>
</a-table-column>
<a-table-column title="操作" :width="150" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEditModal(record)">
<template #icon><icon-edit /></template>
编辑
</a-button>
<a-popconfirm
content="确定删除此优惠券吗?"
@ok="handleDelete(record.id)"
>
<a-button type="text" status="danger" size="small">
<template #icon><icon-delete /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 新增 / 编辑 弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑优惠券' : '创建新优惠券'"
:width="560"
@ok="handleModalSubmit"
@cancel="modalVisible = false"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="优惠券名称" required>
<a-input v-model="formData.name" placeholder="如:新用户专享立减券 / 周末探店AA减免券" maxlength="30" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="减免面额(元)" required>
<a-input-number v-model="formData.discountAmount" :min="1" :precision="2" placeholder="如:20" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="使用门槛最低消费(元)">
<a-input-number v-model="formData.minSpend" :min="0" :precision="2" placeholder="0 表示无门槛" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="发放总量(张)">
<a-input-number v-model="formData.totalCount" :min="1" placeholder="如:1000" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="有效天数(天)">
<a-input-number v-model="formData.validDays" :min="1" placeholder="如:7" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="适用业务品类">
<a-select v-model="formData.categoryLimit" placeholder="选择使用范围">
<a-option value="ALL">全品类通用</a-option>
<a-option value="MEAL">仅限美食探店组局</a-option>
<a-option value="SING">仅限KTV陪唱</a-option>
<a-option value="DRINK">仅限同城微醺/下午茶</a-option>
<a-option value="ESPORTS">仅限线上游戏电竞</a-option>
</a-select>
</a-form-item>
<a-form-item label="使用规则说明">
<a-textarea v-model="formData.description" placeholder="如:不可与其他满减活动叠加,每个订单限用一张" :rows="2" />
</a-form-item>
<a-form-item label="初始发布状态">
<a-radio-group v-model="formData.status">
<a-radio :value="1">立即上架(小程序端立即可领)</a-radio>
<a-radio :value="0">暂存下线</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
interface CouponItem {
id: number;
name: string;
discountAmount: number;
minSpend: number;
validDays: number;
totalCount: number;
receivedCount: number;
categoryLimit: string;
description: string;
status: number;
}
const loading = ref(false);
const couponList = ref<CouponItem[]>([]);
const statusLoadingId = ref<number | null>(null);
const modalVisible = ref(false);
const isEdit = ref(false);
const editingId = ref<number | null>(null);
const defaultFormData = {
name: '',
discountAmount: 20,
minSpend: 50,
validDays: 7,
totalCount: 1000,
categoryLimit: 'ALL',
description: '',
status: 1,
};
const formData = ref({ ...defaultFormData });
const getCategoryLabel = (cat: string) => {
switch (cat) {
case 'MEAL': return '美食探店';
case 'SING': return 'KTV陪唱';
case 'DRINK': return '同城微醺';
case 'ESPORTS': return '线上电竞';
default: return '全品类通用';
}
};
const getCategoryTagColor = (cat: string) => {
switch (cat) {
case 'MEAL': return 'orange';
case 'SING': return 'purple';
case 'DRINK': return 'green';
default: return 'blue';
}
};
const fetchData = async () => {
loading.value = true;
try {
const res: any = await axios.get('/api/admin/coupons');
if (res && res.data && res.data.code === 20000) {
couponList.value = res.data.data;
} else if (Array.isArray(res.data)) {
couponList.value = res.data;
}
} catch (err: any) {
Message.error(err.response?.data?.message || '获取优惠券列表失败');
} finally {
loading.value = false;
}
};
const openCreateModal = () => {
isEdit.value = false;
editingId.value = null;
formData.value = { ...defaultFormData };
modalVisible.value = true;
};
const openEditModal = (record: CouponItem) => {
isEdit.value = true;
editingId.value = record.id;
formData.value = {
name: record.name,
discountAmount: Number(record.discountAmount),
minSpend: Number(record.minSpend),
validDays: Number(record.validDays),
totalCount: Number(record.totalCount),
categoryLimit: record.categoryLimit || 'ALL',
description: record.description || '',
status: record.status,
};
modalVisible.value = true;
};
const handleModalSubmit = async () => {
if (!formData.value.name || !formData.value.name.trim()) {
Message.warning('优惠券名称不能为空');
return;
}
if (!formData.value.discountAmount || formData.value.discountAmount <= 0) {
Message.warning('减免面额必须大于0');
return;
}
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/coupons/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('优惠券修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/coupons', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('优惠券创建成功,已同步至小程序');
modalVisible.value = false;
fetchData();
}
}
} catch (err: any) {
Message.error(err.response?.data?.message || '保存失败');
}
};
const handleToggleStatus = async (record: CouponItem, checked: any) => {
const newStatus = checked ? 1 : 0;
statusLoadingId.value = record.id;
try {
const res: any = await axios.put(`/api/admin/coupons/${record.id}`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
record.status = newStatus;
Message.success(newStatus === 1 ? '优惠券已上架发放' : '优惠券已停用下架');
}
} catch (err: any) {
Message.error(err.response?.data?.message || '切换状态失败');
} finally {
statusLoadingId.value = null;
}
};
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/coupons/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('优惠券已彻底删除');
fetchData();
}
} catch (err: any) {
Message.error(err.response?.data?.message || '删除失败');
}
};
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.container {
padding: 16px 20px;
}
.breadcrumb {
margin-bottom: 16px;
}
.coupon-name-text {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.coupon-desc-text {
font-size: 11px;
color: var(--color-text-3);
margin-top: 2px;
}
.discount-price {
font-size: 16px;
font-weight: 700;
color: #f53f3f;
}
.threshold-text {
font-size: 11px;
color: var(--color-text-3);
}
.valid-days-text {
font-weight: 500;
color: #165dff;
}
</style>

查看文件

@@ -0,0 +1,296 @@
<template>
<div class="container">
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>财务与分销管理</a-breadcrumb-item>
<a-breadcrumb-item>充值赠送阶梯</a-breadcrumb-item>
</a-breadcrumb>
<a-card class="general-card" title="用户钱包充值档位与赠送运营配置">
<template #extra>
<a-space>
<a-button type="primary" @click="openCreateModal">
<template #icon><icon-plus /></template>
新增充值档位
</a-button>
<a-button @click="fetchData">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</a-space>
</template>
<a-alert style="margin-bottom: 16px" type="info">
此处配置的充值金额与赠送额度将即时同步至小程序端个人中心钱包充值界面。支持自定义金额档位、赠送金、推荐标签(如“热门畅享”、“尊享特惠”)及排序权重。
</a-alert>
<a-table
:data="tierList"
:loading="loading"
:pagination="{ pageSize: 10, showTotal: true }"
row-key="id"
>
<template #columns>
<a-table-column title="ID" data-index="id" :width="70" />
<a-table-column title="充值本金" :width="160">
<template #cell="{ record }">
<span class="amount-primary">¥{{ Number(record.amount).toFixed(2) }}</span>
</template>
</a-table-column>
<a-table-column title="加赠金额" :width="160">
<template #cell="{ record }">
<span v-if="Number(record.bonusAmount) > 0" class="bonus-tag">
+ ¥{{ Number(record.bonusAmount).toFixed(2) }}
</span>
<span v-else class="no-bonus-text">无加赠</span>
</template>
</a-table-column>
<a-table-column title="推荐标签" :width="150">
<template #cell="{ record }">
<a-tag v-if="record.tag" color="orangered" size="small">{{ record.tag }}</a-tag>
<span v-else class="no-bonus-text">-</span>
</template>
</a-table-column>
<a-table-column title="到账总价值" :width="160">
<template #cell="{ record }">
<span class="total-val">¥{{ (Number(record.amount) + Number(record.bonusAmount)).toFixed(2) }}</span>
</template>
</a-table-column>
<a-table-column title="显示排序" data-index="sortOrder" :width="100">
<template #cell="{ record }">
<a-badge :count="record.sortOrder" :max-count="999" color="#165dff" />
</template>
</a-table-column>
<a-table-column title="状态" :width="110">
<template #cell="{ record }">
<a-switch
:model-value="record.status === 1"
:loading="statusLoadingId === record.id"
@change="(val) => handleToggleStatus(record, val)"
>
<template #checked>上架</template>
<template #unchecked>下线</template>
</a-switch>
</template>
</a-table-column>
<a-table-column title="操作" :width="150" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEditModal(record)">
<template #icon><icon-edit /></template>
编辑
</a-button>
<a-popconfirm
content="确定删除此充值档位吗?"
@ok="handleDelete(record.id)"
>
<a-button type="text" status="danger" size="small">
<template #icon><icon-delete /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 新增 / 编辑 弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑充值档位' : '新增充值档位'"
:width="500"
@ok="handleModalSubmit"
@cancel="modalVisible = false"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="充值本金(元)" required>
<a-input-number v-model="formData.amount" :min="1" :precision="2" placeholder="如:100" />
</a-form-item>
<a-form-item label="加赠赠送金额(元)">
<a-input-number v-model="formData.bonusAmount" :min="0" :precision="2" placeholder="如:5.00,不送填0" />
</a-form-item>
<a-form-item label="运营角标标签">
<a-input v-model="formData.tag" placeholder="如:热门畅享 / 超值加赠 / 尊享特惠" maxlength="10" />
</a-form-item>
<a-form-item label="排序优先级 (越小越靠前)">
<a-input-number v-model="formData.sortOrder" :min="0" :max="999" />
</a-form-item>
<a-form-item label="初始发布状态">
<a-radio-group v-model="formData.status">
<a-radio :value="1">立即上架(小程序端立即可选)</a-radio>
<a-radio :value="0">暂存下线</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
interface RechargeTierItem {
id: number;
amount: number;
bonusAmount: number;
tag: string;
sortOrder: number;
status: number;
}
const loading = ref(false);
const tierList = ref<RechargeTierItem[]>([]);
const statusLoadingId = ref<number | null>(null);
const modalVisible = ref(false);
const isEdit = ref(false);
const editingId = ref<number | null>(null);
const defaultFormData = {
amount: 100,
bonusAmount: 5,
tag: '热门畅享',
sortOrder: 1,
status: 1,
};
const formData = ref({ ...defaultFormData });
const fetchData = async () => {
loading.value = true;
try {
const res: any = await axios.get('/api/admin/recharge-tiers');
if (res && res.data && res.data.code === 20000) {
tierList.value = res.data.data;
} else if (Array.isArray(res.data)) {
tierList.value = res.data;
}
} catch (err: any) {
Message.error(err.response?.data?.message || '获取充值阶梯失败');
} finally {
loading.value = false;
}
};
const openCreateModal = () => {
isEdit.value = false;
editingId.value = null;
formData.value = { ...defaultFormData };
modalVisible.value = true;
};
const openEditModal = (record: RechargeTierItem) => {
isEdit.value = true;
editingId.value = record.id;
formData.value = {
amount: Number(record.amount),
bonusAmount: Number(record.bonusAmount),
tag: record.tag || '',
sortOrder: record.sortOrder || 0,
status: record.status,
};
modalVisible.value = true;
};
const handleModalSubmit = async () => {
if (!formData.value.amount || formData.value.amount <= 0) {
Message.warning('充值金额必须大于0');
return;
}
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/recharge-tiers/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('充值阶梯修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/recharge-tiers', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('充值阶梯创建成功,已同步至小程序');
modalVisible.value = false;
fetchData();
}
}
} catch (err: any) {
Message.error(err.response?.data?.message || '保存失败');
}
};
const handleToggleStatus = async (record: RechargeTierItem, checked: any) => {
const newStatus = checked ? 1 : 0;
statusLoadingId.value = record.id;
try {
const res: any = await axios.put(`/api/admin/recharge-tiers/${record.id}`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
record.status = newStatus;
Message.success(newStatus === 1 ? '充值档位已上架' : '充值档位已下线');
}
} catch (err: any) {
Message.error(err.response?.data?.message || '切换状态失败');
} finally {
statusLoadingId.value = null;
}
};
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/recharge-tiers/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('充值阶梯已彻底删除');
fetchData();
}
} catch (err: any) {
Message.error(err.response?.data?.message || '删除失败');
}
};
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.container {
padding: 16px 20px;
}
.breadcrumb {
margin-bottom: 16px;
}
.amount-primary {
font-size: 15px;
font-weight: 700;
color: var(--color-text-1);
}
.bonus-tag {
color: #00b42a;
font-weight: 600;
font-size: 13px;
}
.no-bonus-text {
color: var(--color-text-4);
font-size: 12px;
}
.total-val {
font-weight: 600;
color: #165dff;
}
</style>