feat(admin): 从文章详情页面改为文章管理页面

- 修改页面配置,设置新的导航栏标题和样式
- 重新设计页面布局,增加搜索栏、文章列表和操作按钮
- 添加文章搜索、分页加载和删除功能
- 优化文章列表项的样式和交互
- 新增礼品卡相关API和组件
- 更新优惠券组件,增加到期提醒和筛选功能
This commit is contained in:
2025-08-13 10:11:57 +08:00
parent 0e457f66d8
commit a1cacc04e8
67 changed files with 6278 additions and 2816 deletions

View File

@@ -2,12 +2,18 @@
position: relative;
display: flex;
width: 100%;
height: 100px;
margin-bottom: 12px;
border-radius: 8px;
height: 120px;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: #fff;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
&.disabled {
opacity: 0.6;
@@ -15,7 +21,7 @@
.coupon-left {
flex-shrink: 0;
width: 100px;
width: 110px;
display: flex;
flex-direction: column;
align-items: center;
@@ -24,23 +30,23 @@
position: relative;
&.theme-red {
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
}
&.theme-orange {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
}
&.theme-blue {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%);
}
&.theme-purple {
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
background: linear-gradient(135deg, #ab47bc 0%, #9c27b0 100%);
}
&.theme-green {
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
}
.amount-wrapper {
@@ -49,22 +55,23 @@
margin-bottom: 8px;
.currency {
font-size: 24px;
font-weight: 500;
font-size: 28px;
font-weight: 600;
margin-right: 2px;
}
.amount {
font-size: 30px;
font-size: 36px;
font-weight: bold;
line-height: 1;
}
}
.condition {
font-size: 24px;
font-size: 22px;
opacity: 0.9;
margin-top: 2px;
text-align: center;
line-height: 1.2;
}
}
@@ -112,21 +119,23 @@
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 12px;
padding: 16px;
.coupon-info {
flex: 1;
.coupon-title {
font-size: 28px;
font-size: 32px;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
margin-bottom: 6px;
line-height: 1.3;
}
.coupon-validity {
font-size: 24px;
font-size: 26px;
color: #6b7280;
line-height: 1.2;
}
}
@@ -136,38 +145,45 @@
align-items: center;
.coupon-btn {
min-width: 48px;
height: 24px;
border-radius: 12px;
font-size: 24px;
min-width: 120px;
height: 60px;
border-radius: 30px;
font-size: 26px;
border: none;
color: #fff;
font-weight: 600;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
&.theme-red {
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
}
&.theme-orange {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
}
&.theme-blue {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%);
}
&.theme-purple {
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
background: linear-gradient(135deg, #ab47bc 0%, #9c27b0 100%);
}
&.theme-green {
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
}
}
.status-text {
font-size: 24px;
color: #6b7280;
padding: 4px 8px;
font-size: 26px;
color: #9ca3af;
padding: 8px 12px;
font-weight: 500;
}
}
}

View File

@@ -79,13 +79,41 @@ const CouponCard: React.FC<CouponCardProps> = ({
// 获取使用条件文本
const getConditionText = () => {
if (type === 3) return '无门槛'
if (type === 3) return '免费使用' // 免费券
if (minAmount && minAmount > 0) {
return `${minAmount}可用`
return `${minAmount}可用`
}
return '无门槛'
}
// 格式化有效期显示
const formatValidityPeriod = () => {
if (!startTime || !endTime) return ''
const start = new Date(startTime)
const end = new Date(endTime)
const now = new Date()
// 如果还未开始
if (now < start) {
return `${start.getMonth() + 1}.${start.getDate()} 开始生效`
}
// 计算剩余天数
const diffTime = end.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays <= 0) {
return '已过期'
} else if (diffDays <= 3) {
return `${diffDays}天后过期`
} else {
return `${end.getMonth() + 1}.${end.getDate()} 过期`
}
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return ''
@@ -108,8 +136,8 @@ const CouponCard: React.FC<CouponCardProps> = ({
{/* 左侧金额区域 */}
<View className={`coupon-left ${themeClass}`}>
<View className="amount-wrapper">
<Text className="currency">¥</Text>
<Text className="amount">{amount}</Text>
{type !== 3 && <Text className="currency">¥</Text>}
<Text className="amount">{formatAmount()}</Text>
</View>
<View className="condition">
{getConditionText()}
@@ -130,7 +158,7 @@ const CouponCard: React.FC<CouponCardProps> = ({
{title || (type === 1 ? '满减券' : type === 2 ? '折扣券' : '免费券')}
</View>
<View className="coupon-validity">
{getValidityText()}
{formatValidityPeriod()}
</View>
</View>
@@ -151,7 +179,7 @@ const CouponCard: React.FC<CouponCardProps> = ({
size="small"
onClick={onUse}
>
使
使
</Button>
)}
{status !== 0 && (

View File

@@ -0,0 +1,174 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Clock, Close, Agenda } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
export interface ExpiringSoon {
id: number
name: string
type: number
amount: string
minAmount?: string
endTime: string
daysLeft: number
}
export interface CouponExpireNoticeProps {
/** 是否显示提醒 */
visible: boolean
/** 即将过期的优惠券列表 */
expiringSoonCoupons: ExpiringSoon[]
/** 关闭回调 */
onClose: () => void
/** 使用优惠券回调 */
onUseCoupon: (coupon: ExpiringSoon) => void
}
const CouponExpireNotice: React.FC<CouponExpireNoticeProps> = ({
visible,
expiringSoonCoupons,
onClose,
onUseCoupon
}) => {
// 获取优惠券金额显示
const getCouponAmountDisplay = (coupon: ExpiringSoon) => {
switch (coupon.type) {
case 10: // 满减券
return `¥${coupon.amount}`
case 20: // 折扣券
return `${coupon.amount}`
case 30: // 免费券
return '免费'
default:
return `¥${coupon.amount}`
}
}
// 获取使用条件文本
const getConditionText = (coupon: ExpiringSoon) => {
if (coupon.type === 30) return '无门槛使用'
if (coupon.minAmount && parseFloat(coupon.minAmount) > 0) {
return `${coupon.minAmount}元可用`
}
return '无门槛使用'
}
// 获取到期时间显示
const getExpireTimeDisplay = (coupon: ExpiringSoon) => {
if (coupon.daysLeft === 0) {
return '今天到期'
} else if (coupon.daysLeft === 1) {
return '明天到期'
} else {
return `${coupon.daysLeft}天后到期`
}
}
// 去购物
const handleGoShopping = () => {
onClose()
Taro.navigateTo({
url: '/pages/index/index'
})
}
return (
<Popup
visible={visible}
position="center"
style={{ width: '90%', maxWidth: '400px' }}
round
>
<View className="p-6">
{/* 头部 */}
<View className="flex items-center justify-between mb-4">
<View className="flex items-center">
<Clock size="24" className="text-orange-500 mr-2" />
<Text className="text-lg font-semibold text-gray-900">
</Text>
</View>
<View onClick={onClose}>
<Close size="20" className="text-gray-500" />
</View>
</View>
{/* 提醒文案 */}
<View className="text-center mb-6">
<Text className="text-gray-600">
{expiringSoonCoupons.length} 使
</Text>
</View>
{/* 优惠券列表 */}
<View className="max-h-80 overflow-y-auto mb-6">
{expiringSoonCoupons.map((coupon, _) => (
<View
key={coupon.id}
className="flex items-center justify-between p-3 mb-3 bg-gray-50 rounded-lg"
>
<View className="flex-1">
<View className="flex items-center mb-1">
<Text className="font-semibold text-gray-900 mr-2">
{coupon.name}
</Text>
<View className="px-2 py-1 bg-red-100 rounded text-red-600 text-xs">
{getCouponAmountDisplay(coupon)}
</View>
</View>
<Text className="text-sm text-gray-600 mb-1">
{getConditionText(coupon)}
</Text>
<Text className={`text-xs ${
coupon.daysLeft === 0 ? 'text-red-500' :
coupon.daysLeft === 1 ? 'text-orange-500' : 'text-gray-500'
}`}>
{getExpireTimeDisplay(coupon)}
</Text>
</View>
<Button
size="small"
type="primary"
onClick={() => onUseCoupon(coupon)}
>
使
</Button>
</View>
))}
</View>
{/* 底部按钮 */}
<View className="flex gap-3">
<Button
fill="outline"
size="large"
className="flex-1"
onClick={onClose}
>
</Button>
<Button
type="primary"
size="large"
className="flex-1"
icon={<Agenda />}
onClick={handleGoShopping}
>
</Button>
</View>
{/* 底部提示 */}
<View className="text-center mt-4">
<Text className="text-xs text-gray-400">
使
</Text>
</View>
</View>
</Popup>
)
}
export default CouponExpireNotice

View File

@@ -0,0 +1,210 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup, Radio, RadioGroup, Divider } from '@nutui/nutui-react-taro'
import { Filter, Close } from '@nutui/icons-react-taro'
export interface CouponFilterProps {
/** 是否显示筛选器 */
visible: boolean
/** 当前筛选条件 */
filters: {
type?: number[]
minAmount?: number
sortBy?: 'createTime' | 'amount' | 'expireTime'
sortOrder?: 'asc' | 'desc'
}
/** 筛选条件变更回调 */
onFiltersChange: (filters: any) => void
/** 关闭回调 */
onClose: () => void
}
const CouponFilter: React.FC<CouponFilterProps> = ({
visible,
filters,
onFiltersChange,
onClose
}) => {
const [tempFilters, setTempFilters] = useState(filters)
// 优惠券类型选项
const typeOptions = [
{ label: '全部类型', value: '' },
{ label: '满减券', value: '10' },
{ label: '折扣券', value: '20' },
{ label: '免费券', value: '30' }
]
// 最低金额选项
const minAmountOptions = [
{ label: '不限', value: '' },
{ label: '10元以上', value: '10' },
{ label: '50元以上', value: '50' },
{ label: '100元以上', value: '100' },
{ label: '200元以上', value: '200' }
]
// 排序选项
const sortOptions = [
{ label: '创建时间', value: 'createTime' },
{ label: '优惠金额', value: 'amount' },
{ label: '到期时间', value: 'expireTime' }
]
// 排序方向选项
const sortOrderOptions = [
{ label: '升序', value: 'asc' },
{ label: '降序', value: 'desc' }
]
// 重置筛选条件
const handleReset = () => {
const resetFilters = {
type: [],
minAmount: undefined,
sortBy: 'createTime' as const,
sortOrder: 'desc' as const
}
setTempFilters(resetFilters)
}
// 应用筛选条件
const handleApply = () => {
onFiltersChange(tempFilters)
onClose()
}
// 更新临时筛选条件
const updateTempFilters = (key: string, value: any) => {
setTempFilters(prev => ({
...prev,
[key]: value
}))
}
return (
<Popup
visible={visible}
position="right"
style={{ width: '80%', height: '100%' }}
>
<View className="h-full flex flex-col">
{/* 头部 */}
<View className="flex items-center justify-between p-4 border-b border-gray-100">
<View className="flex items-center">
<Filter size="20" className="text-gray-600 mr-2" />
<Text className="text-lg font-semibold"></Text>
</View>
<View onClick={onClose}>
<Close size="20" className="text-gray-600" />
</View>
</View>
{/* 筛选内容 */}
<View className="flex-1 overflow-y-auto p-4">
{/* 优惠券类型 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.type?.[0]?.toString() || ''}
onChange={(value) => {
updateTempFilters('type', value ? [parseInt(value)] : [])
}}
>
{typeOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
<Divider />
{/* 最低消费金额 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.minAmount?.toString() || ''}
onChange={(value) => {
updateTempFilters('minAmount', value ? parseInt(value) : undefined)
}}
>
{minAmountOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
<Divider />
{/* 排序方式 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.sortBy || 'createTime'}
onChange={(value) => updateTempFilters('sortBy', value)}
>
{sortOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
<Divider />
{/* 排序方向 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.sortOrder || 'desc'}
onChange={(value) => updateTempFilters('sortOrder', value)}
>
{sortOrderOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
</View>
{/* 底部按钮 */}
<View className="p-4 border-t border-gray-100">
<View className="flex gap-3">
<Button
fill="outline"
size="large"
className="flex-1"
onClick={handleReset}
>
</Button>
<Button
type="primary"
size="large"
className="flex-1"
onClick={handleApply}
>
</Button>
</View>
</View>
</View>
</Popup>
)
}
export default CouponFilter

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Ask, Ticket, Clock, Gift } from '@nutui/icons-react-taro'
export interface CouponGuideProps {
/** 是否显示指南 */
visible: boolean
/** 关闭回调 */
onClose: () => void
}
const CouponGuide: React.FC<CouponGuideProps> = ({
visible,
onClose
}) => {
const [currentStep, setCurrentStep] = useState(0)
const guideSteps = [
{
title: '如何获取优惠券?',
icon: <Gift size="24" className="text-red-500" />,
content: [
'1. 点击"领取"按钮浏览可领取的优惠券',
'2. 关注商家活动和促销信息',
'3. 完成指定任务获得优惠券奖励',
'4. 邀请好友注册获得推荐奖励'
]
},
{
title: '如何使用优惠券?',
icon: <Ticket size="24" className="text-green-500" />,
content: [
'1. 选择心仪商品加入购物车',
'2. 在结算页面选择可用优惠券',
'3. 确认优惠金额后完成支付',
'4. 优惠券使用后不可退回'
]
},
{
title: '优惠券使用规则',
icon: <Clock size="24" className="text-blue-500" />,
content: [
'1. 每张优惠券只能使用一次',
'2. 优惠券有使用期限,过期作废',
'3. 满减券需达到最低消费金额',
'4. 部分优惠券仅限指定商品使用'
]
},
{
title: '常见问题解答',
icon: <Ask size="24" className="text-purple-500" />,
content: [
'Q: 优惠券可以叠加使用吗?',
'A: 一般情况下不支持叠加,具体以活动规则为准',
'Q: 优惠券过期了怎么办?',
'A: 过期优惠券无法使用,请及时关注有效期',
'Q: 退款时优惠券会退回吗?',
'A: 已使用的优惠券不会退回,请谨慎使用'
]
}
]
const handleNext = () => {
if (currentStep < guideSteps.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onClose()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onClose()
}
const currentGuide = guideSteps[currentStep]
return (
<Popup
visible={visible}
position="center"
closeable={false}
style={{ width: '90%', maxWidth: '400px' }}
>
<View className="p-6">
{/* 头部 */}
<View className="text-center mb-6">
<View className="flex justify-center mb-3">
{currentGuide.icon}
</View>
<Text className="text-xl font-bold text-gray-900">
{currentGuide.title}
</Text>
</View>
{/* 内容 */}
<View className="mb-6">
{currentGuide.content.map((item, index) => (
<View key={index} className="mb-3">
<Text className="text-gray-700 leading-relaxed">
{item}
</Text>
</View>
))}
</View>
{/* 进度指示器 */}
<View className="flex justify-center mb-6">
{guideSteps.map((_, index) => (
<View
key={index}
className={`w-2 h-2 rounded-full mx-1 ${
index === currentStep ? 'bg-red-500' : 'bg-gray-300'
}`}
/>
))}
</View>
{/* 底部按钮 */}
<View className="flex justify-between">
<View className="flex gap-2">
{currentStep > 0 && (
<Button
size="small"
fill="outline"
onClick={handlePrev}
>
</Button>
)}
<Button
size="small"
fill="outline"
onClick={handleSkip}
>
</Button>
</View>
<Button
size="small"
type="primary"
onClick={handleNext}
>
{currentStep === guideSteps.length - 1 ? '完成' : '下一步'}
</Button>
</View>
</View>
</Popup>
)
}
export default CouponGuide

View File

@@ -0,0 +1,182 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Share, Wechat, QQ, Weibo, Link, Close } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
export interface CouponShareProps {
/** 是否显示分享弹窗 */
visible: boolean
/** 优惠券信息 */
coupon: {
id: number
name: string
type: number
amount: string
minAmount?: string
description?: string
}
/** 关闭回调 */
onClose: () => void
}
const CouponShare: React.FC<CouponShareProps> = ({
visible,
coupon,
onClose
}) => {
// 生成分享文案
const generateShareText = () => {
const typeText = coupon.type === 10 ? '满减券' : coupon.type === 20 ? '折扣券' : '免费券'
const amountText = coupon.type === 10 ? `¥${coupon.amount}` :
coupon.type === 20 ? `${coupon.amount}` : '免费'
const conditionText = coupon.minAmount ? `${coupon.minAmount}元可用` : '无门槛'
return `🎁 ${coupon.name}\n💰 ${amountText} ${typeText}\n📋 ${conditionText}\n快来领取吧`
}
// 生成分享链接
const generateShareUrl = () => {
// 这里应该是实际的分享链接包含优惠券ID等参数
return `https://your-domain.com/coupon/share?id=${coupon.id}`
}
// 微信分享
const handleWechatShare = () => {
Taro.showShareMenu({
withShareTicket: true,
success: () => {
Taro.showToast({
title: '分享成功',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '分享失败',
icon: 'error'
})
}
})
}
// 复制链接
const handleCopyLink = () => {
const shareUrl = generateShareUrl()
const shareText = generateShareText()
const fullText = `${shareText}\n\n${shareUrl}`
Taro.setClipboardData({
data: fullText,
success: () => {
Taro.showToast({
title: '已复制到剪贴板',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 保存图片分享
const handleSaveImage = async () => {
try {
// 这里可以生成优惠券图片并保存到相册
// 实际实现需要canvas绘制优惠券图片
Taro.showToast({
title: '功能开发中',
icon: 'none'
})
} catch (error) {
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
const shareOptions = [
{
icon: <Wechat size="32" className="text-green-500" />,
label: '微信好友',
onClick: handleWechatShare
},
{
icon: <Link size="32" className="text-blue-500" />,
label: '复制链接',
onClick: handleCopyLink
},
{
icon: <Share size="32" className="text-purple-500" />,
label: '保存图片',
onClick: handleSaveImage
}
]
return (
<Popup
visible={visible}
position="bottom"
style={{ height: 'auto' }}
round
>
<View className="p-6">
{/* 头部 */}
<View className="flex items-center justify-between mb-6">
<Text className="text-lg font-semibold"></Text>
<View onClick={onClose}>
<Close size="20" className="text-gray-500" />
</View>
</View>
{/* 优惠券预览 */}
<View className="bg-gradient-to-r from-red-400 to-red-500 rounded-xl p-4 mb-6 text-white">
<Text className="text-xl font-bold mb-2">{coupon.name}</Text>
<View className="flex items-center justify-between">
<View>
<Text className="text-2xl font-bold">
{coupon.type === 10 ? `¥${coupon.amount}` :
coupon.type === 20 ? `${coupon.amount}` : '免费'}
</Text>
<Text className="text-sm opacity-90">
{coupon.minAmount ? `${coupon.minAmount}元可用` : '无门槛使用'}
</Text>
</View>
<Share size="24" />
</View>
</View>
{/* 分享选项 */}
<View className="grid grid-cols-3 gap-4 mb-4">
{shareOptions.map((option, index) => (
<View
key={index}
className="flex flex-col items-center py-4 bg-gray-50 rounded-lg"
onClick={option.onClick}
>
<View className="mb-2">{option.icon}</View>
<Text className="text-sm text-gray-700">{option.label}</Text>
</View>
))}
</View>
{/* 分享文案预览 */}
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-xs text-gray-500 mb-2"></Text>
<Text className="text-sm text-gray-700 leading-relaxed">
{generateShareText()}
</Text>
</View>
</View>
</Popup>
)
}
export default CouponShare

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock } from '@nutui/icons-react-taro'
export interface CouponStatsProps {
/** 可用优惠券数量 */
availableCount: number
/** 已使用优惠券数量 */
usedCount: number
/** 已过期优惠券数量 */
expiredCount: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired') => void
}
const CouponStats: React.FC<CouponStatsProps> = ({
availableCount,
usedCount,
expiredCount,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-4">
<Text className="text-lg font-semibold mb-4 text-gray-800"></Text>
<View className="flex justify-between">
{/* 可用优惠券 */}
<View
className="flex-1 text-center py-3 mx-1 bg-red-50 rounded-lg"
onClick={() => handleStatsClick('available')}
>
<View className="flex justify-center mb-2">
<Gift size="24" className="text-red-500" />
</View>
<Text className="text-2xl font-bold text-red-500 block">{availableCount}</Text>
<Text className="text-sm text-gray-600 mt-1"></Text>
</View>
{/* 已使用优惠券 */}
<View
className="flex-1 text-center py-3 mx-1 bg-green-50 rounded-lg"
onClick={() => handleStatsClick('used')}
>
<View className="flex justify-center mb-2">
<Voucher size="24" className="text-green-500" />
</View>
<Text className="text-2xl font-bold text-green-500 block">{usedCount}</Text>
<Text className="text-sm text-gray-600 mt-1">使</Text>
</View>
{/* 已过期优惠券 */}
<View
className="flex-1 text-center py-3 mx-1 bg-gray-50 rounded-lg"
onClick={() => handleStatsClick('expired')}
>
<View className="flex justify-center mb-2">
<Clock size="24" className="text-gray-500" />
</View>
<Text className="text-2xl font-bold text-gray-500 block">{expiredCount}</Text>
<Text className="text-sm text-gray-600 mt-1"></Text>
</View>
</View>
</View>
)
}
export default CouponStats

View File

@@ -0,0 +1,162 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Tag } from '@nutui/nutui-react-taro'
import { Voucher, Clock, Agenda } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
export interface CouponUsageRecordProps {
/** 优惠券ID */
couponId: number
/** 优惠券名称 */
couponName: string
/** 优惠券类型 */
couponType: number
/** 优惠券金额 */
couponAmount: string
/** 使用时间 */
usedTime: string
/** 订单号 */
orderNo?: string
/** 订单金额 */
orderAmount?: string
/** 节省金额 */
savedAmount?: string
/** 使用状态1-已使用 2-已过期 */
status: 1 | 2
/** 点击事件 */
onClick?: () => void
}
const CouponUsageRecord: React.FC<CouponUsageRecordProps> = ({
couponName,
couponType,
couponAmount,
usedTime,
orderNo,
orderAmount,
savedAmount,
status,
onClick
}) => {
// 获取优惠券类型文本
const getCouponTypeText = () => {
switch (couponType) {
case 10: return '满减券'
case 20: return '折扣券'
case 30: return '免费券'
default: return '优惠券'
}
}
// 获取优惠券金额显示
const getCouponAmountDisplay = () => {
switch (couponType) {
case 10: // 满减券
return `¥${couponAmount}`
case 20: // 折扣券
return `${couponAmount}`
case 30: // 免费券
return '免费'
default:
return `¥${couponAmount}`
}
}
// 获取状态信息
const getStatusInfo = () => {
switch (status) {
case 1:
return {
text: '已使用',
color: 'success' as const,
icon: <Voucher size="16" className="text-green-500" />
}
case 2:
return {
text: '已过期',
color: 'danger' as const,
icon: <Clock size="16" className="text-red-500" />
}
default:
return {
text: '未知',
color: 'default' as const,
icon: <Clock size="16" className="text-gray-500" />
}
}
}
const statusInfo = getStatusInfo()
return (
<View
className="bg-white mx-4 mb-3 rounded-xl p-4 border border-gray-100"
onClick={onClick}
>
{/* 头部信息 */}
<View className="flex items-center justify-between mb-3">
<View className="flex items-center">
<View className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-3">
<Text className="text-red-500 font-bold text-lg">
{getCouponAmountDisplay()}
</Text>
</View>
<View>
<Text className="font-semibold text-gray-900 text-base">{couponName}</Text>
<Text className="text-gray-500 text-sm">{getCouponTypeText()}</Text>
</View>
</View>
<View className="flex items-center">
{statusInfo.icon}
<Tag type={statusInfo.color} className="ml-2">
{statusInfo.text}
</Tag>
</View>
</View>
{/* 使用详情 */}
<View className="bg-gray-50 rounded-lg p-3">
<View className="flex items-center justify-between mb-2">
<Text className="text-gray-600 text-sm">使</Text>
<Text className="text-gray-900 text-sm">
{dayjs(usedTime).format('YYYY-MM-DD HH:mm')}
</Text>
</View>
{orderNo && (
<View className="flex items-center justify-between mb-2">
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900 text-sm">{orderNo}</Text>
</View>
)}
{orderAmount && (
<View className="flex items-center justify-between mb-2">
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900 text-sm">¥{orderAmount}</Text>
</View>
)}
{savedAmount && (
<View className="flex items-center justify-between">
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-red-500 text-sm font-semibold">¥{savedAmount}</Text>
</View>
)}
</View>
{/* 底部操作 */}
{orderNo && (
<View className="flex justify-end mt-3">
<View className="flex items-center text-blue-500 text-sm">
<Agenda size="14" className="mr-1" />
<Text></Text>
</View>
</View>
)}
</View>
)
}
export default CouponUsageRecord

View File

@@ -0,0 +1,307 @@
.gift-card {
position: relative;
width: 100%;
margin-bottom: 16px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: #fff;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
&.disabled {
opacity: 0.7;
}
// 主题色彩
&.gift-card-gold {
.gift-card-header {
background: #ffd700;
}
.use-btn {
background: #ffd700;
border: none;
color: #333;
}
}
&.gift-card-silver {
.gift-card-header {
background: #c0c0c0;
}
.use-btn {
background: #c0c0c0;
border: none;
color: #333;
}
}
&.gift-card-bronze {
.gift-card-header {
background: #cd7f32;
}
.use-btn {
background: #cd7f32;
border: none;
color: #fff;
}
}
&.gift-card-blue {
.gift-card-header {
background: #4a90e2;
}
.use-btn {
background: #4a90e2;
border: none;
color: #fff;
}
}
&.gift-card-green {
.gift-card-header {
background: #5cb85c;
}
.use-btn {
background: #5cb85c;
border: none;
color: #fff;
}
}
&.gift-card-purple {
.gift-card-header {
background: #9b59b6;
}
.use-btn {
background: #9b59b6;
border: none;
color: #fff;
}
}
.gift-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
color: #fff;
position: relative;
.gift-card-logo {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.gift-card-title {
flex: 1;
margin-left: 12px;
.title-text {
display: block;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
margin-bottom: 2px;
}
.type-text {
display: block;
font-size: 14px;
opacity: 0.9;
}
}
.gift-card-status {
.nut-tag {
background: rgba(255, 255, 255, 0.9);
color: #333;
border: none;
}
}
}
.gift-card-body {
padding: 20px;
.gift-card-content {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
.gift-image {
margin-right: 16px;
flex-shrink: 0;
}
.gift-info {
flex: 1;
.gift-value {
display: flex;
align-items: baseline;
margin-bottom: 8px;
.value-label {
font-size: 14px;
color: #666;
margin-right: 8px;
}
.value-amount {
font-size: 24px;
font-weight: bold;
color: #333;
}
}
.gift-description {
font-size: 14px;
color: #666;
line-height: 1.4;
margin-bottom: 12px;
}
.gift-code {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
border: 1px dashed #ddd;
.code-label {
display: block;
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.code-value {
font-size: 16px;
font-weight: 600;
color: #333;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
}
}
}
.gift-time-info {
.time-item {
display: flex;
align-items: center;
margin-bottom: 6px;
.time-text {
font-size: 12px;
color: #666;
margin-left: 6px;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
.gift-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 20px;
.footer-info {
.contact-info {
display: flex;
align-items: center;
.contact-text {
font-size: 12px;
color: #999;
margin-left: 4px;
}
}
}
.footer-actions {
display: flex;
gap: 8px;
.use-btn {
min-width: 80px;
font-weight: 600;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
}
}
.gift-card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.overlay-badge {
background: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 8px 16px;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
}
}
}
// 礼品卡基础样式
.gift-card {
opacity: 1;
transform: translateY(0);
}
// 使用按钮效果
.use-btn {
transition: all 0.3s ease;
}
// 响应式设计
@media (max-width: 768px) {
.gift-card {
.gift-card-body {
padding: 16px;
.gift-card-content {
.gift-info {
.gift-value {
.value-amount {
font-size: 20px;
}
}
}
}
}
.gift-card-footer {
padding: 0 16px 16px;
}
}
}

282
src/components/GiftCard.tsx Normal file
View File

@@ -0,0 +1,282 @@
import React from 'react'
import { View, Text, Image } from '@tarojs/components'
import { Button, Tag } from '@nutui/nutui-react-taro'
import { Gift, Clock, Location, Phone } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import './GiftCard.scss'
export interface GiftCardProps {
/** 礼品卡ID */
id: number
/** 礼品卡名称 */
name: string
/** 礼品卡描述 */
description?: string
/** 礼品卡兑换码 */
code?: string
/** 商品图片 */
goodsImage?: string
/** 礼品卡面值 */
faceValue?: string
/** 礼品卡类型10-实物礼品卡 20-虚拟礼品卡 30-服务礼品卡 */
type?: number
/** 使用状态0-可用 1-已使用 2-已过期 */
useStatus?: number
/** 过期时间 */
expireTime?: string
/** 使用时间 */
useTime?: string
/** 使用地址 */
useLocation?: string
/** 客服联系方式 */
contactInfo?: string
/** 是否显示兑换码 */
showCode?: boolean
/** 是否显示使用按钮 */
showUseBtn?: boolean
/** 是否显示详情按钮 */
showDetailBtn?: boolean
/** 卡片主题色 */
theme?: 'gold' | 'silver' | 'bronze' | 'blue' | 'green' | 'purple'
/** 使用按钮点击事件 */
onUse?: () => void
/** 详情按钮点击事件 */
onDetail?: () => void
/** 卡片点击事件 */
onClick?: () => void
}
const GiftCard: React.FC<GiftCardProps> = ({
id,
name,
description,
code,
goodsImage,
faceValue,
type = 10,
useStatus = 0,
expireTime,
useTime,
useLocation,
contactInfo,
showCode = false,
showUseBtn = false,
showDetailBtn = true,
theme = 'gold',
onUse,
onDetail,
onClick
}) => {
// 获取礼品卡类型文本
const getTypeText = () => {
switch (type) {
case 10: return '实物礼品卡'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
}
}
// 获取使用状态信息
const getStatusInfo = () => {
switch (useStatus) {
case 0:
return {
text: '可使用',
color: 'success' as const,
bgColor: 'bg-green-100',
textColor: 'text-green-600'
}
case 1:
return {
text: '已使用',
color: 'warning' as const,
bgColor: 'bg-gray-100',
textColor: 'text-gray-600'
}
case 2:
return {
text: '已过期',
color: 'danger' as const,
bgColor: 'bg-red-100',
textColor: 'text-red-600'
}
default:
return {
text: '未知',
color: 'default' as const,
bgColor: 'bg-gray-100',
textColor: 'text-gray-600'
}
}
}
// 获取主题样式类名
const getThemeClass = () => {
return `gift-card-${theme}`
}
// 格式化过期时间显示
const formatExpireTime = () => {
if (!expireTime) return ''
const expire = dayjs(expireTime)
const now = dayjs()
const diffDays = expire.diff(now, 'day')
if (diffDays < 0) {
return '已过期'
} else if (diffDays === 0) {
return '今天过期'
} else if (diffDays <= 7) {
return `${diffDays}天后过期`
} else {
return expire.format('YYYY-MM-DD 过期')
}
}
// 格式化兑换码显示
const formatCode = () => {
if (!code) return ''
if (!showCode) return code.replace(/(.{4})/g, '$1 ').trim()
return code.replace(/(.{4})/g, '$1 ').trim()
}
const statusInfo = getStatusInfo()
return (
<View
className={`gift-card ${getThemeClass()} ${useStatus !== 0 ? 'disabled' : ''}`}
onClick={onClick}
>
{/* 卡片头部 */}
<View className="gift-card-header">
<View className="gift-card-logo">
<Gift size="24" className="text-white" />
</View>
<View className="gift-card-title">
<Text className="title-text">{name}</Text>
<Text className="type-text">{getTypeText()}</Text>
</View>
<View className="gift-card-status">
<Tag type={statusInfo.color}>{statusInfo.text}</Tag>
</View>
</View>
{/* 卡片主体 */}
<View className="gift-card-body">
<View className="gift-card-content">
{/* 商品图片 */}
{goodsImage && (
<View className="gift-image">
<Image
src={goodsImage}
className="w-16 h-16 rounded-lg object-cover"
mode="aspectFill"
/>
</View>
)}
<View className="gift-info">
{/* 面值 */}
{faceValue && (
<View className="gift-value">
<Text className="value-label"></Text>
<Text className="value-amount">¥{faceValue}</Text>
</View>
)}
{/* 描述 */}
{description && (
<Text className="gift-description">{description}</Text>
)}
{/* 兑换码 */}
{code && (
<View className="gift-code">
<Text className="code-label"></Text>
<Text className="code-value">{formatCode()}</Text>
</View>
)}
</View>
</View>
{/* 时间信息 */}
<View className="gift-time-info">
{useStatus === 1 && useTime && (
<View className="time-item">
<Clock size="14" className="text-gray-400" />
<Text className="time-text">使{dayjs(useTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
)}
{useStatus === 0 && expireTime && (
<View className="time-item">
<Clock size="14" className="text-orange-500" />
<Text className="time-text">{formatExpireTime()}</Text>
</View>
)}
{useLocation && (
<View className="time-item">
<Location size="14" className="text-gray-400" />
<Text className="time-text">使{useLocation}</Text>
</View>
)}
</View>
</View>
{/* 卡片底部操作 */}
<View className="gift-card-footer">
<View className="footer-info">
{contactInfo && (
<View className="contact-info">
<Phone size="12" className="text-gray-400" />
<Text className="contact-text">{contactInfo}</Text>
</View>
)}
</View>
<View className="footer-actions">
{showDetailBtn && (
<Button
size="small"
fill="outline"
onClick={(e) => {
e.stopPropagation()
onDetail?.()
}}
>
</Button>
)}
{showUseBtn && useStatus === 0 && (
<Button
size="small"
type="primary"
className={`use-btn ${getThemeClass()}`}
onClick={(e) => {
e.stopPropagation()
onUse?.()
}}
>
使
</Button>
)}
</View>
</View>
{/* 状态遮罩 */}
{useStatus !== 0 && (
<View className="gift-card-overlay">
<View className="overlay-badge">
{statusInfo.text}
</View>
</View>
)}
</View>
)
}
export default GiftCard

View File

@@ -0,0 +1,169 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Gift, QrCode, Voucher, Service } from '@nutui/icons-react-taro'
export interface GiftCardGuideProps {
/** 是否显示指南 */
visible: boolean
/** 关闭回调 */
onClose: () => void
}
const GiftCardGuide: React.FC<GiftCardGuideProps> = ({
visible,
onClose
}) => {
const [currentStep, setCurrentStep] = useState(0)
const guideSteps = [
{
title: '如何获取礼品卡?',
icon: <Gift size="24" className="text-yellow-500" />,
content: [
'1. 通过兑换码兑换礼品卡',
'2. 扫描二维码快速兑换',
'3. 参与活动获得礼品卡奖励',
'4. 朋友赠送的礼品卡'
]
},
{
title: '如何兑换礼品卡?',
icon: <QrCode size="24" className="text-blue-500" />,
content: [
'1. 点击"兑换"按钮进入兑换页面',
'2. 输入礼品卡兑换码或扫码输入',
'3. 验证兑换码有效性',
'4. 确认兑换,礼品卡添加到账户'
]
},
{
title: '如何使用礼品卡?',
icon: <Voucher size="24" className="text-green-500" />,
content: [
'1. 选择可用状态的礼品卡',
'2. 点击"立即使用"按钮',
'3. 填写使用信息(地址、备注等)',
'4. 确认使用,完成礼品卡消费'
]
},
{
title: '礼品卡类型说明',
icon: <Gift size="24" className="text-purple-500" />,
content: [
'🎁 实物礼品卡:需到指定地址领取商品',
'💻 虚拟礼品卡:自动发放到账户余额',
'🛎️ 服务礼品卡:联系客服预约服务',
'⏰ 注意查看有效期,过期无法使用'
]
},
{
title: '常见问题解答',
icon: <Service size="24" className="text-red-500" />,
content: [
'Q: 礼品卡可以转赠他人吗?',
'A: 未使用的礼品卡可以通过分享功能转赠',
'Q: 礼品卡过期了怎么办?',
'A: 过期礼品卡无法使用,请及时关注有效期',
'Q: 使用礼品卡后可以退款吗?',
'A: 已使用的礼品卡不支持退款操作'
]
}
]
const handleNext = () => {
if (currentStep < guideSteps.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onClose()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onClose()
}
const currentGuide = guideSteps[currentStep]
return (
<Popup
visible={visible}
position="center"
closeable={false}
style={{ width: '90%', maxWidth: '400px' }}
>
<View className="p-6">
{/* 头部 */}
<View className="text-center mb-6">
<View className="flex justify-center mb-3">
{currentGuide.icon}
</View>
<Text className="text-xl font-bold text-gray-900">
{currentGuide.title}
</Text>
</View>
{/* 内容 */}
<View className="mb-6">
{currentGuide.content.map((item, index) => (
<View key={index} className="mb-3">
<Text className="text-gray-700 leading-relaxed">
{item}
</Text>
</View>
))}
</View>
{/* 进度指示器 */}
<View className="flex justify-center mb-6">
{guideSteps.map((_, index) => (
<View
key={index}
className={`w-2 h-2 rounded-full mx-1 ${
index === currentStep ? 'bg-yellow-500' : 'bg-gray-300'
}`}
/>
))}
</View>
{/* 底部按钮 */}
<View className="flex justify-between">
<View className="flex gap-2">
{currentStep > 0 && (
<Button
size="small"
fill="outline"
onClick={handlePrev}
>
</Button>
)}
<Button
size="small"
fill="outline"
onClick={handleSkip}
>
</Button>
</View>
<Button
size="small"
type="primary"
onClick={handleNext}
>
{currentStep === guideSteps.length - 1 ? '完成' : '下一步'}
</Button>
</View>
</View>
</Popup>
)
}
export default GiftCardGuide

View File

@@ -0,0 +1,173 @@
import React from 'react'
import { View, ScrollView } from '@tarojs/components'
import GiftCard, { GiftCardProps } from './GiftCard'
export interface GiftCardListProps {
/** 礼品卡列表数据 */
gifts: GiftCardProps[]
/** 列表标题 */
title?: string
/** 布局方式vertical-垂直布局 horizontal-水平滚动 grid-网格布局 */
layout?: 'vertical' | 'horizontal' | 'grid'
/** 网格列数仅在grid布局时有效 */
columns?: number
/** 是否显示空状态 */
showEmpty?: boolean
/** 空状态文案 */
emptyText?: string
/** 礼品卡点击事件 */
onGiftClick?: (gift: GiftCardProps, index: number) => void
/** 礼品卡使用事件 */
onGiftUse?: (gift: GiftCardProps, index: number) => void
/** 礼品卡详情事件 */
onGiftDetail?: (gift: GiftCardProps, index: number) => void
}
const GiftCardList: React.FC<GiftCardListProps> = ({
gifts = [],
title,
layout = 'vertical',
columns = 2,
showEmpty = true,
emptyText = '暂无礼品卡',
onGiftClick,
onGiftUse,
onGiftDetail
}) => {
const handleGiftClick = (gift: GiftCardProps, index: number) => {
onGiftClick?.(gift, index)
}
const handleGiftUse = (gift: GiftCardProps, index: number) => {
onGiftUse?.(gift, index)
}
const handleGiftDetail = (gift: GiftCardProps, index: number) => {
onGiftDetail?.(gift, index)
}
// 垂直布局
if (layout === 'vertical') {
return (
<View className="p-4">
{title && (
<View className="font-semibold text-gray-800 mb-4 text-lg">{title}</View>
)}
{gifts.length === 0 ? (
showEmpty && (
<View className="text-center py-16 px-5">
<View className="text-gray-400 mb-4">
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<View className="text-2xl">🎁</View>
</View>
</View>
<View className="text-gray-500 text-base">{emptyText}</View>
</View>
)
) : (
gifts.map((gift, index) => (
<GiftCard
key={gift.id || index}
{...gift}
onClick={() => handleGiftClick(gift, index)}
onUse={() => handleGiftUse(gift, index)}
onDetail={() => handleGiftDetail(gift, index)}
/>
))
)}
</View>
)
}
// 网格布局
if (layout === 'grid') {
return (
<View className="p-4">
{title && (
<View className="font-semibold text-gray-800 mb-4 text-lg">{title}</View>
)}
{gifts.length === 0 ? (
showEmpty && (
<View className="text-center py-16 px-5">
<View className="text-gray-400 mb-4">
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<View className="text-2xl">🎁</View>
</View>
</View>
<View className="text-gray-500 text-base">{emptyText}</View>
</View>
)
) : (
<View
className="flex flex-wrap"
style={{gap: '12px'}}
>
{gifts.map((gift, index) => (
<View
key={gift.id || index}
className="w-full"
style={{width: `calc(${100/columns}% - ${12*(columns-1)/columns}px)`}}
>
<GiftCard
{...gift}
onClick={() => handleGiftClick(gift, index)}
onUse={() => handleGiftUse(gift, index)}
onDetail={() => handleGiftDetail(gift, index)}
/>
</View>
))}
</View>
)}
</View>
)
}
// 水平滚动布局
return (
<View>
{title && (
<View className="font-semibold text-gray-800 mb-4 pl-4 text-lg">
{title}
</View>
)}
{gifts.length === 0 ? (
showEmpty && (
<View className="text-center py-16 px-5">
<View className="text-gray-400 mb-4">
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<View className="text-2xl">🎁</View>
</View>
</View>
<View className="text-gray-500 text-base">{emptyText}</View>
</View>
)
) : (
<ScrollView
scrollX
className="flex p-4 gap-3 overflow-x-auto"
showScrollbar={false}
style={{}}
>
{gifts.map((gift, index) => (
<View
key={gift.id || index}
className="flex-shrink-0 w-80 mb-0"
>
<GiftCard
{...gift}
onClick={() => handleGiftClick(gift, index)}
onUse={() => handleGiftUse(gift, index)}
onDetail={() => handleGiftDetail(gift, index)}
/>
</View>
))}
</ScrollView>
)}
</View>
)
}
export default GiftCardList

View File

@@ -0,0 +1,227 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Share, Wechat, QQ, Weibo, Link, Close, Gift } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
export interface GiftCardShareProps {
/** 是否显示分享弹窗 */
visible: boolean
/** 礼品卡信息 */
giftCard: {
id: number
name: string
type: number
faceValue: string
code?: string
description?: string
}
/** 关闭回调 */
onClose: () => void
}
const GiftCardShare: React.FC<GiftCardShareProps> = ({
visible,
giftCard,
onClose
}) => {
// 获取礼品卡类型文本
const getTypeText = () => {
switch (giftCard.type) {
case 10: return '实物礼品卡'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
}
}
// 生成分享文案
const generateShareText = () => {
const typeText = getTypeText()
const valueText = `¥${giftCard.faceValue}`
return `🎁 ${giftCard.name}\n💰 面值 ${valueText}\n🏷 ${typeText}\n${giftCard.description ? `📝 ${giftCard.description}\n` : ''}快来领取这份礼品卡吧!`
}
// 生成分享链接
const generateShareUrl = () => {
// 这里应该是实际的分享链接包含礼品卡ID等参数
return `https://your-domain.com/gift/share?id=${giftCard.id}`
}
// 微信分享
const handleWechatShare = () => {
Taro.showShareMenu({
withShareTicket: true,
success: () => {
Taro.showToast({
title: '分享成功',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '分享失败',
icon: 'error'
})
}
})
}
// 复制链接
const handleCopyLink = () => {
const shareUrl = generateShareUrl()
const shareText = generateShareText()
const fullText = `${shareText}\n\n${shareUrl}`
Taro.setClipboardData({
data: fullText,
success: () => {
Taro.showToast({
title: '已复制到剪贴板',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 复制兑换码
const handleCopyCode = () => {
if (!giftCard.code) {
Taro.showToast({
title: '暂无兑换码',
icon: 'none'
})
return
}
Taro.setClipboardData({
data: giftCard.code,
success: () => {
Taro.showToast({
title: '兑换码已复制',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 保存图片分享
const handleSaveImage = async () => {
try {
// 这里可以生成礼品卡图片并保存到相册
// 实际实现需要canvas绘制礼品卡图片
Taro.showToast({
title: '功能开发中',
icon: 'none'
})
} catch (error) {
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
const shareOptions = [
{
icon: <Wechat size="32" className="text-green-500" />,
label: '微信好友',
onClick: handleWechatShare
},
{
icon: <Link size="32" className="text-blue-500" />,
label: '复制链接',
onClick: handleCopyLink
},
{
icon: <Gift size="32" className="text-purple-500" />,
label: '复制兑换码',
onClick: handleCopyCode,
disabled: !giftCard.code
},
{
icon: <Share size="32" className="text-orange-500" />,
label: '保存图片',
onClick: handleSaveImage
}
]
return (
<Popup
visible={visible}
position="bottom"
style={{ height: 'auto' }}
round
>
<View className="p-6">
{/* 头部 */}
<View className="flex items-center justify-between mb-6">
<Text className="text-lg font-semibold"></Text>
<View onClick={onClose}>
<Close size="20" className="text-gray-500" />
</View>
</View>
{/* 礼品卡预览 */}
<View className="rounded-xl p-4 mb-6 text-white" style={{backgroundColor: '#fbbf24'}}>
<Text className="text-xl font-bold mb-2">{giftCard.name}</Text>
<View className="flex items-center justify-between">
<View>
<Text className="text-2xl font-bold">¥{giftCard.faceValue}</Text>
<Text className="text-sm opacity-90">{getTypeText()}</Text>
</View>
<Gift size="24" />
</View>
{giftCard.code && (
<View className="mt-3 p-2 bg-white bg-opacity-20 rounded">
<Text className="text-xs opacity-80"></Text>
<Text className="font-mono font-bold">{giftCard.code}</Text>
</View>
)}
</View>
{/* 分享选项 */}
<View className="flex justify-between mb-4" style={{gap: '16px'}}>
{shareOptions.map((option, index) => (
<View
key={index}
className={`flex-1 flex flex-col items-center py-4 bg-gray-50 rounded-lg ${
option.disabled ? 'opacity-50' : ''
}`}
onClick={option.disabled ? undefined : option.onClick}
>
<View className="mb-2">{option.icon}</View>
<Text className="text-sm text-gray-700">{option.label}</Text>
</View>
))}
</View>
{/* 分享文案预览 */}
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-xs text-gray-500 mb-2"></Text>
<Text className="text-sm text-gray-700 leading-relaxed">
{generateShareText()}
</Text>
</View>
</View>
</Popup>
)
}
export default GiftCardShare

View File

@@ -0,0 +1,72 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock } from '@nutui/icons-react-taro'
export interface GiftCardStatsProps {
/** 可用礼品卡数量 */
availableCount: number
/** 已使用礼品卡数量 */
usedCount: number
/** 已过期礼品卡数量 */
expiredCount: number
/** 礼品卡总价值 */
totalValue?: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired' | 'total') => void
}
const GiftCardStats: React.FC<GiftCardStatsProps> = ({
availableCount,
usedCount,
expiredCount,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired' | 'total') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-3 shadow-sm">
{/* 紧凑的统计卡片 - 2x2 网格 */}
<View className="grid grid-cols-3 gap-2">
{/* 可用礼品卡 */}
<View
className="flex items-center justify-between p-2 bg-yellow-50 rounded-lg border border-yellow-200"
onClick={() => handleStatsClick('available')}
>
<View className="flex items-center">
<Gift size="16" className="text-yellow-600 mr-1" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-yellow-600">{availableCount}</Text>
</View>
{/* 已使用礼品卡 */}
<View
className="flex items-center justify-between p-2 bg-green-50 rounded-lg border border-green-200"
onClick={() => handleStatsClick('used')}
>
<View className="flex items-center">
<Voucher size="16" className="text-green-600 mr-1" />
<Text className="text-sm text-gray-600">使</Text>
</View>
<Text className="text-lg font-bold text-green-600">{usedCount}</Text>
</View>
{/* 已过期礼品卡 */}
<View
className="flex items-center justify-between p-2 bg-gray-50 rounded-lg border border-gray-200"
onClick={() => handleStatsClick('expired')}
>
<View className="flex items-center">
<Clock size="16" className="text-gray-500 mr-1" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-gray-500">{expiredCount}</Text>
</View>
</View>
</View>
)
}
export default GiftCardStats

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock, Star } from '@nutui/icons-react-taro'
export interface GiftCardStatsProps {
/** 可用礼品卡数量 */
availableCount: number
/** 已使用礼品卡数量 */
usedCount: number
/** 已过期礼品卡数量 */
expiredCount: number
/** 礼品卡总价值 */
totalValue?: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired' | 'total') => void
}
const GiftCardStats: React.FC<GiftCardStatsProps> = ({
availableCount,
usedCount,
expiredCount,
totalValue,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired' | 'total') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-3 shadow-sm">
{/* 紧凑的统计卡片 - 2x2 网格 */}
<View className="grid grid-cols-2 gap-2">
{/* 可用礼品卡 */}
<View
className="flex items-center justify-between p-3 bg-yellow-50 rounded-lg border border-yellow-200"
onClick={() => handleStatsClick('available')}
>
<View className="flex items-center">
<Gift size="20" className="text-yellow-600 mr-2" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-yellow-600">{availableCount}</Text>
</View>
{/* 已使用礼品卡 */}
<View
className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-200"
onClick={() => handleStatsClick('used')}
>
<View className="flex items-center">
<Voucher size="20" className="text-green-600 mr-2" />
<Text className="text-sm text-gray-600">使</Text>
</View>
<Text className="text-lg font-bold text-green-600">{usedCount}</Text>
</View>
{/* 已过期礼品卡 */}
<View
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200"
onClick={() => handleStatsClick('expired')}
>
<View className="flex items-center">
<Clock size="20" className="text-gray-500 mr-2" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-gray-500">{expiredCount}</Text>
</View>
{/* 总价值 */}
{totalValue !== undefined && (
<View
className="flex items-center justify-between p-3 bg-purple-50 rounded-lg border border-purple-200"
onClick={() => handleStatsClick('total')}
>
<View className="flex items-center">
<Star size="20" className="text-purple-600 mr-2" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-purple-600">¥{totalValue}</Text>
</View>
)}
</View>
</View>
)
}
export default GiftCardStats