文件
shanghaipeiwan/pwuniapp/pages/user/coupons.vue
xcbyy 6576c9e4be feat(coupon): 优惠券中心(领取)+ 我的券 + 下单核销/回退
- 新表 user_coupons(快照面额/门槛/类目/有效期)+ orders.user_coupon_id
- 仓储:getCouponsWithClaimed/claimCoupon/getUserCoupons/resolveUserCoupon/consumeUserCoupon/releaseUserCouponByOrder
- 接口:POST /api/marketing/coupons/:id/claim(同券仅一次、受总量限制)、GET /api/marketing/my-coupons(自动标记过期)、券中心带 claimed 标识
- 下单:createOrder 支持 userCouponId(校验归属/门槛/类目/有效期,服务端算抵扣,与订单同事务占用)
- 回退:买家取消/大神拒单/超时退款/仲裁退款 均把券退回为 UNUSED
- 小程序:新增 pages/user/coupons(券中心+我的券)、个人中心入口(可用张数)、下单改为选择"我的券"并传 userCouponId
- 回归 94 套件/666 用例全绿;冒烟 9/9(领取/重复领取409/不存在404/下单抵扣100-10=90/取消回退)
2026-10-19 15:05:56 +08:00

401 行
10 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="coupon-page" :class="['theme-' + currentThemeKey]" :style="navStyle">
<view class="custom-nav-bar">
<view class="nav-content">
<view class="btn-nav-back btn-active" @click="handleBack">
<image class="nav-back-icon" :src="ICONS.chevronLeft" mode="aspectFit" />
</view>
<view class="nav-title-wrap">
<text class="nav-title">优惠券</text>
</view>
<view class="nav-capsule-placeholder"></view>
</view>
</view>
<!-- 顶部切换:券中心 / 我的券 -->
<view class="tab-bar">
<view
class="tab-item btn-active"
:class="{ active: activeTab === 'center' }"
@click="switchTab('center')"
>
<text class="tab-text">券中心</text>
</view>
<view
class="tab-item btn-active"
:class="{ active: activeTab === 'mine' }"
@click="switchTab('mine')"
>
<text class="tab-text">我的券{{ usableCount > 0 ? ` (${usableCount})` : '' }}</text>
</view>
</view>
<scroll-view scroll-y class="coupon-scroll" refresher-enabled :refresher-triggered="isRefreshing" @refresherrefresh="onRefresh">
<view class="coupon-body">
<!-- 券中心 -->
<block v-if="activeTab === 'center'">
<view v-if="isLoading" class="empty-tip"><text>加载中…</text></view>
<view v-else-if="centerList.length === 0" class="empty-tip">
<text class="empty-title">暂无可领取优惠券</text>
<text>平台会不定期发放新人券与品类券,敬请关注</text>
</view>
<view v-else class="coupon-list">
<view v-for="c in centerList" :key="c.id" class="coupon-card">
<view class="coupon-amount">
<text class="amount-num">¥{{ c.discountAmount }}</text>
<text class="amount-sub">{{ c.minSpend > 0 ? `满${c.minSpend}可用` : '无门槛' }}</text>
</view>
<view class="coupon-info">
<text class="coupon-name">{{ c.name }}</text>
<text class="coupon-desc">{{ c.description || '全平台通用' }}</text>
<text class="coupon-extra">{{ c.categoryLimit && c.categoryLimit !== 'ALL' ? `限${c.categoryLimit} · ` : '全品类 · ' }}有效期 {{ c.validDays }} 天</text>
</view>
<view
class="coupon-btn btn-active"
:class="{ disabled: c.claimed || c.receivedCount >= c.totalCount }"
@click="handleClaim(c)"
>
<text>{{ c.claimed ? '已领取' : (c.receivedCount >= c.totalCount ? '已领完' : '立即领取') }}</text>
</view>
</view>
</view>
</block>
<!-- 我的券 -->
<block v-else>
<view class="filter-bar">
<text
v-for="f in statusFilters"
:key="f.key"
class="filter-item btn-active"
:class="{ active: mineFilter === f.key }"
@click="mineFilter = f.key"
>{{ f.label }}</text>
</view>
<view v-if="mineList.length === 0" class="empty-tip">
<text class="empty-title">暂无优惠券</text>
<text>去「券中心」领取平台优惠券吧</text>
</view>
<view v-else class="coupon-list">
<view
v-for="c in mineList"
:key="c.id"
class="coupon-card"
:class="{ used: c.status !== 'UNUSED' }"
>
<view class="coupon-amount">
<text class="amount-num">¥{{ c.discountAmount }}</text>
<text class="amount-sub">{{ c.minSpend > 0 ? `满${c.minSpend}可用` : '无门槛' }}</text>
</view>
<view class="coupon-info">
<text class="coupon-name">{{ c.name }}</text>
<text class="coupon-extra">{{ c.categoryLimit && c.categoryLimit !== 'ALL' ? `限${c.categoryLimit} · ` : '全品类 · ' }}有效期至 {{ formatDate(c.expiredAt) }}</text>
</view>
<view class="coupon-state">
<text>{{ c.status === 'UNUSED' ? '未使用' : (c.status === 'USED' ? '已使用' : '已过期') }}</text>
</view>
</view>
</view>
</block>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getMarketingCoupons, claimCoupon, getMyCoupons } from '@/common/api.js';
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
const activeTab = ref('center');
const isLoading = ref(true);
const isRefreshing = ref(false);
const centerList = ref([]);
const mineList = ref([]);
const mineFilter = ref('UNUSED');
const statusFilters = [
{ key: 'UNUSED', label: '未使用' },
{ key: 'USED', label: '已使用' },
{ key: 'EXPIRED', label: '已过期' }
];
const navStyle = computed(() => ({
'--safe-header-gap': `${navInfo.value.totalHeaderHeight}px`,
'--capsule-right-space': `${navInfo.value.capsuleRightSpace}px`,
'--capsule-height': `${navInfo.value.capsuleHeight}px`
}));
const usableCount = computed(() => mineList.value.filter((x) => x.status === 'UNUSED').length);
const currentUserId = () => uni.getStorageSync('userId') || 0;
const formatDate = (v) => {
if (!v) return '长期有效';
const s = String(v).replace('T', ' ');
return s.slice(0, 10);
};
const loadCenter = async () => {
const list = await getMarketingCoupons();
centerList.value = Array.isArray(list) ? list : [];
};
const loadMine = async () => {
const uid = currentUserId();
if (!uid) {
mineList.value = [];
return;
}
const res = await getMyCoupons(uid);
const all = Array.isArray(res?.list) ? res.list : [];
mineList.value = all.filter((x) => x.status === mineFilter.value);
};
const reloadAll = async () => {
isLoading.value = true;
try {
await Promise.all([loadCenter(), loadMine()]);
} catch (_) {
// 忽略网络异常
} finally {
isLoading.value = false;
isRefreshing.value = false;
}
};
const switchTab = async (tab) => {
activeTab.value = tab;
await reloadAll();
};
const onRefresh = () => {
isRefreshing.value = true;
reloadAll();
};
const handleClaim = async (c) => {
if (c.claimed || c.receivedCount >= c.totalCount) return;
const uid = currentUserId();
if (!uid) {
uni.showToast({ title: '请先登录', icon: 'none' });
return;
}
try {
await claimCoupon(c.id, uid);
c.claimed = true;
c.receivedCount = Number(c.receivedCount || 0) + 1;
uni.showToast({ title: '领取成功', icon: 'success' });
await loadMine();
} catch (err) {
uni.showToast({ title: err.message || '领取失败', icon: 'none' });
}
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/user/index' });
}
};
onShow(() => {
navInfo.value = getNavBarInfo();
currentThemeKey.value = getCurrentThemeKey();
reloadAll();
});
</script>
<style scoped>
.coupon-page {
min-height: 100vh;
background: #0a0f1d;
}
.custom-nav-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(10, 15, 29, 0.92);
padding-top: var(--safe-header-gap, 0);
}
.nav-content {
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
}
.btn-nav-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back-icon {
width: 18px;
height: 18px;
}
.nav-title-wrap {
flex: 1;
text-align: center;
}
.nav-title {
color: #fff;
font-size: 17px;
font-weight: 600;
}
.nav-capsule-placeholder {
width: 32px;
}
.tab-bar {
display: flex;
gap: 12px;
padding: 8px 16px 0;
}
.tab-item {
flex: 1;
height: 36px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
}
.tab-item.active {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.tab-text {
color: #cbd5e1;
font-size: 13px;
font-weight: 600;
}
.tab-item.active .tab-text {
color: #fff;
}
.coupon-scroll {
height: calc(100vh - 88px - var(--safe-header-gap, 0px));
}
.coupon-body {
padding: 12px 16px 40px;
}
.filter-bar {
display: flex;
gap: 18px;
margin-bottom: 10px;
}
.filter-item {
color: #7d89a8;
font-size: 13px;
}
.filter-item.active {
color: #8b9cff;
font-weight: 600;
}
.coupon-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.coupon-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 14px;
background: linear-gradient(135deg, #1b2340 0%, #141a30 100%);
}
.coupon-card.used {
opacity: 0.55;
}
.coupon-amount {
width: 84px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.amount-num {
color: #ffd166;
font-size: 20px;
font-weight: 700;
}
.amount-sub {
color: #8d99b8;
font-size: 10px;
margin-top: 2px;
}
.coupon-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.coupon-name {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.coupon-desc {
color: #94a3b8;
font-size: 12px;
}
.coupon-extra {
color: #6d7997;
font-size: 11px;
}
.coupon-btn {
flex-shrink: 0;
padding: 7px 12px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.coupon-btn text {
color: #fff;
font-size: 12px;
font-weight: 600;
}
.coupon-btn.disabled {
background: rgba(255, 255, 255, 0.12);
}
.coupon-btn.disabled text {
color: #8d99b8;
}
.coupon-state {
flex-shrink: 0;
}
.coupon-state text {
color: #8d99b8;
font-size: 12px;
}
.empty-tip {
padding: 60px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty-title {
color: #e6eaf5;
font-size: 15px;
font-weight: 600;
}
.empty-tip text {
color: #7d89a8;
font-size: 12px;
text-align: center;
line-height: 1.6;
}
.btn-active:active {
opacity: 0.75;
}
</style>