Files
jczxw-pc/app/pages/console/coupons.vue
2026-04-23 16:30:57 +08:00

659 lines
18 KiB
Vue

<template>
<div class="space-y-4">
<a-page-header title="优惠券" sub-title="可用优惠与使用记录">
<template #extra>
<a-input
v-model:value="codeInput"
placeholder="输入兑换码"
class="w-48"
allow-clear
>
<template #suffix>
<a-button type="link" size="small" style="padding: 0" :loading="redeeming" @click="handleRedeem">
兑换
</a-button>
</template>
</a-input>
</template>
</a-page-header>
<!-- 统计概要 -->
<a-row :gutter="[16, 16]">
<a-col :xs="8" :md="4" v-for="stat in stats" :key="stat.label">
<div class="mini-stat" :class="stat.color">
<div class="mini-stat-value">{{ stat.value }}</div>
<div class="mini-stat-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="card">
<a-spin :spinning="loading">
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" @change="onTabChange">
<a-tab-pane key="available" tab="可用优惠券" />
<a-tab-pane key="used" tab="已使用" />
<a-tab-pane key="expired" tab="已过期" />
</a-tabs>
<!-- 可用优惠券列表 -->
<div v-if="activeTab === 'available'" class="coupon-list">
<div v-if="availableCoupons.length === 0" class="coupon-empty">
<a-empty description="暂无可用优惠券">
<template #image>
<div class="empty-icon">🎫</div>
</template>
</a-empty>
</div>
<div v-else class="coupon-grid">
<div
v-for="coupon in availableCoupons"
:key="coupon.id"
class="coupon-card"
:class="coupon.typeClass"
>
<div class="coupon-left">
<div class="coupon-amount">
<span class="coupon-prefix">{{ coupon.discountType === 'percent' ? '' : '¥' }}</span>
<span class="coupon-number">{{ coupon.amount }}</span>
<span class="coupon-suffix" v-if="coupon.discountType === 'percent'">%</span>
</div>
<div class="coupon-condition">{{ coupon.condition }}</div>
</div>
<div class="coupon-divider">
<div class="divider-circle top" />
<div class="divider-line" />
<div class="divider-circle bottom" />
</div>
<div class="coupon-right">
<div class="coupon-name">{{ coupon.name }}</div>
<div class="coupon-scope">{{ coupon.scope }}</div>
<div class="coupon-expire">
<ClockCircleOutlined style="font-size: 11px; margin-right: 3px" />
{{ coupon.expireText }}
</div>
<a-button
size="small"
class="coupon-use-btn"
@click="handleUse(coupon)"
>
立即使用
</a-button>
</div>
</div>
</div>
</div>
<!-- 已使用列表 -->
<div v-if="activeTab === 'used'" class="coupon-list">
<div v-if="usedCoupons.length === 0" class="coupon-empty">
<a-empty description="暂无使用记录" />
</div>
<a-table
v-else
:data-source="usedCoupons"
:pagination="false"
size="middle"
:row-key="(r: any) => r.id"
>
<a-table-column title="优惠券" key="name" width="200">
<template #default="{ record }">
<span class="used-name">{{ record.name }}</span>
</template>
</a-table-column>
<a-table-column title="面额" key="amount" width="120">
<template #default="{ record }">
<span class="used-amount">
{{ record.discountType === 'percent' ? `${record.amount}%` : `¥${record.amount}` }}
</span>
</template>
</a-table-column>
<a-table-column title="适用范围" key="scope" width="180">
<template #default="{ record }">
{{ record.scope }}
</template>
</a-table-column>
<a-table-column title="使用时间" key="usedAt" width="180">
<template #default="{ record }">
{{ record.usedAt || '-' }}
</template>
</a-table-column>
<a-table-column title="关联订单" key="orderNo" width="200">
<template #default="{ record }">
<span v-if="record.orderNo" class="text-gray-500">{{ record.orderNo }}</span>
<span v-else>-</span>
</template>
</a-table-column>
</a-table>
</div>
<!-- 已过期列表 -->
<div v-if="activeTab === 'expired'" class="coupon-list">
<div v-if="expiredCoupons.length === 0" class="coupon-empty">
<a-empty description="暂无过期优惠券" />
</div>
<div v-else class="expired-grid">
<div v-for="coupon in expiredCoupons" :key="coupon.id" class="expired-card">
<div class="expired-left">
<div class="expired-amount">
{{ coupon.discountType === 'percent' ? `${coupon.amount}%` : `¥${coupon.amount}` }}
</div>
</div>
<div class="expired-right">
<div class="expired-name">{{ coupon.name }}</div>
<div class="expired-reason">已过期 · {{ coupon.expireText }}</div>
</div>
</div>
</div>
</div>
<!-- 引导提示 -->
<div v-if="activeTab === 'available'" class="guide-banner mt-4">
<div class="guide-icon">🎁</div>
<div class="guide-text">
<div class="guide-title">还没有优惠券?</div>
<div class="guide-desc">参与活动、充值会员或关注公众号获取更多优惠</div>
</div>
<a-button type="link" @click="navigateTo('/market')">
前往应用商店
</a-button>
</div>
</a-spin>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { ClockCircleOutlined, GiftOutlined } from '@ant-design/icons-vue'
import { getUserInfo } from '@/api/layout'
import { pageShopUserCoupon, listShopUserCoupon, addShopUserCoupon } from '@/api/shop/shopUserCoupon'
import { listShopCoupon } from '@/api/shop/shopCoupon'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
import type { ShopCoupon } from '@/api/shop/shopCoupon/model'
definePageMeta({ layout: 'console' })
// ─── 状态 ────────────────────────────────────────────────────
const loading = ref(false)
const activeTab = ref('available')
const codeInput = ref('')
const redeeming = ref(false)
const currentUserId = ref<number | null>(null)
// ─── 优惠券类型映射 ──────────────────────────────────────────
const TYPE_MAP: Record<number, { discountType: 'fixed' | 'percent'; amount: number; typeClass: string }> = {
10: { discountType: 'fixed', amount: 0, typeClass: '' }, // 满减券
20: { discountType: 'percent', amount: 0, typeClass: 'blue' }, // 折扣券
30: { discountType: 'fixed', amount: 0, typeClass: 'green' }, // 免费券
}
const SCOPE_MAP: Record<number, string> = {
10: '全部商品',
20: '指定商品',
30: '指定分类',
}
// ─── 数据转换 ────────────────────────────────────────────────
function transformCoupon(raw: ShopUserCoupon) {
const typeInfo = TYPE_MAP[raw.type || 10] || TYPE_MAP[10]
let amount = typeInfo.amount
if (raw.type === 10 && raw.reducePrice) {
amount = Number(raw.reducePrice)
} else if (raw.type === 20 && raw.discount) {
amount = raw.discount
}
const minPrice = raw.minPrice ? Number(raw.minPrice) : 0
const condition = minPrice > 0 ? `${minPrice}元可用` : '无门槛'
const scope = SCOPE_MAP[raw.applyRange || 10] || '全部商品'
const expireText = formatExpire(raw.startTime, raw.endTime)
return {
id: String(raw.id || ''),
name: raw.name || '优惠券',
amount,
discountType: typeInfo.discountType,
condition,
scope,
expireText,
typeClass: typeInfo.typeClass,
usedAt: raw.useTime || '',
orderNo: raw.orderNo || '',
status: raw.status as number,
}
}
function formatExpire(start?: string, end?: string) {
if (!end) return '永久有效'
const e = new Date(end)
const now = new Date()
const diff = e.getTime() - now.getTime()
const days = Math.ceil(diff / 86400000)
if (days <= 0) return '已过期'
if (days <= 7) return `${days}天后过期`
if (days <= 30) return `${Math.ceil(days / 7)}周后过期`
return end.replace(/T.*/, '')
}
// ─── 数据列表 ────────────────────────────────────────────────
const allCoupons = ref<ReturnType<typeof transformCoupon>[]>([])
const availableCoupons = computed(() => allCoupons.value.filter(c => c.status === 0))
const usedCoupons = computed(() => allCoupons.value.filter(c => c.status === 1))
const expiredCoupons = computed(() => allCoupons.value.filter(c => c.status === 2))
const stats = computed(() => [
{ label: '可用', value: String(availableCoupons.value.length), color: 'green' },
{ label: '已使用', value: String(usedCoupons.value.length), color: 'blue' },
{ label: '已过期', value: String(expiredCoupons.value.length), color: 'gray' },
])
// ─── 加载数据 ────────────────────────────────────────────────
async function ensureUser() {
if (currentUserId.value) return
try {
const user = await getUserInfo()
currentUserId.value = user.userId ?? null
} catch {
// fallback to localStorage
if (import.meta.client) {
const uid = localStorage.getItem('UserId')
if (uid) currentUserId.value = Number(uid)
}
}
}
async function loadCoupons() {
loading.value = true
try {
await ensureUser()
const userId = currentUserId.value
if (!userId) {
message.warning('请先登录')
return
}
const res = await pageShopUserCoupon({
userId,
page: 1,
limit: 200,
})
const list = res?.list || []
allCoupons.value = list.map(transformCoupon)
} catch (e) {
console.error('加载优惠券失败', e)
// 不阻塞页面,显示空状态即可
} finally {
loading.value = false
}
}
function onTabChange() {
// Tab 切换不需要重新加载,数据已全部获取
}
// ─── 兑换功能 ────────────────────────────────────────────────
async function handleRedeem() {
const code = codeInput.value?.trim()
if (!code) {
message.warning('请输入兑换码')
return
}
redeeming.value = true
try {
await ensureUser()
const userId = currentUserId.value
// 通过兑换码查找对应优惠券模板
const coupons = await listShopCoupon({ keywords: code })
if (!coupons || coupons.length === 0) {
message.error('兑换码无效,请检查后重试')
return
}
const couponTemplate = coupons[0]
if (couponTemplate.status === 1 || couponTemplate.enabled === '0') {
message.error('该优惠券已停用')
return
}
// 检查是否已领完
if (couponTemplate.totalCount !== -1 && couponTemplate.issuedCount !== undefined && couponTemplate.issuedCount >= couponTemplate.totalCount) {
message.error('该优惠券已被领完')
return
}
// 检查是否已领取过
if (couponTemplate.limitPerUser !== -1) {
const myCoupons = await listShopUserCoupon({ userId: userId!, keywords: couponTemplate.name })
if (myCoupons && myCoupons.length >= (couponTemplate.limitPerUser || 1)) {
message.warning('您已领取过该优惠券,每人限领 ' + couponTemplate.limitPerUser + ' 张')
return
}
}
// 领取优惠券
await addShopUserCoupon({
couponId: couponTemplate.id,
userId: userId!,
name: couponTemplate.name,
description: couponTemplate.description,
type: couponTemplate.type,
reducePrice: couponTemplate.reducePrice,
discount: couponTemplate.discount,
minPrice: couponTemplate.minPrice,
applyRange: couponTemplate.applyRange,
applyRangeConfig: couponTemplate.applyRangeConfig,
startTime: couponTemplate.startTime as string | undefined,
endTime: couponTemplate.endTime as string | undefined,
status: 0,
obtainType: 10, // 主动领取
obtainSource: '兑换码领取',
})
message.success('兑换成功!')
codeInput.value = ''
await loadCoupons()
} catch (e) {
console.error('兑换失败', e)
message.error(e instanceof Error ? e.message : '兑换失败,请稍后重试')
} finally {
redeeming.value = false
}
}
// ─── 使用优惠券 ──────────────────────────────────────────────
function handleUse(coupon: { scope: string }) {
navigateTo('/market')
}
// ─── 初始化 ──────────────────────────────────────────────────
onMounted(() => {
loadCoupons()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
/* 迷你统计 */
.mini-stat {
padding: 14px;
border-radius: 10px;
border: 1px solid transparent;
text-align: center;
}
.mini-stat.green { background: #f0fdf4; border-color: #bbf7d0; }
.mini-stat.blue { background: #eff6ff; border-color: #dbeafe; }
.mini-stat.gray { background: #f9fafb; border-color: #e5e7eb; }
.mini-stat-value {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
}
.mini-stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 列表容器 */
.coupon-list { min-height: 200px; }
.coupon-empty {
padding: 60px 0;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 8px;
}
/* 优惠券卡片 */
.coupon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
@media (max-width: 480px) {
.coupon-grid {
grid-template-columns: 1fr;
}
}
.coupon-card {
display: flex;
border-radius: 12px;
overflow: hidden;
background: #fff;
border: 1px solid #f0f0f0;
transition: all 0.2s;
}
.coupon-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 左侧金额区 */
.coupon-left {
width: 120px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 12px;
background: linear-gradient(135deg, #f5f3ff, #ede9fe);
}
.coupon-card.blue .coupon-left {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
}
.coupon-card.green .coupon-left {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
}
.coupon-card.orange .coupon-left {
background: linear-gradient(135deg, #fff7ed, #fed7aa);
}
.coupon-amount {
display: flex;
align-items: baseline;
line-height: 1;
}
.coupon-prefix {
font-size: 14px;
font-weight: 600;
color: #4f46e5;
}
.coupon-card.blue .coupon-prefix { color: #3b82f6; }
.coupon-card.green .coupon-prefix { color: #16a34a; }
.coupon-card.orange .coupon-prefix { color: #ea580c; }
.coupon-number {
font-size: 32px;
font-weight: 700;
color: #4f46e5;
}
.coupon-card.blue .coupon-number { color: #3b82f6; }
.coupon-card.green .coupon-number { color: #16a34a; }
.coupon-card.orange .coupon-number { color: #ea580c; }
.coupon-suffix {
font-size: 14px;
font-weight: 600;
color: #4f46e5;
}
.coupon-card.blue .coupon-suffix { color: #3b82f6; }
.coupon-card.green .coupon-suffix { color: #16a34a; }
.coupon-card.orange .coupon-suffix { color: #ea580c; }
.coupon-condition {
font-size: 11px;
color: rgba(0, 0, 0, 0.4);
margin-top: 4px;
}
/* 分割线 */
.coupon-divider {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 2px;
position: relative;
}
.divider-circle {
width: 14px;
height: 14px;
border-radius: 50%;
background: #f9fafb;
border: 1px solid #f0f0f0;
flex-shrink: 0;
}
.divider-circle.top { margin-bottom: -8px; z-index: 1; }
.divider-circle.bottom { margin-top: -8px; z-index: 1; }
.divider-line {
width: 1px;
flex: 1;
border-left: 1px dashed #e0e0e0;
}
/* 右侧信息 */
.coupon-right {
flex: 1;
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: center;
}
.coupon-name {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.coupon-scope {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 4px;
}
.coupon-expire {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
margin-bottom: 10px;
}
.coupon-use-btn {
align-self: flex-start;
border-radius: 6px;
font-size: 12px;
}
/* 已使用列表 */
.used-name {
font-weight: 500;
color: rgba(0, 0, 0, 0.75);
}
.used-amount {
font-weight: 600;
color: #4f46e5;
}
/* 已过期卡片 */
.expired-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.expired-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
opacity: 0.65;
}
.expired-left {
font-size: 18px;
font-weight: 700;
color: #9ca3af;
min-width: 60px;
text-align: center;
}
.expired-right { flex: 1; min-width: 0; }
.expired-name {
font-size: 13px;
font-weight: 500;
color: rgba(0, 0, 0, 0.5);
text-decoration: line-through;
}
.expired-reason {
font-size: 11px;
color: rgba(0, 0, 0, 0.35);
margin-top: 2px;
}
/* 引导横幅 */
.guide-banner {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 10px;
background: linear-gradient(135deg, #faf5ff, #f5f3ff);
border: 1px solid #ede9fe;
}
.guide-icon {
font-size: 32px;
flex-shrink: 0;
}
.guide-text { flex: 1; }
.guide-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
}
.guide-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-top: 2px;
}
</style>