738 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">我的优惠券</view>
<view class="page-subtitle">My Coupons</view>
</view>
<!-- Tab栏 - 毛玻璃风格 -->
<view class="tab-bar glass-card">
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
<text class="tab-text">未使用</text>
<view class="tab-indicator" v-if="currentTab === 1"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
<text class="tab-text">已使用</text>
<view class="tab-indicator" v-if="currentTab === 2"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 3 }" @click="switchTab(3)">
<text class="tab-text">已过期</text>
<view class="tab-indicator" v-if="currentTab === 3"></view>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">🎟</text>
<text class="empty-text">{{ getEmptyText() }}</text>
</view>
<!-- 优惠券列表 -->
<view v-else class="coupon-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="coupon-ticket"
:class="getCouponClass()"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<!-- 左侧金额区域 -->
<view class="coupon-left">
<view class="coupon-value">
<text class="coupon-symbol">¥</text>
<text class="coupon-amount">{{ formatValue(item.remaining ?? item.amount ?? 0) }}</text>
</view>
<text class="coupon-label">{{ currentTab === 1 ? '可用' : (currentTab === 2 ? '已用' : '过期') }}</text>
</view>
<!-- 中间分割线 -->
<view class="coupon-divider">
<view class="divider-notch top"></view>
<view class="divider-dash"></view>
<view class="divider-notch bottom"></view>
</view>
<!-- 右侧信息区域 -->
<view class="coupon-right">
<view class="coupon-header">
<text class="coupon-name">{{ item.name || '优惠券' }}</text>
<view class="coupon-original" v-if="item.amount && item.remaining !== undefined && item.remaining !== item.amount">
<text>原值 ¥{{ formatValue(item.amount) }}</text>
</view>
</view>
<text class="coupon-rules">{{ item.rules || '全场通用' }}</text>
<!-- 使用进度条 -->
<view class="coupon-progress" v-if="item.amount && item.remaining !== undefined && item.remaining < item.amount">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: getUsedPercent(item) + '%' }"></view>
</view>
<text class="progress-text">已用 {{ formatValue(item.amount - item.remaining) }} ({{ getUsedPercent(item) }}%)</text>
</view>
<view class="coupon-footer">
<view class="footer-left">
<text class="coupon-expire">{{ formatExpiry(item) }}</text>
<text class="coupon-used-time" v-if="currentTab === 2 && item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
</view>
</view>
<!-- 优化后的按钮位置 -->
<view class="coupon-action-wrapper" v-if="currentTab === 1">
<view class="use-btn" @click.stop="onUseCoupon(item)">
<text class="btn-text">去使用</text>
<view class="btn-shine"></view>
</view>
</view>
<view class="coupon-status" v-else>
<text class="status-tag" :class="currentTab === 2 ? 'used' : 'expired'">{{ currentTab === 2 ? '已使用' : '已过期' }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading && list.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { getUserCoupons } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const currentTab = ref(1)
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// 获取用户ID
function getUserId() {
return uni.getStorageSync('user_id')
}
// 检查登录状态
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
// 格式化金额 (分转元)
function formatValue(val) {
return (Number(val) / 100).toFixed(0)
}
// 格式化有效期
function formatExpiry(item) {
// 后端返回的字段是 valid_end
const endTime = item.valid_end || item.end_time
if (!endTime) return '长期有效'
const d = new Date(endTime)
// Check for invalid date (e.g., "0001-01-01" from Go zero value)
if (isNaN(d.getTime()) || d.getFullYear() < 2000) return '长期有效'
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const label = currentTab.value === 3 ? '过期时间' : '有效期至'
return `${label} ${y}-${m}-${day}`
}
// 格式化日期时间
function formatDateTime(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
// 计算使用百分比
function getUsedPercent(item) {
if (!item.amount || !item.remaining) return 0
const used = item.amount - item.remaining
return Math.floor((used / item.amount) * 100)
}
// 获取空状态文本
function getEmptyText() {
if (currentTab.value === 1) return '暂无可用优惠券'
if (currentTab.value === 2) return '暂无使用记录'
return '暂无过期优惠券'
}
// 获取优惠券样式类
function getCouponClass() {
if (currentTab.value === 2) return 'coupon-used'
if (currentTab.value === 3) return 'coupon-expired'
return ''
}
// 切换Tab
function switchTab(tab) {
if (currentTab.value === tab) return
uni.vibrateShort({ type: 'light' })
currentTab.value = tab
list.value = []
page.value = 1
hasMore.value = true
fetchData()
}
// 下拉刷新
async function onRefresh() {
isRefreshing.value = true
page.value = 1
hasMore.value = true
await fetchData(false)
isRefreshing.value = false
}
// 加载更多
async function loadMore() {
if (loading.value || !hasMore.value) return
await fetchData(true)
}
// 获取数据
async function fetchData(append = false) {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
// status: 0=unused, 1=used, 2=expired
const statusMap = { 1: 0, 2: 1, 3: 2 }
const res = await getUserCoupons(userId, statusMap[currentTab.value], page.value, pageSize)
const items = res.list || res.data || []
if (append) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
if (items.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
} catch (e) {
console.error('获取优惠券失败:', e)
hasMore.value = false
} finally {
loading.value = false
}
}
// 去使用优惠券
function onUseCoupon(item) {
uni.vibrateShort({ type: 'medium' })
// 通常跳转到首页或抽盒页
uni.switchTab({
url: '/pages/index/index'
})
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与个人中心统一) */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* Tab栏 */
.tab-bar {
@extend .glass-card;
display: flex;
margin: 0 $spacing-lg;
padding: 8rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
position: relative;
transition: all 0.3s;
}
.tab-text {
font-size: 28rpx;
color: $text-sub;
font-weight: 500;
}
.tab-item.active .tab-text {
color: $text-main;
font-weight: 700;
}
.tab-indicator {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: $brand-primary;
border-radius: 6rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 280rpx);
padding: $spacing-lg;
position: relative;
z-index: 1;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 优惠券列表 */
.coupon-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 优惠券卡片 */
.coupon-ticket {
background: #fff;
border-radius: 16rpx;
display: flex;
overflow: hidden;
box-shadow: $shadow-sm;
position: relative;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.coupon-left {
width: 180rpx;
background: linear-gradient(135deg, #FFF5E6, #fff);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx;
position: relative;
}
.coupon-value {
color: $brand-primary;
font-weight: 900;
}
.coupon-symbol {
font-size: 24rpx;
}
.coupon-amount {
font-size: 56rpx;
line-height: 1;
}
.coupon-label {
font-size: 20rpx;
color: $brand-primary;
margin-top: 8rpx;
border: 1px solid $brand-primary;
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
/* 分割线 */
.coupon-divider {
width: 30rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.divider-notch {
width: 24rpx;
height: 24rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.divider-notch.top {
top: -12rpx;
}
.divider-notch.bottom {
bottom: -12rpx;
}
.divider-dash {
width: 0;
height: 80%;
border-left: 2rpx dashed #eee;
}
.coupon-right {
flex: 1;
padding: 24rpx;
padding-right: 130rpx; /* Prevent text overlap with button */
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
position: relative; /* Ensure padding works with absolute button */
}
.coupon-header {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.coupon-name {
font-size: $font-md;
font-weight: 700;
color: $text-main;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coupon-original {
font-size: 20rpx;
color: $text-tertiary;
text-decoration: line-through;
margin-left: 8rpx;
}
.coupon-rules {
font-size: $font-xs;
color: $text-sub;
margin-bottom: 16rpx;
}
/* 进度条 */
.coupon-progress {
margin-bottom: 12rpx;
}
.progress-bar {
height: 6rpx;
background: $bg-secondary;
border-radius: 100rpx;
overflow: hidden;
margin-bottom: 4rpx;
}
.progress-fill {
height: 100%;
background: $brand-primary;
border-radius: 100rpx;
}
.progress-text {
font-size: 18rpx;
color: $text-tertiary;
}
.coupon-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
}
.footer-left {
display: flex;
flex-direction: column;
}
.coupon-expire {
font-size: 20rpx;
color: $text-tertiary;
}
.coupon-used-time {
font-size: 18rpx;
color: $text-tertiary;
margin-top: 4rpx;
}
/* 优化后的按钮样式 */
.coupon-action-wrapper {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
.use-btn {
background: linear-gradient(135deg, #FF8D3F, #FF5C00);
padding: 12rpx 32rpx;
border-radius: 40rpx;
box-shadow: 0 6rpx 20rpx rgba(255, 92, 0, 0.3);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&:active {
transform: scale(0.92);
box-shadow: 0 2rpx 10rpx rgba(255, 92, 0, 0.2);
}
}
.btn-text {
color: #fff;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 2rpx;
position: relative;
z-index: 2;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-25deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20%, 100% { left: 150%; }
}
.status-tag {
font-size: 22rpx;
color: $text-tertiary;
background: #F5F5F5;
padding: 6rpx 16rpx;
border-radius: 8rpx;
margin-left: auto;
}
/* 过期/已使用状态 */
.coupon-used .coupon-left,
.coupon-expired .coupon-left {
background: #f9f9f9;
}
.coupon-used .coupon-value,
.coupon-expired .coupon-value,
.coupon-used .coupon-label,
.coupon-expired .coupon-label {
color: $text-tertiary;
border-color: $text-tertiary;
}
.coupon-used .coupon-name,
.coupon-expired .coupon-name {
color: $text-sub;
}
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>