- 原因:初版用 padding-top=safe-header-gap + 44px 内容行,把标题推到状态栏下方,顶部留白过大 - 改为与其它页面一致的规范:导航栏总高=var(--safe-header-gap),内容行 position:absolute;bottom:0;height:var(--capsule-height) 贴底对齐胶囊;标题绝对居中(translateX(-50%)) - 页面根改为 height:100vh + flex 纵向布局,列表用 flex:1 撑满剩余空间(tab-bar/滚动区不再按 100vh 硬算) - 返回按钮对齐既有规范(64rpx 圆形底 + 38rpx 图标) - 回归 97 套件/674 用例全绿(theme-consistency.spec.ts 增加导航规范断言)
431 行
12 KiB
Vue
431 行
12 KiB
Vue
<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 class="empty-text">加载中…</text></view>
|
||
<view v-else-if="centerList.length === 0" class="empty-tip">
|
||
<text class="empty-title">暂无可领取优惠券</text>
|
||
<text class="empty-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 class="empty-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 () => {
|
||
try {
|
||
const list = await getMarketingCoupons();
|
||
centerList.value = Array.isArray(list) ? list : [];
|
||
} catch (_) {
|
||
centerList.value = [];
|
||
}
|
||
};
|
||
|
||
const loadMine = async () => {
|
||
const uid = currentUserId();
|
||
if (!uid) {
|
||
mineList.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
const res = await getMyCoupons(uid);
|
||
const all = Array.isArray(res?.list) ? res.list : [];
|
||
mineList.value = all.filter((x) => x.status === mineFilter.value);
|
||
} catch (_) {
|
||
mineList.value = [];
|
||
}
|
||
};
|
||
|
||
const reloadAll = async () => {
|
||
isLoading.value = true;
|
||
try {
|
||
await Promise.all([loadCenter(), loadMine()]);
|
||
} 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>
|
||
/* 颜色全部取自全局主题变量(App.vue 中按 theme-warm 覆盖),随主题切换自动适配 */
|
||
/* 导航栏规范与其它页面一致:总高=安全区,内容行贴底对齐胶囊,标题绝对居中 */
|
||
.coupon-page {
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
background: var(--bg-primary, #0a0f1d);
|
||
}
|
||
.custom-nav-bar {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
height: var(--safe-header-gap, 88px);
|
||
background: var(--nav-bg, #0a0f1d);
|
||
border-bottom: 1px solid var(--border-subtle, #1e293b);
|
||
}
|
||
.nav-content {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: var(--capsule-height, 32px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
.btn-nav-back {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 32rpx;
|
||
background: var(--bg-surface-elevated, rgba(255, 255, 255, 0.08));
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.nav-back-icon {
|
||
width: 38rpx;
|
||
height: 38rpx;
|
||
}
|
||
.nav-title-wrap {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
text-align: center;
|
||
}
|
||
.nav-title {
|
||
color: var(--text-primary, #f8fafc);
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
letter-spacing: 0.5rpx;
|
||
}
|
||
.nav-capsule-placeholder {
|
||
width: var(--capsule-right-space, 0rpx);
|
||
}
|
||
.tab-bar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 12px 16px 0;
|
||
}
|
||
.tab-item {
|
||
flex: 1;
|
||
height: 36px;
|
||
border-radius: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--bg-surface-elevated, #1e2942);
|
||
border: 1px solid var(--border-subtle, #1e293b);
|
||
}
|
||
.tab-item.active {
|
||
background: var(--color-brand-gradient, linear-gradient(135deg, #6366f1, #8b5cf6));
|
||
border-color: transparent;
|
||
}
|
||
.tab-text {
|
||
color: var(--text-secondary, #cbd5e1);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.tab-item.active .tab-text {
|
||
color: #fff;
|
||
}
|
||
.coupon-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.coupon-body {
|
||
padding: 12px 16px 40px;
|
||
}
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: 18px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.filter-item {
|
||
color: var(--text-muted, #7d89a8);
|
||
font-size: 13px;
|
||
}
|
||
.filter-item.active {
|
||
color: var(--color-brand, #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: var(--bg-surface, #141a30);
|
||
border: 1px solid var(--border-subtle, #1e293b);
|
||
}
|
||
.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: var(--color-brand, #ffd166);
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
}
|
||
.amount-sub {
|
||
color: var(--text-muted, #8d99b8);
|
||
font-size: 10px;
|
||
margin-top: 2px;
|
||
}
|
||
.coupon-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.coupon-name {
|
||
color: var(--text-primary, #fff);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
.coupon-desc {
|
||
color: var(--text-secondary, #94a3b8);
|
||
font-size: 12px;
|
||
}
|
||
.coupon-extra {
|
||
color: var(--text-muted, #6d7997);
|
||
font-size: 11px;
|
||
}
|
||
.coupon-btn {
|
||
flex-shrink: 0;
|
||
padding: 7px 12px;
|
||
border-radius: 16px;
|
||
background: var(--color-brand-gradient, linear-gradient(135deg, #6366f1, #8b5cf6));
|
||
box-shadow: var(--shadow-brand, none);
|
||
}
|
||
.coupon-btn text {
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.coupon-btn.disabled {
|
||
background: var(--bg-surface-elevated, rgba(255, 255, 255, 0.12));
|
||
box-shadow: none;
|
||
}
|
||
.coupon-btn.disabled text {
|
||
color: var(--text-muted, #8d99b8);
|
||
}
|
||
.coupon-state {
|
||
flex-shrink: 0;
|
||
}
|
||
.coupon-state text {
|
||
color: var(--text-muted, #8d99b8);
|
||
font-size: 12px;
|
||
}
|
||
.empty-tip {
|
||
padding: 60px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.empty-title {
|
||
color: var(--text-primary, #e6eaf5);
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
.empty-text {
|
||
color: var(--text-muted, #7d89a8);
|
||
font-size: 12px;
|
||
text-align: center;
|
||
line-height: 1.6;
|
||
}
|
||
.btn-active:active {
|
||
opacity: 0.75;
|
||
}
|
||
</style>
|