hookehuyr

feat(优惠券): 重构优惠券列表和详情页面,添加加载状态和API集成

重构优惠券列表页面,添加分页加载和状态筛选功能
优化优惠券详情页面,完善状态显示和核销逻辑
新增蓝色主题相关样式类
更新API接口路径和返回字段
......@@ -19,6 +19,7 @@ declare module 'vue' {
NutDialog: typeof import('@nutui/nutui-taro')['Dialog']
NutImagePreview: typeof import('@nutui/nutui-taro')['ImagePreview']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutLoading: typeof import('@nutui/nutui-taro')['Loading']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRow: typeof import('@nutui/nutui-taro')['Row']
......
/*
* @Date: 2024-01-01 00:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-08 13:15:39
* @LastEditTime: 2025-09-08 16:23:23
* @FilePath: /lls_program/src/api/coupon.js
* @Description: 优惠券相关接口
*/
......@@ -15,7 +15,7 @@ const Api = {
REDEEM_COUPON: '/srv/?a=coupon&t=redeem',
MY_COUPON_LIST: '/srv/?a=redemption&t=list',
MY_COUPON_DETAIL: '/srv/?a=redemption&t=detail',
USE_COUPON: '/srv/?a=coupon&t=use',
USE_COUPON: '/srv/?a=redemption&t=use',
}
/**
......@@ -111,6 +111,7 @@ export const redeemCouponAPI = (params) => fn(fetch.post(Api.REDEEM_COUPON, para
* @returns {string} response.data.thumbnail - 优惠券缩略图
* @returns {string} response.data.status - 优惠券状态, UNUSED=未使用, USED=已使用, EXPIRED=已过期
* @returns {string} response.data.expire_time - 有效期截止时间
* @returns {string} response.data.used_time - 核销时间
*/
export const getMyCouponListAPI = (params = {}) => fn(fetch.get(Api.MY_COUPON_LIST, params));
......@@ -127,6 +128,7 @@ export const getMyCouponListAPI = (params = {}) => fn(fetch.get(Api.MY_COUPON_LI
* @returns {string} response.data.banner - 优惠券banner图
* @returns {string} response.data.status - 优惠券状态, UNUSED=未使用, USED=已使用, EXPIRED=已过期
* @returns {string} response.data.expire_time - 有效期截止时间
* @returns {string} response.data.expiration_rules[string] - 有效期
* @returns {Array} response.data.applicable_stores[string] - 可用门店
* @returns {Array} response.data.usage_rules[string] - 使用规则
*/
......
......@@ -5,3 +5,11 @@
.bg-blue-500 {
background-color: #4A90E2 !important;
}
.text-blue-500 {
color: #4A90E2 !important;
}
.border-blue-500 {
border-color: #4A90E2 !important;
}
......
<template>
<view class="min-h-screen bg-white pb-24">
<!-- <AppHeader title="优惠券详情" :showBack="true" /> -->
<!-- Loading State -->
<view v-if="loading" class="flex justify-center items-center h-screen">
<view class="text-gray-500">加载中...</view>
</view>
<!-- Content -->
<view v-else>
<!-- Top Image -->
<view class="w-full h-48">
<image :src="reward.image" class="w-full h-full object-cover" />
<image :src="reward.banner || 'https://placehold.co/800x400/e2f3ff/0369a1?text=优惠券&font=roboto'" class="w-full h-full object-cover" />
</view>
<!-- Main Content -->
......@@ -13,34 +19,46 @@
<view class="text-4xl font-bold text-blue-500 mb-2">
<text class="text-2xl">{{ reward.title }}</text>
</view>
<!-- Status Badge -->
<!-- <view class="mt-2">
<text :class="getStatusClass(reward.status)">{{ getStatusText(reward.status) }}</text>
</view> -->
</view>
<!-- Details Sections -->
<view class="space-y-6">
<!-- Applicable Stores -->
<view>
<view v-if="reward.applicable_stores && reward.applicable_stores.length > 0">
<h2 class="text-lg font-medium mb-3">可用门店</h2>
<view class="space-y-2 text-gray-600">
<view v-for="store in reward.stores" :key="store" class="flex text-sm">
<view v-for="store in reward.applicable_stores" :key="store" class="flex text-sm">
<span class="mr-2">·</span>
<p>{{ store }}</p>
</view>
</view>
</view>
<!-- Redemption Rules -->
<view>
<!-- Expiration Rules -->
<view v-if="reward.expiration_rules && reward.expiration_rules.length > 0">
<h2 class="text-lg font-medium mb-3">有效期</h2>
<view class="space-y-2 text-gray-600">
<view v-for="rule in reward.redemption_rules" :key="rule" class="flex text-sm">
<view v-for="rule in reward.expiration_rules" :key="rule" class="flex text-sm">
<span class="mr-2">·</span>
<p>{{ rule }}</p>
</view>
</view>
</view>
<!-- Expire Time -->
<view v-if="reward.expire_time">
<h2 class="text-lg font-medium mb-3">到期时间</h2>
<view class="text-gray-600 text-sm">
<p>{{ reward.expire_time }}</p>
</view>
</view>
<!-- Usage Rules -->
<view>
<view v-if="reward.usage_rules && reward.usage_rules.length > 0">
<h2 class="text-lg font-medium mb-3">使用规则</h2>
<view class="space-y-2 text-gray-600">
<view v-for="rule in reward.usage_rules" :key="rule" class="flex text-sm">
......@@ -54,69 +72,204 @@
<!-- Bottom Button -->
<view class="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100">
<nut-button type="primary" size="large" block :color="THEME_COLORS.PRIMARY" @click="handleRedeem">
立即核销
<nut-button
type="primary"
size="large"
block
:color="reward.status === 'UNUSED' ? THEME_COLORS.PRIMARY : '#D1D5DB'"
:disabled="reward.status !== 'UNUSED' || useLoading"
:loading="useLoading"
@click="handleRedeem"
>
{{ getButtonText(reward.status) }}
</nut-button>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import Taro from '@tarojs/taro';
import AppHeader from '../../components/AppHeader.vue';
import { ref, onMounted } from 'vue';
import Taro, { useLoad } from '@tarojs/taro';
// 导入主题颜色
import { THEME_COLORS } from '@/utils/config';
// 导入接口
import { getMyCouponDetailAPI, useCouponAPI } from '@/api/coupon';
// 页面参数
const couponId = ref('');
const loading = ref(false);
const useLoading = ref(false);
// Mock reward data based on the image
// 优惠券详情数据
const reward = ref({
id: 1,
title: '吴良材眼镜店85折券',
points: 10,
image: 'https://placehold.co/800x400/e2f3ff/0369a1?text=LFX&font=roboto', // Placeholder image
stores: [
'吴良材眼镜店 (南京东路店)',
'吴良材眼镜店 (淮海中路店)',
'吴良材眼镜店 (徐家汇店)'
],
redemption_rules: [
'2025-10-01~2025-12-31',
],
usage_rules: [
'仅限店内正价商品使用',
'不可与其他优惠同时使用',
'特价商品不可使用',
'最终解释权归商家所有'
]
id: '',
title: '',
banner: '',
status: 'UNUSED', // UNUSED=未使用, USED=已使用, EXPIRED=已过期
expire_time: '',
expiration_rules: [],
applicable_stores: [],
usage_rules: []
});
/**
* @description Handles the redemption of the coupon.
* 获取我的优惠券详情
*/
const fetchCouponDetail = async () => {
if (!couponId.value) {
console.error('缺少优惠券ID');
return;
}
loading.value = true;
try {
const response = await getMyCouponDetailAPI({ id: couponId.value });
if (response && response.data) {
reward.value = {
id: response.data.id || '',
title: response.data.title || '',
banner: response.data.banner || '',
status: response.data.status || 'UNUSED',
expire_time: response.data.expire_time || '',
expiration_rules: response.data.expiration_rules || [],
applicable_stores: response.data.applicable_stores || [],
usage_rules: response.data.usage_rules || []
};
}
} catch (error) {
console.error('获取优惠券详情失败:', error);
Taro.showToast({
title: '获取详情失败',
icon: 'error'
});
} finally {
loading.value = false;
}
};
/**
* 获取状态显示文本
*/
const getStatusText = (status) => {
switch (status) {
case 'UNUSED':
return '未使用';
case 'USED':
return '已使用';
case 'EXPIRED':
return '已过期';
default:
return '';
}
};
/**
* 获取状态样式类
*/
const getStatusClass = (status) => {
switch (status) {
case 'UNUSED':
return 'px-2 py-1 bg-green-100 text-green-600 rounded text-sm';
case 'USED':
return 'px-2 py-1 bg-gray-100 text-gray-600 rounded text-sm';
case 'EXPIRED':
return 'px-2 py-1 bg-red-100 text-red-600 rounded text-sm';
default:
return 'px-2 py-1 bg-gray-100 text-gray-600 rounded text-sm';
}
};
/**
* 获取按钮文本
*/
const handleRedeem = () => {
// Show a confirmation modal
Taro.showModal({
const getButtonText = (status) => {
switch (status) {
case 'UNUSED':
return '立即核销';
case 'USED':
return '已核销';
case 'EXPIRED':
return '已过期';
default:
return '不可用';
}
};
/**
* 处理优惠券核销
*/
const handleRedeem = async () => {
if (reward.value.status !== 'UNUSED') {
Taro.showToast({
title: '优惠券不可用',
icon: 'none'
});
return;
}
try {
// 显示确认弹窗
const result = await Taro.showModal({
title: '温馨提示',
content: `将核销此优惠券,是否确认?`,
success: (res) => {
if (res.confirm) {
// Simulate API call for redemption
Taro.showLoading({ title: '核销中...' });
setTimeout(() => {
Taro.hideLoading();
content: '将核销此优惠券,是否确认?',
confirmText: '确认核销',
cancelText: '取消'
});
if (result.confirm) {
useLoading.value = true;
// 调用核销API
const response = await useCouponAPI({ id: couponId.value });
if (response) {
// 核销成功
reward.value.status = 'USED';
Taro.showToast({
title: '核销成功',
icon: 'success',
duration: 2000
});
// After successful redemption, you might want to navigate the user to their coupons page
// For now, we'll just navigate back.
// 延迟返回上一页
setTimeout(() => {
Taro.navigateBack();
}, 2000);
}, 1500);
}
}
} catch (error) {
console.error('核销失败:', error);
Taro.showToast({
title: error.message || '核销失败,请重试',
icon: 'error'
});
} finally {
useLoading.value = false;
}
};
/**
* 初始化页面数据
*/
const initPageData = async (options = {}) => {
// 获取URL参数中的优惠券ID
if (options.id) {
couponId.value = options.id;
await fetchCouponDetail();
} else {
console.error('缺少优惠券ID参数');
Taro.showToast({
title: '参数错误',
icon: 'error'
});
}
};
// 页面加载时初始化数据
useLoad((options) => {
initPageData(options);
});
</script>
......
<template>
<view class="min-h-screen bg-gray-100">
<!-- <AppHeader title="我的券" :showBack="true" /> -->
<view class="min-h-screen bg-gray-50">
<!-- Tabs -->
<view class="bg-white flex justify-around items-center border-b border-gray-200">
<view class="bg-white px-4 py-2 border-b border-gray-200">
<view class="flex space-x-6">
<view
v-for="tab in tabs"
:key="tab.name"
@click="activeTab = tab.name"
class="py-3 text-center w-1/4 text-base font-medium cursor-pointer"
:class="[
activeTab === tab.name
? 'text-blue-500 border-b-2 border-blue-500'
: 'text-gray-500'
]"
@click="handleTabChange(tab.name)"
:class="{
'text-blue-500 border-b-2 border-blue-500': activeTab === tab.name,
'text-gray-500': activeTab !== tab.name
}"
class="pb-2 px-1 text-sm font-medium transition-colors"
>
{{ tab.label }}
</view>
</view>
</view>
<!-- Loading State -->
<view v-if="loading" class="flex justify-center items-center py-20">
<view class="text-gray-500">加载中...</view>
</view>
<!-- Rewards List -->
<view class="p-4">
<!-- Content -->
<view v-else class="p-4">
<view v-if="filteredRewards.length > 0" class="space-y-4">
<view
v-for="reward in filteredRewards"
:key="reward.id"
class="bg-white rounded-lg shadow-sm p-4 flex justify-between items-center"
class="bg-white rounded-xl p-4 flex items-center shadow-[0_2px_8px_rgba(0,0,0,0.08)]"
>
<view class="flex-1">
<h3 class="text-lg font-semibold text-gray-800">{{ reward.title }}</h3>
<p class="text-sm text-gray-500 mt-1">有效期至 {{ reward.expiryDate }}</p>
<p v-if="reward.status === 'used'" class="text-sm text-gray-400 mt-1">使用于 {{ reward.usedDate }}</p>
<image :src="reward.thumbnail || 'https://placehold.co/120x120/e2f3ff/0369a1?text=券&font=roboto'" class="w-16 h-16 rounded-lg mr-4 flex-shrink-0" mode="aspectFill" />
<view class="flex-1 min-w-0">
<view class="font-medium text-base mb-1">{{ reward.title }}</view>
<view v-if="reward.status === 'UNUSED'" class="text-xs text-gray-400">
有效期至:{{ formatDate(reward.expire_time) }}
</view>
<view v-if="reward.status === 'USED'" class="text-xs text-red-500">
使用日期:{{ formatDate(reward.used_time) }}
</view>
</view>
<view class="ml-4 flex flex-col items-end">
<button
@click="handleUseReward(reward)"
:disabled="reward.status !== 'unused'"
class="px-6 py-2 rounded-full text-white font-medium text-sm transition-colors"
:disabled="reward.status !== 'UNUSED'"
:class="{
'bg-blue-500 hover:bg-blue-600': reward.status === 'unused',
'bg-gray-300 cursor-not-allowed': reward.status !== 'unused'
'bg-blue-500 text-white': reward.status === 'UNUSED',
'bg-gray-300 text-gray-500': reward.status !== 'UNUSED'
}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors flex-shrink-0"
>
{{ getButtonText(reward.status) }}
</button>
</view>
</view>
</view>
<!-- Empty State -->
<view v-else class="text-center py-20">
<p class="text-gray-500">暂无相关兑换券</p>
<view class="text-gray-400 text-lg mb-2">暂无券</view>
<view class="text-gray-500 text-sm">您还没有任何券</view>
</view>
<!-- Load More -->
<view v-if="hasMore && filteredRewards.length > 0" class="text-center mt-6">
<view
@click="loadMore"
class="text-blue-500 py-4"
>
{{ loading ? '加载中...' : '加载更多' }}
</view>
</view>
<!-- No More Data -->
<view v-if="!hasMore && filteredRewards.length > 0" class="text-center py-4 text-gray-500">
没有更多数据了
</view>
</view>
</view>
......@@ -56,104 +85,150 @@
import { ref, computed } from 'vue';
import Taro from '@tarojs/taro';
import { useDidShow } from '@tarojs/taro';
// 导入接口
import { getMyCouponListAPI } from '@/api/coupon';
const tabs = ref([
{ name: 'all', label: '全部' },
{ name: 'unused', label: '未使用' },
{ name: 'used', label: '已使用' },
{ name: 'expired', label: '已过期' },
{ name: 'all', label: '全部', status: '' },
{ name: 'unused', label: '未使用', status: 'UNUSED' },
{ name: 'used', label: '已使用', status: 'USED' },
{ name: 'expired', label: '已过期', status: 'EXPIRED' },
]);
const activeTab = ref('all');
const loading = ref(false);
const rewards = ref([]);
const currentPage = ref(0);
const hasMore = ref(true);
const filteredRewards = computed(() => {
if (activeTab.value === 'all') {
// 由于API已经根据status参数进行了筛选,这里直接返回rewards
return rewards.value;
}
return rewards.value.filter(reward => reward.status === activeTab.value);
});
/**
* 获取按钮文本
*/
const getButtonText = (status) => {
switch (status) {
case 'unused':
case 'UNUSED':
return '使用';
case 'used':
case 'USED':
return '已使用';
case 'expired':
case 'EXPIRED':
return '已过期';
default:
return '';
}
};
/**
* 获取状态文本
*/
// const getStatusText = (status) => {
// switch (status) {
// case 'UNUSED':
// return '未使用';
// case 'USED':
// return '已使用';
// case 'EXPIRED':
// return '已过期';
// default:
// return '';
// }
// };
/**
* 格式化日期
*/
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN');
};
/**
* 处理优惠券使用
*/
const handleUseReward = (reward) => {
if (reward.status === 'unused') {
// Here you would typically navigate to a usage/QR code page
// For now, we can just log it or update the status for demo purposes
console.log(`Using reward: ${reward.title}`);
// Example of updating status:
// const item = rewards.value.find(r => r.id === reward.id);
// if (item) {
// item.status = 'used';
// item.usedDate = new Date().toISOString().split('T')[0];
// }
if (reward.status === 'UNUSED') {
// 跳转到卡券详情页
Taro.navigateTo({
url: '/pages/CouponDetail/index?id=' + reward.id
})
});
}
};
/**
* 获取我的优惠券列表
*/
const fetchMyCouponList = async (reset = false) => {
if (loading.value || (!hasMore.value && !reset)) return;
loading.value = true;
try {
const currentTab = tabs.value.find(tab => tab.name === activeTab.value);
const params = {
status: currentTab?.status || '',
page: reset ? 0 : currentPage.value,
limit: 10
};
const response = await getMyCouponListAPI(params);
if (response && response.data) {
const coupons = Array.isArray(response.data) ? response.data : [];
if (reset) {
rewards.value = coupons;
currentPage.value = 0;
} else {
rewards.value = [...rewards.value, ...coupons];
}
// 如果返回的数据少于limit,说明没有更多数据了
hasMore.value = coupons.length >= 10;
currentPage.value += 1;
}
} catch (error) {
console.error('获取我的优惠券列表失败:', error);
Taro.showToast({
title: '获取列表失败',
icon: 'error'
});
} finally {
loading.value = false;
}
};
/**
* 处理标签页切换
*/
const handleTabChange = (tabName) => {
if (activeTab.value === tabName) return;
activeTab.value = tabName;
currentPage.value = 0;
hasMore.value = true;
fetchMyCouponList(true); // 重置并重新加载数据
};
/**
* 加载更多
*/
const loadMore = () => {
fetchMyCouponList(false);
};
/**
* 初始化页面数据
*/
const initPageData = () => {
fetchMyCouponList(true);
};
// 页面显示时刷新数据
useDidShow(() => {
initPageData();
});
const initPageData = () => {
rewards.value = [
{
id: 1,
title: '杏花楼集团 85折券',
expiryDate: '2025-08-28',
status: 'unused',
usedDate: null,
},
{
id: 2,
title: '老凤祥银楼 20元抵用券',
expiryDate: '2025-08-28',
status: 'unused',
usedDate: null,
},
{
id: 3,
title: '吴良材眼镜 5折券',
expiryDate: '2024-05-20',
status: 'used',
usedDate: '2024-05-01',
},
{
id: 4,
title: '沈大成双酿团 免费券',
expiryDate: '2024-03-15',
status: 'expired',
usedDate: null,
},
{
id: 5,
title: '沈大成双酿团 免费券',
expiryDate: '2024-03-15',
status: 'expired',
usedDate: null,
},
{
id: 6,
title: '沈大成双酿团 免费券',
expiryDate: '2024-03-15',
status: 'expired',
usedDate: null,
},
];
}
</script>
......