feat(src): 新增文章、经销商申请、用户地址和礼物添加功能
- 新增文章添加页面,支持文章基本信息、设置、高级设置和图片上传 - 新增经销商申请页面,支持申请信息填写和审核状态显示 - 新增用户地址添加页面,支持地址信息填写和地址识别功能 - 新增礼物添加页面,功能与文章添加类似 - 统一使用 .tsx 文件格式 - 添加 .editorconfig、.eslintrc 和 .gitignore 文件,规范代码风格和项目结构
This commit is contained in:
70
src/components/AddCartBar.tsx
Normal file
70
src/components/AddCartBar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {Headphones, Share} from '@nutui/icons-react-taro'
|
||||
import navTo from "@/utils/common";
|
||||
import Taro, { getCurrentInstance } from '@tarojs/taro';
|
||||
import {getUserInfo} from "@/api/layout";
|
||||
import {useEffect, useState} from "react";
|
||||
import {getCmsArticle} from "@/api/cms/cmsArticle";
|
||||
import {CmsArticle} from "@/api/cms/cmsArticle/model";
|
||||
|
||||
function AddCartBar() {
|
||||
const { router } = getCurrentInstance();
|
||||
const [id, setId] = useState<number>()
|
||||
const [article, setArticle] = useState<CmsArticle>()
|
||||
const [IsLogin, setIsLogin] = useState<boolean>(false)
|
||||
const onPay = () => {
|
||||
if (!IsLogin) {
|
||||
Taro.showToast({title: `请先登录`, icon: 'error'})
|
||||
setTimeout(() => {
|
||||
Taro.switchTab(
|
||||
{
|
||||
url: '/pages/user/user',
|
||||
},
|
||||
)
|
||||
}, 1000)
|
||||
return false;
|
||||
}
|
||||
if (article?.model == 'bm') {
|
||||
navTo('/bszx/bm/bm?id=' + id)
|
||||
}
|
||||
if (article?.model == 'pay') {
|
||||
navTo('/bszx/pay/pay?id=' + id)
|
||||
}
|
||||
}
|
||||
const reload = (id) => {
|
||||
getCmsArticle(id).then(data => {
|
||||
setArticle(data)
|
||||
})
|
||||
getUserInfo().then((data) => {
|
||||
if (data) {
|
||||
setIsLogin(true);
|
||||
Taro.setStorageSync('UserId', data.userId)
|
||||
}
|
||||
}).catch(() => {
|
||||
console.log('未登录')
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const id = router?.params.id as number | undefined;
|
||||
setId(id)
|
||||
reload(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={'flex justify-between items-center w-full fixed bottom-0 bg-gray-100 pb-5'}>
|
||||
<div className={'btn flex px-5 items-center gap-4'}>
|
||||
<button className={'item px-4 py-1 bg-white flex items-center gap-2 text-nowrap whitespace-nowrap'} open-type="contact">
|
||||
<Headphones size={16}/>咨询
|
||||
</button>
|
||||
<button className={'item px-4 py-1 bg-white flex items-center gap-2 text-nowrap whitespace-nowrap'} open-type="share"><Share
|
||||
size={16}/>分享
|
||||
</button>
|
||||
</div>
|
||||
<div className={'bg-red-500 py-3 px-10 text-white'} style={{ whiteSpace: 'nowrap'}}
|
||||
onClick={onPay}>{article?.model == 'pay' ? '我要捐款' : '我要报名'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 监听页面分享事件
|
||||
export default AddCartBar
|
||||
60
src/components/CartIcon.tsx
Normal file
60
src/components/CartIcon.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Badge } from "@nutui/nutui-react-taro";
|
||||
import { Cart } from "@nutui/icons-react-taro";
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useCart } from "@/hooks/useCart";
|
||||
|
||||
interface CartIconProps {
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
size?: number;
|
||||
showBadge?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const CartIcon: React.FC<CartIconProps> = ({
|
||||
style,
|
||||
className = '',
|
||||
size = 16,
|
||||
showBadge = true,
|
||||
onClick
|
||||
}) => {
|
||||
const { cartCount } = useCart();
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
// 默认跳转到购物车页面
|
||||
Taro.switchTab({ url: '/pages/cart/cart' });
|
||||
}
|
||||
};
|
||||
|
||||
if (showBadge) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Badge value={cartCount} top="-2" right="2">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Cart size={size} />
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Cart size={size} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartIcon;
|
||||
217
src/components/CouponCard.scss
Normal file
217
src/components/CouponCard.scss
Normal file
@@ -0,0 +1,217 @@
|
||||
.coupon-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
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.6;
|
||||
}
|
||||
|
||||
.coupon-left {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
|
||||
&.theme-red {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||
}
|
||||
|
||||
&.theme-orange {
|
||||
background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
|
||||
}
|
||||
|
||||
&.theme-blue {
|
||||
background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%);
|
||||
}
|
||||
|
||||
&.theme-purple {
|
||||
background: linear-gradient(135deg, #ab47bc 0%, #9c27b0 100%);
|
||||
}
|
||||
|
||||
&.theme-green {
|
||||
background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
|
||||
}
|
||||
|
||||
.amount-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.currency {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.condition {
|
||||
font-size: 22px;
|
||||
opacity: 0.9;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.coupon-divider {
|
||||
flex-shrink: 0;
|
||||
width: 2px;
|
||||
position: relative;
|
||||
background: #f5f5f5;
|
||||
|
||||
.divider-line {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 4px,
|
||||
#ddd 4px,
|
||||
#ddd 8px
|
||||
);
|
||||
}
|
||||
|
||||
.divider-circle-top {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
top: -8px;
|
||||
left: -7px;
|
||||
}
|
||||
|
||||
.divider-circle-bottom {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
bottom: -8px;
|
||||
left: -7px;
|
||||
}
|
||||
}
|
||||
|
||||
.coupon-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
|
||||
.coupon-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.coupon-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.coupon-validity {
|
||||
font-size: 26px;
|
||||
color: #6b7280;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.coupon-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.coupon-btn {
|
||||
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, #ff6b6b 0%, #ee5a52 100%);
|
||||
}
|
||||
|
||||
&.theme-orange {
|
||||
background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
|
||||
}
|
||||
|
||||
&.theme-blue {
|
||||
background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%);
|
||||
}
|
||||
|
||||
&.theme-purple {
|
||||
background: linear-gradient(135deg, #ab47bc 0%, #9c27b0 100%);
|
||||
}
|
||||
|
||||
&.theme-green {
|
||||
background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 26px;
|
||||
color: #9ca3af;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-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;
|
||||
|
||||
.status-badge {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/components/CouponCard.tsx
Normal file
234
src/components/CouponCard.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Button } from '@nutui/nutui-react-taro'
|
||||
import './CouponCard.scss'
|
||||
|
||||
export interface CouponCardProps {
|
||||
/** 优惠券ID */
|
||||
id?: string
|
||||
/** 优惠券金额 */
|
||||
amount: number
|
||||
/** 最低消费金额 */
|
||||
minAmount?: number
|
||||
/** 优惠券类型:10-满减券 20-折扣券 30-免费券 */
|
||||
type?: 10 | 20 | 30
|
||||
/** 优惠券状态:0-未使用 1-已使用 2-已过期 */
|
||||
status?: 0 | 1 | 2
|
||||
/** 状态文本描述(后端返回) */
|
||||
statusText?: string
|
||||
/** 优惠券标题 */
|
||||
title?: string
|
||||
/** 优惠券描述 */
|
||||
description?: string
|
||||
/** 有效期开始时间 */
|
||||
startTime?: string
|
||||
/** 有效期结束时间 */
|
||||
endTime?: string
|
||||
/** 是否即将过期(后端计算) */
|
||||
isExpiringSoon?: boolean
|
||||
/** 剩余天数(后端计算) */
|
||||
daysRemaining?: number
|
||||
/** 剩余小时数(后端计算) */
|
||||
hoursRemaining?: number
|
||||
/** 是否显示领取按钮 */
|
||||
showReceiveBtn?: boolean
|
||||
/** 是否显示使用按钮 */
|
||||
showUseBtn?: boolean
|
||||
/** 领取按钮点击事件 */
|
||||
onReceive?: () => void
|
||||
/** 使用按钮点击事件 */
|
||||
onUse?: () => void
|
||||
/** 优惠券样式主题:red | orange | blue | purple | green */
|
||||
theme?: 'red' | 'orange' | 'blue' | 'purple' | 'green'
|
||||
}
|
||||
|
||||
const CouponCard: React.FC<CouponCardProps> = ({
|
||||
amount,
|
||||
minAmount,
|
||||
type = 10,
|
||||
status = 0,
|
||||
statusText,
|
||||
title,
|
||||
startTime,
|
||||
endTime,
|
||||
isExpiringSoon,
|
||||
daysRemaining,
|
||||
hoursRemaining,
|
||||
showReceiveBtn = false,
|
||||
showUseBtn = false,
|
||||
onReceive,
|
||||
onUse,
|
||||
theme = 'red'
|
||||
}) => {
|
||||
// 获取主题颜色类名
|
||||
const getThemeClass = () => {
|
||||
return `theme-${theme}`
|
||||
}
|
||||
// 格式化优惠券金额显示
|
||||
const formatAmount = () => {
|
||||
switch (type) {
|
||||
case 10: // 满减券
|
||||
return `¥${amount}`
|
||||
case 20: // 折扣券
|
||||
return `${amount}折`
|
||||
case 30: // 免费券
|
||||
return '免费'
|
||||
default:
|
||||
return `¥${amount}`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优惠券状态文本
|
||||
const getStatusText = () => {
|
||||
// 优先使用后端返回的状态文本
|
||||
if (statusText) {
|
||||
return statusText
|
||||
}
|
||||
|
||||
// 兜底逻辑
|
||||
switch (status) {
|
||||
case 0:
|
||||
return '可用'
|
||||
case 1:
|
||||
return '已使用'
|
||||
case 2:
|
||||
return '已过期'
|
||||
default:
|
||||
return '可用'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取使用条件文本
|
||||
const getConditionText = () => {
|
||||
if (type === 30) return '免费使用' // 免费券
|
||||
if (minAmount && minAmount > 0) {
|
||||
return `满${minAmount}元可用`
|
||||
}
|
||||
return '无门槛'
|
||||
}
|
||||
|
||||
// 格式化有效期显示
|
||||
const formatValidityPeriod = () => {
|
||||
// 第一优先级:使用后端返回的状态文本
|
||||
if (statusText) {
|
||||
return statusText
|
||||
}
|
||||
|
||||
// 第二优先级:根据状态码显示
|
||||
if (status === 2) {
|
||||
return '已过期'
|
||||
}
|
||||
|
||||
if (status === 1) {
|
||||
return '已使用'
|
||||
}
|
||||
|
||||
// 第三优先级:使用后端计算的剩余时间
|
||||
if (isExpiringSoon && daysRemaining !== undefined) {
|
||||
if (daysRemaining <= 0 && hoursRemaining !== undefined) {
|
||||
return `${hoursRemaining}小时后过期`
|
||||
}
|
||||
return `${daysRemaining}天后过期`
|
||||
}
|
||||
|
||||
// 兜底逻辑:使用前端计算
|
||||
if (!endTime) return '可用'
|
||||
|
||||
const end = new Date(endTime)
|
||||
const now = new Date()
|
||||
|
||||
if (startTime) {
|
||||
const start = new Date(startTime)
|
||||
// 如果还未开始
|
||||
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 themeClass = getThemeClass()
|
||||
|
||||
return (
|
||||
<View className={`coupon-card ${status !== 0 ? 'disabled' : ''}`}>
|
||||
{/* 左侧金额区域 */}
|
||||
<View className={`coupon-left ${themeClass}`}>
|
||||
<View className="amount-wrapper">
|
||||
{type !== 30 && <Text className="currency">¥</Text>}
|
||||
<Text className="amount">{formatAmount()}</Text>
|
||||
</View>
|
||||
<View className="condition">
|
||||
{getConditionText()}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 中间分割线 */}
|
||||
<View className="coupon-divider">
|
||||
<View className="divider-line"></View>
|
||||
<View className="divider-circle-top"></View>
|
||||
<View className="divider-circle-bottom"></View>
|
||||
</View>
|
||||
|
||||
{/* 右侧信息区域 */}
|
||||
<View className="coupon-right">
|
||||
<View className="coupon-info">
|
||||
<View className="coupon-title">
|
||||
{title || (type === 10 ? '满减券' : type === 20 ? '折扣券' : '免费券')}
|
||||
</View>
|
||||
<View className="coupon-validity">
|
||||
{formatValidityPeriod()}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 按钮区域 */}
|
||||
<View className="coupon-actions">
|
||||
{showReceiveBtn && status === 0 && (
|
||||
<Button
|
||||
className={`coupon-btn ${themeClass}`}
|
||||
size="small"
|
||||
onClick={onReceive}
|
||||
>
|
||||
领取
|
||||
</Button>
|
||||
)}
|
||||
{showUseBtn && status === 0 && (
|
||||
<Button
|
||||
className={`coupon-btn ${themeClass}`}
|
||||
size="small"
|
||||
onClick={onUse}
|
||||
>
|
||||
立即使用
|
||||
</Button>
|
||||
)}
|
||||
{status !== 0 && (
|
||||
<View className="status-text">
|
||||
{getStatusText()}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 状态遮罩 */}
|
||||
{status !== 0 && (
|
||||
<View className="status-overlay">
|
||||
<Text className="status-badge">
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CouponCard
|
||||
174
src/components/CouponExpireNotice.tsx
Normal file
174
src/components/CouponExpireNotice.tsx
Normal 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
|
||||
210
src/components/CouponFilter.tsx
Normal file
210
src/components/CouponFilter.tsx
Normal 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: any) => {
|
||||
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: any) => {
|
||||
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
|
||||
159
src/components/CouponGuide.tsx
Normal file
159
src/components/CouponGuide.tsx
Normal 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
|
||||
96
src/components/CouponList.tsx
Normal file
96
src/components/CouponList.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { View, ScrollView } from '@tarojs/components'
|
||||
import CouponCard, { CouponCardProps } from './CouponCard'
|
||||
|
||||
export interface CouponListProps {
|
||||
/** 优惠券列表数据 */
|
||||
coupons: CouponCardProps[]
|
||||
/** 列表标题 */
|
||||
title?: string
|
||||
/** 布局方式:vertical-垂直布局 horizontal-水平滚动 */
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
/** 是否显示空状态 */
|
||||
showEmpty?: boolean
|
||||
/** 空状态文案 */
|
||||
emptyText?: string
|
||||
/** 优惠券点击事件 */
|
||||
onCouponClick?: (coupon: CouponCardProps, index: number) => void
|
||||
}
|
||||
|
||||
const CouponList: React.FC<CouponListProps> = ({
|
||||
coupons = [],
|
||||
title,
|
||||
layout = 'vertical',
|
||||
showEmpty = true,
|
||||
emptyText = '暂无优惠券',
|
||||
onCouponClick
|
||||
}) => {
|
||||
const handleCouponClick = (coupon: CouponCardProps, index: number) => {
|
||||
onCouponClick?.(coupon, index)
|
||||
}
|
||||
|
||||
// 垂直布局
|
||||
if (layout === 'vertical') {
|
||||
return (
|
||||
<View className="p-4">
|
||||
{title && (
|
||||
<View className="font-semibold text-gray-800 mb-4">{title}</View>
|
||||
)}
|
||||
|
||||
{coupons.length === 0 ? (
|
||||
showEmpty && (
|
||||
<View className="text-center py-10 px-5 text-gray-500">
|
||||
{emptyText}
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
coupons.map((coupon, index) => (
|
||||
<View
|
||||
key={index}
|
||||
onClick={() => handleCouponClick(coupon, index)}
|
||||
>
|
||||
<CouponCard {...coupon} />
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 水平滚动布局
|
||||
return (
|
||||
<View>
|
||||
{title && (
|
||||
<View className="font-semibold text-gray-800 mb-4 pl-4">
|
||||
{title}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{coupons.length === 0 ? (
|
||||
showEmpty && (
|
||||
<View className="text-center py-10 px-5 text-gray-500">
|
||||
{emptyText}
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<ScrollView
|
||||
scrollX
|
||||
className="flex p-4 gap-2 overflow-x-auto"
|
||||
showScrollbar={false}
|
||||
>
|
||||
{coupons.map((coupon, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className="flex-shrink-0 w-60 mb-0"
|
||||
onClick={() => handleCouponClick(coupon, index)}
|
||||
>
|
||||
<CouponCard {...coupon} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CouponList
|
||||
182
src/components/CouponShare.tsx
Normal file
182
src/components/CouponShare.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Popup } from '@nutui/nutui-react-taro'
|
||||
import { Share, 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: <Share 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
|
||||
71
src/components/CouponStats.tsx
Normal file
71
src/components/CouponStats.tsx
Normal 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 hidden">
|
||||
<Text className="font-semibold text-gray-800">优惠券统计</Text>
|
||||
|
||||
<View className="flex justify-between mt-2">
|
||||
{/* 可用优惠券 */}
|
||||
<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
|
||||
162
src/components/CouponUsageRecord.tsx
Normal file
162
src/components/CouponUsageRecord.tsx
Normal 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
|
||||
106
src/components/ErrorBoundary.scss
Normal file
106
src/components/ErrorBoundary.scss
Normal file
@@ -0,0 +1,106 @@
|
||||
.error-boundary {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
&__container {
|
||||
background: white;
|
||||
border-radius: 16rpx;
|
||||
padding: 60rpx 40rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
||||
max-width: 600rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__message {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 40rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__details {
|
||||
background: #f8f8f8;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
margin-bottom: 40rpx;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__error-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 10rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__error-message {
|
||||
font-size: 22rpx;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 10rpx;
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__error-stack {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
display: block;
|
||||
max-height: 200rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__button {
|
||||
flex: 1;
|
||||
max-width: 200rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
|
||||
&--primary {
|
||||
background: #007aff;
|
||||
color: white;
|
||||
|
||||
&:active {
|
||||
background: #0056cc;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
|
||||
&:active {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/components/ErrorBoundary.tsx
Normal file
124
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { View, Text, Button } from '@tarojs/components';
|
||||
import './ErrorBoundary.scss';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局错误边界组件
|
||||
* 用于捕获React组件树中的JavaScript错误
|
||||
*/
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
// 更新state,下次渲染将显示错误UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// 记录错误信息
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
|
||||
// 调用外部错误处理函数
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// 上报错误到监控系统
|
||||
this.reportError(error, errorInfo);
|
||||
}
|
||||
|
||||
// 上报错误到监控系统
|
||||
private reportError = (error: Error, errorInfo: React.ErrorInfo) => {
|
||||
try {
|
||||
// 这里可以集成错误监控服务,如Sentry、Bugsnag等
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// 可以发送到后端日志系统
|
||||
// this.sendErrorToServer(error, errorInfo);
|
||||
} catch (reportError) {
|
||||
console.error('Failed to report error:', reportError);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置错误状态
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
// 重新加载页面
|
||||
private handleReload = () => {
|
||||
Taro.reLaunch({ url: '/pages/index/index' });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// 如果有自定义的fallback UI,使用它
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// 默认的错误UI
|
||||
return (
|
||||
<View className="error-boundary">
|
||||
<View className="error-boundary__container">
|
||||
<View className="error-boundary__icon">😵</View>
|
||||
<Text className="error-boundary__title">页面出现了问题</Text>
|
||||
<Text className="error-boundary__message">
|
||||
抱歉,页面遇到了一些技术问题,请尝试刷新页面或返回首页
|
||||
</Text>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<View className="error-boundary__details">
|
||||
<Text className="error-boundary__error-title">错误详情:</Text>
|
||||
<Text className="error-boundary__error-message">
|
||||
{this.state.error?.message}
|
||||
</Text>
|
||||
<Text className="error-boundary__error-stack">
|
||||
{this.state.error?.stack}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="error-boundary__actions">
|
||||
<Button
|
||||
className="error-boundary__button error-boundary__button--primary"
|
||||
onClick={this.handleReset}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<Button
|
||||
className="error-boundary__button error-boundary__button--secondary"
|
||||
onClick={this.handleReload}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
38
src/components/FixedButton.tsx
Normal file
38
src/components/FixedButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import {View} from '@tarojs/components';
|
||||
import {Button} from '@nutui/nutui-react-taro'
|
||||
|
||||
interface FixedButtonProps {
|
||||
text?: string;
|
||||
onClick?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
function FixedButton({text, onClick, icon, disabled, background}: FixedButtonProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 底部安全区域占位 */}
|
||||
<View className="h-20 w-full"></View>
|
||||
<View
|
||||
className="fixed z-50 bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-4 py-3 safe-area-bottom">
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
background
|
||||
}}
|
||||
size="large"
|
||||
block
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
className="px-6"
|
||||
onClick={onClick}>
|
||||
{text || '新增'}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FixedButton;
|
||||
6
src/components/Gap.tsx
Normal file
6
src/components/Gap.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
function MyGap({height}){
|
||||
return (
|
||||
<div style={{height}} className={'bg-gray-100'}></div>
|
||||
)
|
||||
}
|
||||
export default MyGap;
|
||||
184
src/components/GiftCard.md
Normal file
184
src/components/GiftCard.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# GiftCard 礼品卡组件
|
||||
|
||||
一个功能丰富、设计精美的礼品卡组件,支持多种类型的礼品卡展示和交互。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 🎨 视觉设计
|
||||
- **多主题支持**:金色、银色、铜色、蓝色、绿色、紫色六种主题
|
||||
- **响应式设计**:适配不同屏幕尺寸
|
||||
- **状态指示**:清晰的可用、已使用、已过期状态展示
|
||||
- **折扣标识**:自动计算并显示折扣百分比
|
||||
|
||||
### 🖼️ 图片展示
|
||||
- **单图模式**:支持单张商品图片展示
|
||||
- **轮播模式**:支持多张图片轮播展示
|
||||
- **自适应尺寸**:图片自动适配容器大小
|
||||
|
||||
### 💰 价格信息
|
||||
- **面值显示**:突出显示礼品卡面值
|
||||
- **原价对比**:显示原价和折扣信息
|
||||
- **优惠活动**:展示当前优惠活动信息
|
||||
|
||||
### ⭐ 商品详情
|
||||
- **品牌分类**:显示商品品牌和分类信息
|
||||
- **评分评价**:展示用户评分和评价数量
|
||||
- **规格库存**:显示商品规格和库存状态
|
||||
- **商品标签**:支持多个商品特色标签
|
||||
|
||||
### 📋 使用指南
|
||||
- **使用说明**:详细的使用步骤说明
|
||||
- **注意事项**:重要的使用注意事项
|
||||
- **适用门店**:显示可使用的门店列表
|
||||
|
||||
### 🔧 交互功能
|
||||
- **兑换码展示**:支持兑换码的显示和隐藏
|
||||
- **操作按钮**:使用、详情等操作按钮
|
||||
- **点击事件**:支持整卡点击事件
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础用法
|
||||
|
||||
```tsx
|
||||
import GiftCard from '@/components/GiftCard'
|
||||
|
||||
const MyComponent = () => {
|
||||
return (
|
||||
<GiftCard
|
||||
id={1}
|
||||
name="星巴克咖啡礼品卡"
|
||||
description="享受醇香咖啡时光"
|
||||
goodsImage="https://example.com/starbucks.jpg"
|
||||
faceValue="100"
|
||||
type={20}
|
||||
useStatus={0}
|
||||
theme="green"
|
||||
showUseBtn={true}
|
||||
onUse={() => console.log('使用礼品卡')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 完整功能展示
|
||||
|
||||
```tsx
|
||||
import GiftCard from '@/components/GiftCard'
|
||||
|
||||
const FullFeatureCard = () => {
|
||||
const cardData = {
|
||||
id: 1,
|
||||
name: '星巴克咖啡礼品卡',
|
||||
description: '享受醇香咖啡时光,适用于全国星巴克门店',
|
||||
code: 'SB2024001234567890',
|
||||
goodsImages: [
|
||||
'https://example.com/image1.jpg',
|
||||
'https://example.com/image2.jpg'
|
||||
],
|
||||
faceValue: '100',
|
||||
originalPrice: '120',
|
||||
type: 20,
|
||||
useStatus: 0,
|
||||
expireTime: '2024-12-31 23:59:59',
|
||||
goodsInfo: {
|
||||
brand: '星巴克',
|
||||
category: '餐饮美食',
|
||||
rating: 4.8,
|
||||
reviewCount: 1256,
|
||||
tags: ['热门', '全国通用'],
|
||||
instructions: [
|
||||
'出示兑换码至门店收银台即可使用',
|
||||
'可用于购买任意饮品和食品'
|
||||
],
|
||||
notices: [
|
||||
'礼品卡一经售出,不可退换',
|
||||
'请妥善保管兑换码'
|
||||
],
|
||||
applicableStores: ['全国星巴克门店', '机场店']
|
||||
},
|
||||
promotionInfo: {
|
||||
type: 'discount',
|
||||
description: '限时优惠:满100减20',
|
||||
validUntil: '2024-09-30 23:59:59'
|
||||
},
|
||||
showCode: true,
|
||||
showUseBtn: true,
|
||||
showGoodsDetail: true,
|
||||
theme: 'green'
|
||||
}
|
||||
|
||||
return (
|
||||
<GiftCard
|
||||
{...cardData}
|
||||
onUse={() => console.log('使用')}
|
||||
onDetail={() => console.log('详情')}
|
||||
onClick={() => console.log('点击')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 属性说明
|
||||
|
||||
### 基础属性
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| id | number | - | 礼品卡ID |
|
||||
| name | string | - | 礼品卡名称 |
|
||||
| description | string | - | 礼品卡描述 |
|
||||
| faceValue | string | - | 礼品卡面值 |
|
||||
| type | number | 10 | 类型:10-实物 20-虚拟 30-服务 |
|
||||
| useStatus | number | 0 | 状态:0-可用 1-已使用 2-已过期 |
|
||||
| theme | string | 'gold' | 主题色 |
|
||||
|
||||
### 商品信息
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| goodsInfo.brand | string | 商品品牌 |
|
||||
| goodsInfo.category | string | 商品分类 |
|
||||
| goodsInfo.rating | number | 商品评分 |
|
||||
| goodsInfo.reviewCount | number | 评价数量 |
|
||||
| goodsInfo.tags | string[] | 商品标签 |
|
||||
| goodsInfo.instructions | string[] | 使用说明 |
|
||||
| goodsInfo.notices | string[] | 注意事项 |
|
||||
|
||||
### 事件回调
|
||||
| 事件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| onUse | () => void | 使用按钮点击 |
|
||||
| onDetail | () => void | 详情按钮点击 |
|
||||
| onClick | () => void | 卡片点击 |
|
||||
|
||||
## 主题配置
|
||||
|
||||
组件支持6种预设主题:
|
||||
|
||||
- `gold` - 金色主题(默认)
|
||||
- `silver` - 银色主题
|
||||
- `bronze` - 铜色主题
|
||||
- `blue` - 蓝色主题
|
||||
- `green` - 绿色主题
|
||||
- `purple` - 紫色主题
|
||||
|
||||
## 样式定制
|
||||
|
||||
可以通过覆盖CSS类名来自定义样式:
|
||||
|
||||
```scss
|
||||
.gift-card {
|
||||
// 自定义卡片样式
|
||||
}
|
||||
|
||||
.gift-card-gold {
|
||||
// 自定义金色主题
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保传入的图片URL有效且可访问
|
||||
2. 价格相关字段建议使用字符串类型,避免精度问题
|
||||
3. 时间字段请使用标准的日期时间格式
|
||||
4. 商品标签数量建议控制在5个以内,避免布局混乱
|
||||
5. 使用说明和注意事项条目建议简洁明了
|
||||
624
src/components/GiftCard.scss
Normal file
624
src/components/GiftCard.scss
Normal file
@@ -0,0 +1,624 @@
|
||||
.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: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||
}
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||
border: none;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.gift-card-silver {
|
||||
.gift-card-header {
|
||||
background: linear-gradient(135deg, #e8e8e8 0%, #d0d0d0 100%);
|
||||
}
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #e8e8e8 0%, #d0d0d0 100%);
|
||||
border: none;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
&.gift-card-bronze {
|
||||
.gift-card-header {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #b8722c 100%);
|
||||
}
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #b8722c 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.gift-card-blue {
|
||||
.gift-card-header {
|
||||
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
|
||||
}
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.gift-card-green {
|
||||
.gift-card-header {
|
||||
background: linear-gradient(135deg, #5cb85c 0%, #449d44 100%);
|
||||
}
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #5cb85c 0%, #449d44 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.gift-card-purple {
|
||||
.gift-card-header {
|
||||
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||||
}
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.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-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 2px;
|
||||
// 商品名称可能较长,需要处理溢出
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.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-container {
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
.gift-image {
|
||||
position: relative;
|
||||
|
||||
.gift-image-single {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background-color: #f5f5f5;
|
||||
// 添加加载状态和错误处理
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gift-image-swiper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.swiper-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.swiper-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #ff4757;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
z-index: 2;
|
||||
|
||||
.discount-text {
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gift-info {
|
||||
flex: 1;
|
||||
|
||||
.goods-basic-info {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.brand-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.brand-text {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.category-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.price-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.current-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-right: 12px;
|
||||
|
||||
.price-symbol {
|
||||
font-size: 16px;
|
||||
color: #ff4757;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff4757;
|
||||
}
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.rating-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.rating-text {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
margin-left: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.review-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.nut-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.gift-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.goods-specs {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.spec-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.spec-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
|
||||
&.in-stock {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.out-stock {
|
||||
color: #ff4757;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.promotion-info {
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffe8e8 100%);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-left: 3px solid #ff4757;
|
||||
|
||||
.promotion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.promotion-icon {
|
||||
color: #ff4757;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.promotion-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ff4757;
|
||||
}
|
||||
}
|
||||
|
||||
.promotion-desc {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.promotion-valid {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.gift-code {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #ddd;
|
||||
|
||||
.code-label {
|
||||
display: block;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.code-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-instructions {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.instruction-section {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.section-icon {
|
||||
color: #4a90e2;
|
||||
margin-right: 6px;
|
||||
|
||||
&.warning {
|
||||
color: #ff9500;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.instruction-list {
|
||||
.instruction-item {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
padding-left: 8px;
|
||||
|
||||
&.notice {
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.store-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.store-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gift-time-info {
|
||||
.time-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.time-text {
|
||||
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-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-image-container {
|
||||
margin-right: 12px;
|
||||
|
||||
.gift-image .gift-image-single,
|
||||
.gift-image-swiper {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.gift-info {
|
||||
.goods-basic-info {
|
||||
.price-info {
|
||||
.current-price {
|
||||
.price-amount {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-tags {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.promotion-info {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-instructions {
|
||||
.instruction-section {
|
||||
.instruction-list {
|
||||
.instruction-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gift-card-footer {
|
||||
padding: 0 16px 16px;
|
||||
|
||||
.footer-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小屏幕优化
|
||||
@media (max-width: 480px) {
|
||||
.gift-card {
|
||||
.gift-card-content {
|
||||
flex-direction: column;
|
||||
|
||||
.gift-image-container {
|
||||
margin-right: 0;
|
||||
margin-bottom: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-instructions {
|
||||
.instruction-section {
|
||||
.store-list {
|
||||
.store-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
371
src/components/GiftCard.tsx
Normal file
371
src/components/GiftCard.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Tag, Rate } from '@nutui/nutui-react-taro'
|
||||
import { Gift, Clock, Location } from '@nutui/icons-react-taro'
|
||||
import dayjs from 'dayjs'
|
||||
import './GiftCard.scss'
|
||||
|
||||
export interface GiftCardProps {
|
||||
/** 礼品卡ID */
|
||||
id: number
|
||||
/** 礼品卡名称 */
|
||||
name: string
|
||||
/** 商品名称 */
|
||||
goodsName?: string
|
||||
/** 礼品卡描述 */
|
||||
description?: string
|
||||
/** 礼品卡兑换码 */
|
||||
code?: string
|
||||
/** 商品图片 */
|
||||
goodsImage?: string
|
||||
/** 商品图片列表 */
|
||||
goodsImages?: string[]
|
||||
/** 礼品卡面值 */
|
||||
faceValue?: string
|
||||
/** 商品原价 */
|
||||
originalPrice?: string
|
||||
/** 礼品卡类型:10-实物礼品卡 20-虚拟礼品卡 30-服务礼品卡 */
|
||||
type?: number
|
||||
/** 状态:0-未使用 1-已使用 2-失效 */
|
||||
status?: number
|
||||
/** 过期时间 */
|
||||
expireTime?: string
|
||||
/** 使用时间 */
|
||||
takeTime?: string
|
||||
/** 使用地址 */
|
||||
useLocation?: string
|
||||
/** 客服联系方式 */
|
||||
contactInfo?: string
|
||||
/** 商品信息 */
|
||||
goodsInfo?: {
|
||||
/** 商品品牌 */
|
||||
brand?: string
|
||||
/** 商品规格 */
|
||||
specification?: string
|
||||
/** 商品分类 */
|
||||
category?: string
|
||||
/** 库存数量 */
|
||||
stock?: number
|
||||
/** 商品评分 */
|
||||
rating?: number
|
||||
/** 评价数量 */
|
||||
reviewCount?: number
|
||||
/** 商品标签 */
|
||||
tags?: string[]
|
||||
/** 使用说明 */
|
||||
instructions?: string[]
|
||||
/** 注意事项 */
|
||||
notices?: string[]
|
||||
/** 适用门店 */
|
||||
applicableStores?: string[]
|
||||
}
|
||||
/** 优惠信息 */
|
||||
promotionInfo?: {
|
||||
/** 优惠类型 */
|
||||
type?: 'discount' | 'gift' | 'cashback'
|
||||
/** 优惠描述 */
|
||||
description?: string
|
||||
/** 优惠金额 */
|
||||
amount?: string
|
||||
/** 优惠有效期 */
|
||||
validUntil?: string
|
||||
}
|
||||
/** 是否显示兑换码 */
|
||||
showCode?: boolean
|
||||
/** 是否显示使用按钮 */
|
||||
showUseBtn?: boolean
|
||||
/** 是否显示详情按钮 */
|
||||
showDetailBtn?: boolean
|
||||
/** 是否显示商品详情 */
|
||||
showGoodsDetail?: boolean
|
||||
/** 卡片主题色 */
|
||||
theme?: 'gold' | 'silver' | 'bronze' | 'blue' | 'green' | 'purple'
|
||||
/** 使用按钮点击事件 */
|
||||
onUse?: () => void
|
||||
/** 详情按钮点击事件 */
|
||||
onDetail?: () => void
|
||||
/** 卡片点击事件 */
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const GiftCard: React.FC<GiftCardProps> = ({
|
||||
name,
|
||||
goodsName,
|
||||
code,
|
||||
faceValue,
|
||||
originalPrice,
|
||||
type = 10,
|
||||
status = 0,
|
||||
expireTime,
|
||||
takeTime,
|
||||
useLocation,
|
||||
goodsInfo,
|
||||
promotionInfo,
|
||||
showCode = false,
|
||||
showGoodsDetail = true,
|
||||
theme = 'gold',
|
||||
onClick
|
||||
}) => {
|
||||
|
||||
// 获取显示名称,优先使用商品名称
|
||||
// const displayName = goodsName || name
|
||||
// 获取礼品卡类型文本
|
||||
const getTypeText = () => {
|
||||
switch (type) {
|
||||
case 10: return '实物礼品卡'
|
||||
case 20: return '虚拟礼品卡'
|
||||
case 30: return '服务礼品卡'
|
||||
default: return '礼品卡'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = () => {
|
||||
switch (status) {
|
||||
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()} ${status !== 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="text-left title-text">{getTypeText()}</Text>
|
||||
<Text className="text-left type-text">{name}</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">
|
||||
|
||||
<View className="gift-info">
|
||||
{/* 商品基本信息 */}
|
||||
<View className="goods-basic-info">
|
||||
{/* 商品名称 */}
|
||||
{goodsName && (
|
||||
<View className="brand-category">
|
||||
<Text className="brand-text">{goodsName}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 价格信息 */}
|
||||
<View className="price-info">
|
||||
{faceValue && (
|
||||
<View className="current-price">
|
||||
<Text className="price-symbol">¥</Text>
|
||||
<Text className="price-amount">{faceValue}</Text>
|
||||
</View>
|
||||
)}
|
||||
{originalPrice && originalPrice !== faceValue && (
|
||||
<Text className="original-price">原价¥{originalPrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 评分和评价 */}
|
||||
{goodsInfo?.rating && (
|
||||
<View className="rating-info">
|
||||
<Rate
|
||||
value={goodsInfo.rating}
|
||||
/>
|
||||
<Text className="rating-text">{goodsInfo.rating}</Text>
|
||||
{goodsInfo.reviewCount && (
|
||||
<Text className="review-count">({goodsInfo.reviewCount}条评价)</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 规格和库存 */}
|
||||
{showGoodsDetail && (goodsInfo?.specification || goodsInfo?.stock !== undefined) && (
|
||||
<View className="goods-specs">
|
||||
{goodsInfo.stock !== undefined && (
|
||||
<View className="spec-item">
|
||||
<Text className="spec-label">库存:</Text>
|
||||
<Text className={`spec-value ${goodsInfo.stock > 0 ? 'in-stock' : 'out-stock'}`}>
|
||||
{goodsInfo.stock > 0 ? `${goodsInfo.stock}件` : '缺货'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 优惠信息 */}
|
||||
{promotionInfo && (
|
||||
<View className="promotion-info">
|
||||
<View className="promotion-header">
|
||||
<Gift size="14" className="promotion-icon" />
|
||||
<Text className="promotion-title">优惠活动</Text>
|
||||
</View>
|
||||
<Text className="promotion-desc">{promotionInfo.description}</Text>
|
||||
{promotionInfo.validUntil && (
|
||||
<Text className="promotion-valid">
|
||||
有效期至:{dayjs(promotionInfo.validUntil).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 兑换码 */}
|
||||
{code && (
|
||||
<View className="gift-code">
|
||||
<Text className="code-label">兑换码</Text>
|
||||
<Text className="code-value">{formatCode()}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 使用说明和注意事项 */}
|
||||
{showGoodsDetail && (goodsInfo?.instructions || goodsInfo?.notices || goodsInfo?.applicableStores) && (
|
||||
<View className="goods-instructions">
|
||||
{goodsInfo.applicableStores && goodsInfo.applicableStores.length > 0 && (
|
||||
<View className="instruction-section">
|
||||
<View className="section-header">
|
||||
<Text className="section-title">适用门店</Text>
|
||||
</View>
|
||||
<View className="store-list">
|
||||
{goodsInfo.applicableStores.map((store, index) => (
|
||||
<Tag key={index} plain className="store-tag">
|
||||
{store}
|
||||
</Tag>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<View className="gift-time-info">
|
||||
{status === 1 && takeTime && (
|
||||
<View className="time-item">
|
||||
<Clock size="14" className="text-gray-400" />
|
||||
<Text className="time-text">使用时间:{dayjs(takeTime).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === 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 text-sm">使用地址:{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">*/}
|
||||
{/* {showUseBtn && status === 0 && (*/}
|
||||
{/* <Button*/}
|
||||
{/* size="small"*/}
|
||||
{/* type="primary"*/}
|
||||
{/* className={`use-btn ${getThemeClass()}`}*/}
|
||||
{/* >*/}
|
||||
{/* 立即使用*/}
|
||||
{/* </Button>*/}
|
||||
{/* )}*/}
|
||||
{/* </View>*/}
|
||||
{/*</View>*/}
|
||||
|
||||
{/* 状态遮罩 */}
|
||||
{status !== 0 && (
|
||||
<View className="gift-card-overlay">
|
||||
<View className="overlay-badge">
|
||||
{statusInfo.text}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default GiftCard
|
||||
140
src/components/GiftCardExample.tsx
Normal file
140
src/components/GiftCardExample.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react'
|
||||
import { View } from '@tarojs/components'
|
||||
import GiftCard from './GiftCard'
|
||||
|
||||
const GiftCardExample: React.FC = () => {
|
||||
// 示例数据
|
||||
const giftCardData = {
|
||||
id: 1,
|
||||
name: '星巴克咖啡礼品卡',
|
||||
description: '享受醇香咖啡时光,适用于全国星巴克门店',
|
||||
code: 'SB2024001234567890',
|
||||
goodsImages: [
|
||||
'https://example.com/starbucks-card-1.jpg',
|
||||
'https://example.com/starbucks-card-2.jpg',
|
||||
'https://example.com/starbucks-card-3.jpg'
|
||||
],
|
||||
faceValue: '100',
|
||||
originalPrice: '120',
|
||||
type: 20, // 虚拟礼品卡
|
||||
useStatus: 0, // 可用
|
||||
expireTime: '2024-12-31 23:59:59',
|
||||
contactInfo: '400-800-8888',
|
||||
goodsInfo: {
|
||||
brand: '星巴克',
|
||||
specification: '电子礼品卡',
|
||||
category: '餐饮美食',
|
||||
stock: 999,
|
||||
rating: 4.8,
|
||||
reviewCount: 1256,
|
||||
tags: ['热门', '全国通用', '无需预约', '即买即用'],
|
||||
instructions: [
|
||||
'出示兑换码至门店收银台即可使用',
|
||||
'可用于购买任意饮品和食品',
|
||||
'不可兑换现金,不设找零',
|
||||
'单次可使用多张礼品卡'
|
||||
],
|
||||
notices: [
|
||||
'礼品卡一经售出,不可退换',
|
||||
'请妥善保管兑换码,遗失不补',
|
||||
'部分特殊商品可能不适用',
|
||||
'具体使用规则以门店公告为准'
|
||||
],
|
||||
applicableStores: [
|
||||
'全国星巴克门店',
|
||||
'机场店',
|
||||
'高铁站店',
|
||||
'商场店'
|
||||
]
|
||||
},
|
||||
promotionInfo: {
|
||||
type: 'discount' as const,
|
||||
description: '限时优惠:满100减20,买2张送1张咖啡券',
|
||||
amount: '20',
|
||||
validUntil: '2024-09-30 23:59:59'
|
||||
},
|
||||
showCode: true,
|
||||
showUseBtn: true,
|
||||
showDetailBtn: true,
|
||||
showGoodsDetail: true,
|
||||
theme: 'green' as const
|
||||
}
|
||||
|
||||
const handleUse = () => {
|
||||
console.log('使用礼品卡')
|
||||
}
|
||||
|
||||
const handleDetail = () => {
|
||||
console.log('查看详情')
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
console.log('点击礼品卡')
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="gift-card-example">
|
||||
<GiftCard
|
||||
{...giftCardData}
|
||||
onUse={handleUse}
|
||||
onDetail={handleDetail}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
{/* 简化版本示例 */}
|
||||
<GiftCard
|
||||
id={2}
|
||||
name="麦当劳优惠券"
|
||||
description="美味汉堡套餐,限时优惠"
|
||||
goodsImage="https://example.com/mcd-card.jpg"
|
||||
faceValue="50"
|
||||
originalPrice="60"
|
||||
type={20}
|
||||
useStatus={0}
|
||||
expireTime="2024-10-31 23:59:59"
|
||||
goodsInfo={{
|
||||
brand: '麦当劳',
|
||||
category: '快餐',
|
||||
rating: 4.5,
|
||||
reviewCount: 892,
|
||||
tags: ['快餐', '全国通用']
|
||||
}}
|
||||
showCode={false}
|
||||
showUseBtn={true}
|
||||
showDetailBtn={true}
|
||||
showGoodsDetail={false}
|
||||
theme="blue"
|
||||
onUse={handleUse}
|
||||
onDetail={handleDetail}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
{/* 已使用状态示例 */}
|
||||
<GiftCard
|
||||
id={3}
|
||||
name="海底捞火锅券"
|
||||
description="享受正宗川味火锅"
|
||||
goodsImage="https://example.com/haidilao-card.jpg"
|
||||
faceValue="200"
|
||||
type={30}
|
||||
useStatus={1}
|
||||
takeTime="2024-08-15 19:30:00"
|
||||
useLocation="海底捞王府井店"
|
||||
goodsInfo={{
|
||||
brand: '海底捞',
|
||||
category: '火锅',
|
||||
rating: 4.9,
|
||||
reviewCount: 2341
|
||||
}}
|
||||
showCode={false}
|
||||
showUseBtn={false}
|
||||
showDetailBtn={true}
|
||||
theme="gold"
|
||||
onDetail={handleDetail}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default GiftCardExample
|
||||
169
src/components/GiftCardGuide.tsx
Normal file
169
src/components/GiftCardGuide.tsx
Normal 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
|
||||
175
src/components/GiftCardList.tsx
Normal file
175
src/components/GiftCardList.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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
|
||||
227
src/components/GiftCardShare.tsx
Normal file
227
src/components/GiftCardShare.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Popup } from '@nutui/nutui-react-taro'
|
||||
import { Share, 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: <Share 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
|
||||
87
src/components/GiftCardStats.tsx
Normal file
87
src/components/GiftCardStats.tsx
Normal 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 hidden">
|
||||
{/* 紧凑的统计卡片 - 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
|
||||
87
src/components/GiftCardStatsMax.tsx
Normal file
87
src/components/GiftCardStatsMax.tsx
Normal 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
|
||||
162
src/components/GoodsList.tsx
Normal file
162
src/components/GoodsList.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import {Avatar, Cell, Space, Tabs, Button, TabPane, Swiper} from '@nutui/nutui-react-taro'
|
||||
import {useEffect, useState, CSSProperties, useRef} from "react";
|
||||
import {BszxPay} from "@/api/bszx/bszxPay/model";
|
||||
import {InfiniteLoading} from '@nutui/nutui-react-taro'
|
||||
import dayjs from "dayjs";
|
||||
import {pageShopOrder} from "@/api/shop/shopOrder";
|
||||
import {ShopOrder} from "@/api/shop/shopOrder/model";
|
||||
import {copyText} from "@/utils/common";
|
||||
|
||||
const InfiniteUlStyle: CSSProperties = {
|
||||
marginTop: '84px',
|
||||
height: '82vh',
|
||||
width: '100%',
|
||||
padding: '0',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}
|
||||
const tabs = [
|
||||
{
|
||||
index: 0,
|
||||
key: '全部',
|
||||
title: '全部'
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
key: '已上架',
|
||||
title: '已上架'
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
key: '已下架',
|
||||
title: '已下架'
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
key: '已售罄',
|
||||
title: '已售罄'
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
key: '警戒库存',
|
||||
title: '警戒库存'
|
||||
},
|
||||
{
|
||||
index: 5,
|
||||
key: '回收站',
|
||||
title: '回收站'
|
||||
},
|
||||
]
|
||||
|
||||
function GoodsList(props: any) {
|
||||
const [list, setList] = useState<ShopOrder[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const swiperRef = useRef<React.ElementRef<typeof Swiper> | null>(null)
|
||||
const [tabIndex, setTabIndex] = useState<string | number>(0)
|
||||
|
||||
console.log(props.statusBarHeight, 'ppp')
|
||||
const reload = async () => {
|
||||
pageShopOrder({page}).then(res => {
|
||||
let newList: BszxPay[] | undefined = []
|
||||
if (res?.list && res?.list.length > 0) {
|
||||
newList = list?.concat(res.list)
|
||||
setHasMore(true)
|
||||
} else {
|
||||
newList = res?.list
|
||||
setHasMore(false)
|
||||
}
|
||||
setList(newList || []);
|
||||
})
|
||||
}
|
||||
|
||||
const reloadMore = async () => {
|
||||
setPage(page + 1)
|
||||
reload().then();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(2)
|
||||
reload().then()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
align={'left'}
|
||||
className={'fixed left-0'}
|
||||
style={{ top: '84px'}}
|
||||
value={tabIndex}
|
||||
onChange={(page) => {
|
||||
swiperRef.current?.to(page)
|
||||
setTabIndex(page)
|
||||
}}
|
||||
>
|
||||
{
|
||||
tabs?.map((item, index) => {
|
||||
return <TabPane key={index} title={item.title}></TabPane>
|
||||
})
|
||||
}
|
||||
</Tabs>
|
||||
<div style={InfiniteUlStyle} id="scroll">
|
||||
<InfiniteLoading
|
||||
target="scroll"
|
||||
hasMore={hasMore}
|
||||
onLoadMore={reloadMore}
|
||||
onScroll={() => {
|
||||
|
||||
}}
|
||||
onScrollToUpper={() => {
|
||||
|
||||
}}
|
||||
loadingText={
|
||||
<>
|
||||
加载中
|
||||
</>
|
||||
}
|
||||
loadMoreText={
|
||||
<>
|
||||
没有更多了
|
||||
</>
|
||||
}
|
||||
>
|
||||
{list?.map(item => {
|
||||
return (
|
||||
<Cell style={{padding: '16px'}}>
|
||||
<Space direction={'vertical'} className={'w-full flex flex-col'}>
|
||||
<div className={'order-no flex justify-between'}>
|
||||
<span className={'text-gray-700 font-bold text-sm'}
|
||||
onClick={() => copyText(`${item.orderNo}`)}>{item.orderNo}</span>
|
||||
<span className={'text-orange-500'}>待付款</span>
|
||||
</div>
|
||||
<div
|
||||
className={'create-time text-gray-400 text-xs'}>{dayjs(item.createTime).format('YYYY年MM月DD日 HH:mm:ss')}</div>
|
||||
<div className={'goods-info'}>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'flex items-center'}>
|
||||
<Avatar
|
||||
src='34'
|
||||
size={'45'}
|
||||
shape={'square'}
|
||||
/>
|
||||
<div className={'ml-2'}>{item.realName}</div>
|
||||
</div>
|
||||
<div className={'text-gray-400 text-xs'}>{item.totalNum}件商品</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={' w-full text-right'}>实付金额:¥{item.payPrice}</div>
|
||||
<Space className={'btn flex justify-end'}>
|
||||
<Button size={'small'}>取消订单</Button>
|
||||
<Button size={'small'}>发货</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Cell>
|
||||
)
|
||||
})}
|
||||
</InfiniteLoading>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoodsList
|
||||
113
src/components/GradientThemeSelector.tsx
Normal file
113
src/components/GradientThemeSelector.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Popup, Button } from '@nutui/nutui-react-taro'
|
||||
import { gradientThemes, GradientTheme } from '@/styles/gradients'
|
||||
|
||||
interface GradientThemeSelectorProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onSelect: (theme: GradientTheme) => void
|
||||
currentTheme?: GradientTheme
|
||||
}
|
||||
|
||||
const GradientThemeSelector: React.FC<GradientThemeSelectorProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentTheme
|
||||
}) => {
|
||||
const [selectedTheme, setSelectedTheme] = useState<GradientTheme | null>(currentTheme || null)
|
||||
|
||||
const handleThemeSelect = (theme: GradientTheme) => {
|
||||
setSelectedTheme(theme)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedTheme) {
|
||||
onSelect(selectedTheme)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const renderThemeItem = (theme: GradientTheme) => {
|
||||
const isSelected = selectedTheme?.name === theme.name
|
||||
|
||||
return (
|
||||
<View
|
||||
key={theme.name}
|
||||
className={`p-3 rounded-lg border-2 ${isSelected ? 'border-blue-500' : 'border-gray-200'}`}
|
||||
onClick={() => handleThemeSelect(theme)}
|
||||
>
|
||||
{/* 渐变预览 */}
|
||||
<View
|
||||
className="w-full h-16 rounded-lg mb-2"
|
||||
style={{
|
||||
background: theme.background
|
||||
}}
|
||||
>
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Text className="text-white text-xs font-bold">
|
||||
预览
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 主题信息 */}
|
||||
<View className="text-center">
|
||||
<Text className="text-sm font-semibold text-gray-800 mb-1">
|
||||
{theme.description.split(' - ')[0]}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500">
|
||||
{theme.description.split(' - ')[1]}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 选中标识 */}
|
||||
{isSelected && (
|
||||
<View className="absolute top-1 right-1 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<Text className="text-white text-xs">✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
position="bottom"
|
||||
onClose={onClose}
|
||||
style={{ height: '70vh' }}
|
||||
>
|
||||
<View className="p-4">
|
||||
<View className="flex items-center justify-between mb-4">
|
||||
<Text className="text-lg font-bold">选择主题</Text>
|
||||
<Button size="small" fill="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm text-gray-600 mb-4">
|
||||
选择您喜欢的渐变主题,让界面更符合您的个人风格
|
||||
</Text>
|
||||
|
||||
{/* 主题网格 */}
|
||||
<View className="grid grid-cols-2 gap-3 mb-6">
|
||||
{gradientThemes.map(renderThemeItem)}
|
||||
</View>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
disabled={!selectedTheme}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
应用主题
|
||||
</Button>
|
||||
</View>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradientThemeSelector
|
||||
31
src/components/Header.tsx
Normal file
31
src/components/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import {NavBar} from '@nutui/nutui-react-taro'
|
||||
import {ArrowLeft} from '@nutui/icons-react-taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
function Header(props) {
|
||||
return (
|
||||
<>
|
||||
<NavBar
|
||||
style={{
|
||||
background: 'url(https://oss.wsdns.cn/20250413/defb52abb1414429930ae2727d2b8ff6.png)',
|
||||
backgroundSize: 'cover',
|
||||
color: '#fff',
|
||||
}}
|
||||
onBackClick={() => {
|
||||
}}
|
||||
back={
|
||||
<>
|
||||
<div className={'flex items-center'} onClick={() => Taro.navigateBack()}>
|
||||
<ArrowLeft size={14}/>
|
||||
返回
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className={'text-white'}>{props?.title || '标题'}</span>
|
||||
</NavBar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header;
|
||||
78
src/components/OrderConfirmSkeleton.scss
Normal file
78
src/components/OrderConfirmSkeleton.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
.order-confirm-skeleton {
|
||||
padding: 0;
|
||||
background: #f5f5f5;
|
||||
|
||||
.skeleton-section {
|
||||
background: #fff;
|
||||
margin-bottom: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.skeleton-address {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-goods {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-payment {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-price-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-remark {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
80
src/components/OrderConfirmSkeleton.tsx
Normal file
80
src/components/OrderConfirmSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { View } from '@tarojs/components'
|
||||
import { Skeleton } from '@nutui/nutui-react-taro'
|
||||
import './OrderConfirmSkeleton.scss'
|
||||
|
||||
const OrderConfirmSkeleton: React.FC = () => {
|
||||
return (
|
||||
<View className="order-confirm-skeleton">
|
||||
{/* 收货地址骨架 */}
|
||||
<View className="skeleton-section">
|
||||
<View className="skeleton-address">
|
||||
<Skeleton width="20px" height="20px" animated />
|
||||
<View className="skeleton-address-content">
|
||||
<Skeleton width="200px" height="16px" animated />
|
||||
<Skeleton width="120px" height="14px" animated />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品信息骨架 */}
|
||||
<View className="skeleton-section">
|
||||
<View className="skeleton-goods">
|
||||
<Skeleton width="80px" height="80px" animated />
|
||||
<View className="skeleton-goods-content">
|
||||
<Skeleton width="180px" height="16px" animated />
|
||||
<Skeleton width="60px" height="14px" animated />
|
||||
<View className="skeleton-goods-price">
|
||||
<Skeleton width="80px" height="16px" animated />
|
||||
<Skeleton width="40px" height="14px" animated />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 支付方式骨架 */}
|
||||
<View className="skeleton-section">
|
||||
<View className="skeleton-payment">
|
||||
<Skeleton width="60px" height="16px" animated />
|
||||
<Skeleton width="80px" height="16px" animated />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 价格明细骨架 */}
|
||||
<View className="skeleton-section">
|
||||
<View className="skeleton-price-item">
|
||||
<Skeleton width="100px" height="16px" animated />
|
||||
<Skeleton width="60px" height="16px" animated />
|
||||
</View>
|
||||
<View className="skeleton-price-item">
|
||||
<Skeleton width="60px" height="16px" animated />
|
||||
<Skeleton width="80px" height="16px" animated />
|
||||
</View>
|
||||
<View className="skeleton-price-item">
|
||||
<Skeleton width="60px" height="16px" animated />
|
||||
<Skeleton width="60px" height="16px" animated />
|
||||
</View>
|
||||
<View className="skeleton-price-item">
|
||||
<Skeleton width="120px" height="18px" animated />
|
||||
<Skeleton width="80px" height="18px" animated />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 订单备注骨架 */}
|
||||
<View className="skeleton-section">
|
||||
<View className="skeleton-remark">
|
||||
<Skeleton width="60px" height="16px" animated />
|
||||
<Skeleton width="200px" height="32px" animated />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部按钮骨架 */}
|
||||
<View className="skeleton-bottom">
|
||||
<Skeleton width="120px" height="20px" animated />
|
||||
<Skeleton width="100px" height="40px" animated />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderConfirmSkeleton
|
||||
181
src/components/OrderList.tsx
Normal file
181
src/components/OrderList.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import {Avatar, Cell, Space, Tabs, Button, TabPane} from '@nutui/nutui-react-taro'
|
||||
import {useEffect, useState, CSSProperties} from "react";
|
||||
import Taro from '@tarojs/taro';
|
||||
import {InfiniteLoading} from '@nutui/nutui-react-taro'
|
||||
import dayjs from "dayjs";
|
||||
import {pageShopOrder} from "@/api/shop/shopOrder";
|
||||
import {ShopOrder} from "@/api/shop/shopOrder/model";
|
||||
import {copyText} from "@/utils/common";
|
||||
|
||||
const InfiniteUlStyle: CSSProperties = {
|
||||
marginTop: '84px',
|
||||
height: '82vh',
|
||||
width: '100%',
|
||||
padding: '0',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}
|
||||
const tabs = [
|
||||
{
|
||||
index: 0,
|
||||
key: '全部',
|
||||
title: '全部'
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
key: '待付款',
|
||||
title: '待付款'
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
key: '待发货',
|
||||
title: '待发货'
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
key: '已收货',
|
||||
title: '已收货'
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
key: '已完成',
|
||||
title: '已完成'
|
||||
}
|
||||
]
|
||||
|
||||
function OrderList(props: any) {
|
||||
const [list, setList] = useState<ShopOrder[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [tapIndex, setTapIndex] = useState<string | number>('0')
|
||||
|
||||
console.log(props.statusBarHeight, 'ppp')
|
||||
|
||||
const getOrderStatusParams = (index: string | number) => {
|
||||
let params: { payStatus?: number; deliveryStatus?: number; orderStatus?: number } = {};
|
||||
switch (index) {
|
||||
case '1': // 待付款
|
||||
params.payStatus = 0;
|
||||
break;
|
||||
case '2': // 待发货
|
||||
params.payStatus = 1;
|
||||
params.deliveryStatus = 10;
|
||||
break;
|
||||
case '3': // 已收货
|
||||
params.deliveryStatus = 30;
|
||||
break;
|
||||
case '4': // 已完成
|
||||
params.orderStatus = 1;
|
||||
break;
|
||||
case '0': // 全部
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const reload = async (resetPage = false) => {
|
||||
const currentPage = resetPage ? 1 : page;
|
||||
const params = getOrderStatusParams(tapIndex);
|
||||
pageShopOrder({ page: currentPage, ...params }).then(res => {
|
||||
let newList: ShopOrder[] | undefined = [];
|
||||
if (res?.list && res?.list.length > 0) {
|
||||
newList = resetPage ? res.list : list?.concat(res.list);
|
||||
setHasMore(true);
|
||||
} else {
|
||||
newList = res?.list;
|
||||
setHasMore(false);
|
||||
}
|
||||
setList(newList || []);
|
||||
setPage(currentPage);
|
||||
});
|
||||
};
|
||||
|
||||
const reloadMore = async () => {
|
||||
setPage(page + 1);
|
||||
reload();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reload(true); // 首次加载或tab切换时重置页码
|
||||
}, [tapIndex]); // 监听tapIndex变化
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
align={'left'}
|
||||
className={'fixed left-0'}
|
||||
style={{ top: '84px'}}
|
||||
tabStyle={{ backgroundColor: 'transparent'}}
|
||||
value={tapIndex}
|
||||
onChange={(paneKey) => {
|
||||
setTapIndex(paneKey)
|
||||
}}
|
||||
>
|
||||
{
|
||||
tabs?.map((item, index) => {
|
||||
return <TabPane key={index} title={item.title}></TabPane>
|
||||
})
|
||||
}
|
||||
</Tabs>
|
||||
<div style={InfiniteUlStyle} id="scroll">
|
||||
<InfiniteLoading
|
||||
target="scroll"
|
||||
hasMore={hasMore}
|
||||
onLoadMore={reloadMore}
|
||||
onScroll={() => {
|
||||
|
||||
}}
|
||||
onScrollToUpper={() => {
|
||||
|
||||
}}
|
||||
loadingText={
|
||||
<>
|
||||
加载中
|
||||
</>
|
||||
}
|
||||
loadMoreText={
|
||||
<>
|
||||
没有更多了
|
||||
</>
|
||||
}
|
||||
>
|
||||
{list?.map(item => {
|
||||
return (
|
||||
<Cell style={{padding: '16px'}} onClick={() => Taro.navigateTo({url: `/shop/orderDetail/index?orderId=${item.orderId}`})}>
|
||||
<Space direction={'vertical'} className={'w-full flex flex-col'}>
|
||||
<div className={'order-no flex justify-between'}>
|
||||
<span className={'text-gray-700 font-bold text-sm'}
|
||||
onClick={(e) => {e.stopPropagation(); copyText(`${item.orderNo}`)}}>{item.orderNo}</span>
|
||||
<span className={'text-orange-500'}>待付款</span> {/* 这里可以根据item.orderStatus显示不同的状态 */}
|
||||
</div>
|
||||
<div
|
||||
className={'create-time text-gray-400 text-xs'}>{dayjs(item.createTime).format('YYYY年MM月DD日 HH:mm:ss')}</div>
|
||||
<div className={'goods-info'}>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'flex items-center'}>
|
||||
<Avatar
|
||||
src='34'
|
||||
size={'45'}
|
||||
shape={'square'}
|
||||
/>
|
||||
<div className={'ml-2'}>{item.realName}</div>
|
||||
</div>
|
||||
<div className={'text-gray-400 text-xs'}>{item.totalNum}件商品</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={' w-full text-right'}>实付金额:¥{item.payPrice}</div>
|
||||
<Space className={'btn flex justify-end'}>
|
||||
<Button size={'small'}>发货</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Cell>
|
||||
)
|
||||
})}
|
||||
</InfiniteLoading>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderList
|
||||
118
src/components/PayRecord.tsx
Normal file
118
src/components/PayRecord.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import {Avatar, Cell, Space} from '@nutui/nutui-react-taro'
|
||||
import {useEffect, useState, CSSProperties} from "react";
|
||||
import {BszxPay} from "@/api/bszx/bszxPay/model";
|
||||
import {getCount, pageBszxPay} from "@/api/bszx/bszxPay";
|
||||
import {InfiniteLoading} from '@nutui/nutui-react-taro'
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const InfiniteUlStyle: CSSProperties = {
|
||||
height: '70vh',
|
||||
width: '100%',
|
||||
padding: '0',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}
|
||||
function PayRecord() {
|
||||
const [list, setList] = useState<BszxPay[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [totalMoney, setTotalMoney] = useState()
|
||||
const [numbers, setNumbers] = useState()
|
||||
const reload = async () => {
|
||||
pageBszxPay({page}).then(res => {
|
||||
let newList: BszxPay[] | undefined = []
|
||||
if (res?.list && res?.list.length > 0) {
|
||||
newList = list?.concat(res.list)
|
||||
setHasMore(true)
|
||||
} else {
|
||||
newList = res?.list
|
||||
setHasMore(false)
|
||||
}
|
||||
setList(newList || []);
|
||||
})
|
||||
getCount().then(res => {
|
||||
setNumbers(res.numbers);
|
||||
setTotalMoney(res.totalMoney);
|
||||
})
|
||||
}
|
||||
|
||||
const reloadMore = async () => {
|
||||
setPage(page + 1)
|
||||
reload().then();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(2)
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={'px-2'}>
|
||||
<Cell>
|
||||
<div className={'flex w-full text-center justify-around'}>
|
||||
<div className={'item py-1'}>
|
||||
<span className={'text-gray-400'}>已筹资金(元)</span>
|
||||
<span className={'text-xl py-1 font-bold'}>¥{totalMoney}元</span>
|
||||
</div>
|
||||
<div className={'item py-1'}>
|
||||
<span className={'text-gray-400'}>爱心人次</span>
|
||||
<span className={'text-xl py-1 font-bold'}>{numbers}次</span>
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<ul style={InfiniteUlStyle} id="scroll">
|
||||
<InfiniteLoading
|
||||
target="scroll"
|
||||
hasMore={hasMore}
|
||||
onLoadMore={reloadMore}
|
||||
onScroll={() => {
|
||||
console.log('onScroll')
|
||||
}}
|
||||
onScrollToUpper={() => {
|
||||
console.log('onScrollToUpper')
|
||||
}}
|
||||
loadingText={
|
||||
<>
|
||||
加载中
|
||||
</>
|
||||
}
|
||||
loadMoreText={
|
||||
<>
|
||||
没有更多了
|
||||
</>
|
||||
}
|
||||
>
|
||||
{list?.map(item => {
|
||||
return (
|
||||
<Cell style={{padding: '0'}}>
|
||||
<div className={'flex w-full justify-between items-center'}>
|
||||
<div className={'flex'}>
|
||||
<Space>
|
||||
<Avatar
|
||||
src={item.avatar}
|
||||
/>
|
||||
<div className={'flex flex-col'}>
|
||||
<div className={'real-name text-lg'}>
|
||||
{item.name || '匿名'}
|
||||
</div>
|
||||
<div style={{maxWidth: '240px'}} className={'text-gray-400'}>{item.formName},{dayjs(item.createTime).format('YYYY-MM-DD HH:mm')}</div>
|
||||
<div className={'text-green-600 my-1'}>心愿:{item.comments}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<div className={'price text-red-500 text-xl font-bold'}>
|
||||
¥{item.price}
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
)
|
||||
})}
|
||||
</InfiniteLoading>
|
||||
</ul>
|
||||
</Cell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PayRecord
|
||||
168
src/components/PaymentCountdown.md
Normal file
168
src/components/PaymentCountdown.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# PaymentCountdown 支付倒计时组件
|
||||
|
||||
基于订单创建时间的支付倒计时组件,支持静态显示和实时更新两种模式。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **双模式支持**:静态显示(列表页)和实时更新(详情页)
|
||||
- ✅ **智能状态判断**:自动判断紧急程度并应用不同样式
|
||||
- ✅ **过期自动处理**:倒计时结束后触发回调
|
||||
- ✅ **灵活样式**:支持徽章模式和纯文本模式
|
||||
- ✅ **性能优化**:避免不必要的重渲染
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础用法
|
||||
|
||||
```tsx
|
||||
import PaymentCountdown from '@/components/PaymentCountdown';
|
||||
|
||||
// 订单列表页 - 静态显示
|
||||
<PaymentCountdown
|
||||
createTime={order.createTime}
|
||||
payStatus={order.payStatus}
|
||||
realTime={false}
|
||||
mode="badge"
|
||||
/>
|
||||
|
||||
// 订单详情页 - 实时更新
|
||||
<PaymentCountdown
|
||||
createTime={order.createTime}
|
||||
payStatus={order.payStatus}
|
||||
realTime={true}
|
||||
showSeconds={true}
|
||||
mode="badge"
|
||||
onExpired={() => {
|
||||
console.log('支付已过期');
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
|
||||
```tsx
|
||||
// 自定义超时时间(12小时)
|
||||
<PaymentCountdown
|
||||
createTime={order.createTime}
|
||||
payStatus={order.payStatus}
|
||||
realTime={true}
|
||||
timeoutHours={12}
|
||||
showSeconds={true}
|
||||
mode="badge"
|
||||
className="custom-countdown"
|
||||
onExpired={handlePaymentExpired}
|
||||
/>
|
||||
|
||||
// 纯文本模式
|
||||
<PaymentCountdown
|
||||
createTime={order.createTime}
|
||||
payStatus={order.payStatus}
|
||||
realTime={false}
|
||||
mode="text"
|
||||
/>
|
||||
```
|
||||
|
||||
## API 参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| createTime | string | - | 订单创建时间 |
|
||||
| payStatus | boolean | false | 支付状态 |
|
||||
| realTime | boolean | false | 是否实时更新 |
|
||||
| timeoutHours | number | 24 | 超时小时数 |
|
||||
| showSeconds | boolean | false | 是否显示秒数 |
|
||||
| className | string | '' | 自定义样式类名 |
|
||||
| onExpired | function | - | 过期回调函数 |
|
||||
| mode | 'badge' \| 'text' | 'badge' | 显示模式 |
|
||||
|
||||
## 样式状态
|
||||
|
||||
### 正常状态
|
||||
- 红色渐变背景
|
||||
- 白色文字
|
||||
- 轻微阴影效果
|
||||
|
||||
### 紧急状态(< 1小时)
|
||||
- 更深的红色背景
|
||||
- 脉冲动画效果
|
||||
|
||||
### 非常紧急状态(< 10分钟)
|
||||
- 最深的红色背景
|
||||
- 快速闪烁动画
|
||||
|
||||
### 过期状态
|
||||
- 灰色背景
|
||||
- 无动画效果
|
||||
|
||||
## Hook 使用
|
||||
|
||||
如果需要单独使用倒计时逻辑,可以直接使用 Hook:
|
||||
|
||||
```tsx
|
||||
import { usePaymentCountdown, formatCountdownText } from '@/hooks/usePaymentCountdown';
|
||||
|
||||
const MyComponent = ({ order }) => {
|
||||
const timeLeft = usePaymentCountdown(
|
||||
order.createTime,
|
||||
order.payStatus,
|
||||
true, // 实时更新
|
||||
24 // 24小时超时
|
||||
);
|
||||
|
||||
const countdownText = formatCountdownText(timeLeft, true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
剩余时间:{countdownText}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 工具函数
|
||||
|
||||
```tsx
|
||||
import {
|
||||
formatCountdownText,
|
||||
isUrgentCountdown,
|
||||
isCriticalCountdown
|
||||
} from '@/hooks/usePaymentCountdown';
|
||||
|
||||
// 格式化倒计时文本
|
||||
const text = formatCountdownText(timeLeft, true); // "2小时30分15秒"
|
||||
|
||||
// 判断是否紧急
|
||||
const isUrgent = isUrgentCountdown(timeLeft); // < 1小时
|
||||
|
||||
// 判断是否非常紧急
|
||||
const isCritical = isCriticalCountdown(timeLeft); // < 10分钟
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **性能考虑**:列表页建议使用 `realTime={false}` 避免过多定时器
|
||||
2. **内存泄漏**:组件会自动清理定时器,无需手动处理
|
||||
3. **时区问题**:确保 `createTime` 格式正确,建议使用 ISO 格式
|
||||
4. **过期处理**:`onExpired` 回调只在实时模式下触发
|
||||
|
||||
## 样式定制
|
||||
|
||||
可以通过 CSS 变量或覆盖样式类来自定义外观:
|
||||
|
||||
```scss
|
||||
.custom-countdown {
|
||||
.payment-countdown-badge {
|
||||
background: linear-gradient(135deg, #your-color-1, #your-color-2);
|
||||
border-radius: 8px;
|
||||
|
||||
&.urgent {
|
||||
animation: customPulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes customPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
```
|
||||
161
src/components/PaymentCountdown.scss
Normal file
161
src/components/PaymentCountdown.scss
Normal file
@@ -0,0 +1,161 @@
|
||||
/* 支付倒计时样式 */
|
||||
|
||||
/* 徽章模式样式 */
|
||||
.payment-countdown-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #ff4757, #ff3838);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px rgba(255, 71, 87, 0.3);
|
||||
margin-left: 8px;
|
||||
|
||||
.countdown-text {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 紧急状态(少于1小时) */
|
||||
&.urgent {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* 非常紧急状态(少于10分钟) */
|
||||
&.critical {
|
||||
background: linear-gradient(135deg, #ff4757, #c44569);
|
||||
animation: flash 1s infinite;
|
||||
}
|
||||
|
||||
/* 过期状态 */
|
||||
&.expired {
|
||||
background: linear-gradient(135deg, #95a5a6, #7f8c8d);
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 纯文本模式样式 */
|
||||
.payment-countdown-text {
|
||||
color: #ff4757;
|
||||
font-weight: 500;
|
||||
|
||||
/* 紧急状态 */
|
||||
&.urgent {
|
||||
color: #ff6b6b;
|
||||
animation: textPulse 2s infinite;
|
||||
}
|
||||
|
||||
/* 非常紧急状态 */
|
||||
&.critical {
|
||||
color: #ff4757;
|
||||
animation: textFlash 1s infinite;
|
||||
}
|
||||
|
||||
/* 过期状态 */
|
||||
&.expired {
|
||||
color: #95a5a6;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes textPulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes textFlash {
|
||||
0% { opacity: 1; }
|
||||
25% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
75% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 375px) {
|
||||
.payment-countdown-badge {
|
||||
padding: 3px 6px;
|
||||
|
||||
.countdown-text {
|
||||
}
|
||||
}
|
||||
|
||||
.payment-countdown-text {
|
||||
}
|
||||
}
|
||||
|
||||
/* 详情页专用样式 */
|
||||
.order-detail-countdown {
|
||||
.payment-countdown-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
margin: 8px 0;
|
||||
|
||||
.countdown-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-countdown-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表页专用样式 */
|
||||
.order-list-countdown {
|
||||
.payment-countdown-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
|
||||
.countdown-text {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.payment-countdown-text {
|
||||
|
||||
}
|
||||
}
|
||||
82
src/components/PaymentCountdown.tsx
Normal file
82
src/components/PaymentCountdown.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import {
|
||||
usePaymentCountdown,
|
||||
formatCountdownText,
|
||||
isUrgentCountdown,
|
||||
isCriticalCountdown
|
||||
} from '@/hooks/usePaymentCountdown';
|
||||
import './PaymentCountdown.scss';
|
||||
|
||||
export interface PaymentCountdownProps {
|
||||
/** 订单创建时间 */
|
||||
createTime?: string;
|
||||
/** 支付状态 */
|
||||
payStatus?: boolean;
|
||||
/** 是否实时更新(详情页用true,列表页用false) */
|
||||
realTime?: boolean;
|
||||
/** 超时小时数,默认24小时 */
|
||||
timeoutHours?: number;
|
||||
/** 是否显示秒数 */
|
||||
showSeconds?: boolean;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
/** 过期回调 */
|
||||
onExpired?: () => void;
|
||||
/** 显示模式:badge(徽章模式) | text(纯文本模式) */
|
||||
mode?: 'badge' | 'text';
|
||||
}
|
||||
|
||||
const PaymentCountdown: React.FC<PaymentCountdownProps> = ({
|
||||
createTime,
|
||||
payStatus = false,
|
||||
realTime = false,
|
||||
timeoutHours = 1,
|
||||
showSeconds = false,
|
||||
className = '',
|
||||
onExpired,
|
||||
mode = 'badge'
|
||||
}) => {
|
||||
const timeLeft = usePaymentCountdown(createTime, payStatus, realTime, timeoutHours);
|
||||
|
||||
// 如果已支付或没有创建时间,不显示倒计时
|
||||
if (payStatus || !createTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已过期,触发回调并显示过期状态
|
||||
if (timeLeft.isExpired) {
|
||||
onExpired?.();
|
||||
return (
|
||||
<Text className={`payment-countdown-text expired ${className}`}>
|
||||
支付已过期
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// 判断紧急程度
|
||||
const isUrgent = isUrgentCountdown(timeLeft);
|
||||
const isCritical = isCriticalCountdown(timeLeft);
|
||||
|
||||
// 格式化倒计时文本
|
||||
const countdownText = formatCountdownText(timeLeft, showSeconds);
|
||||
const fullText = `等待付款 ${countdownText}`;
|
||||
|
||||
// 纯文本模式
|
||||
if (mode === 'text') {
|
||||
return (
|
||||
<Text className={`payment-countdown-text ${isUrgent ? 'urgent' : ''} ${isCritical ? 'critical' : ''} ${className}`}>
|
||||
{fullText}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// 徽章模式
|
||||
return (
|
||||
<View className={`payment-countdown-badge ${isUrgent ? 'urgent' : ''} ${isCritical ? 'critical' : ''} ${className}`}>
|
||||
<Text className="countdown-text">{fullText}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentCountdown;
|
||||
120
src/components/QuantitySelector.scss
Normal file
120
src/components/QuantitySelector.scss
Normal file
@@ -0,0 +1,120 @@
|
||||
.quantity-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: #f8f8f8;
|
||||
color: #666;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: #f5f5f5 !important;
|
||||
color: #ccc !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e5e5e5;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__stock {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__stock-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 尺寸变体
|
||||
&--small {
|
||||
.quantity-selector__controls {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.quantity-selector__btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.quantity-selector__input {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.quantity-selector__value {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
.quantity-selector__controls {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.quantity-selector__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.quantity-selector__input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.quantity-selector__value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
.quantity-selector__controls {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.quantity-selector__btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.quantity-selector__input {
|
||||
width: 48px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.quantity-selector__value {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/components/QuantitySelector.tsx
Normal file
88
src/components/QuantitySelector.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Button } from '@nutui/nutui-react-taro'
|
||||
import { Minus, Plus } from '@nutui/icons-react-taro'
|
||||
import './QuantitySelector.scss'
|
||||
|
||||
export interface QuantitySelectorProps {
|
||||
/** 当前数量 */
|
||||
value: number
|
||||
/** 最小数量 */
|
||||
min?: number
|
||||
/** 最大数量(库存) */
|
||||
max?: number
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 数量变化回调 */
|
||||
onChange?: (value: number) => void
|
||||
/** 尺寸 */
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
/** 是否显示库存提示 */
|
||||
showStock?: boolean
|
||||
/** 库存数量 */
|
||||
stock?: number
|
||||
}
|
||||
|
||||
const QuantitySelector: React.FC<QuantitySelectorProps> = ({
|
||||
value,
|
||||
min = 1,
|
||||
max = 999,
|
||||
disabled = false,
|
||||
onChange,
|
||||
size = 'medium',
|
||||
showStock = false,
|
||||
stock
|
||||
}) => {
|
||||
const handleDecrease = () => {
|
||||
if (disabled || value <= min) return
|
||||
const newValue = value - 1
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
const handleIncrease = () => {
|
||||
if (disabled || value >= max) return
|
||||
const newValue = value + 1
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
const canDecrease = !disabled && value > min
|
||||
const canIncrease = !disabled && value < max
|
||||
|
||||
return (
|
||||
<View className={`quantity-selector quantity-selector--${size}`}>
|
||||
<View className="quantity-selector__controls">
|
||||
<Button
|
||||
className={`quantity-selector__btn quantity-selector__btn--minus ${!canDecrease ? 'quantity-selector__btn--disabled' : ''}`}
|
||||
size="small"
|
||||
onClick={handleDecrease}
|
||||
disabled={!canDecrease}
|
||||
>
|
||||
<Minus size={size === 'small' ? 12 : size === 'large' ? 16 : 14} />
|
||||
</Button>
|
||||
|
||||
<View className="quantity-selector__input">
|
||||
<Text className="quantity-selector__value">{value}</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className={`quantity-selector__btn quantity-selector__btn--plus ${!canIncrease ? 'quantity-selector__btn--disabled' : ''}`}
|
||||
size="small"
|
||||
onClick={handleIncrease}
|
||||
disabled={!canIncrease}
|
||||
>
|
||||
<Plus size={size === 'small' ? 12 : size === 'large' ? 16 : 14} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{showStock && stock !== undefined && (
|
||||
<View className="quantity-selector__stock">
|
||||
<Text className="text-sm">
|
||||
库存 {stock} 件
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuantitySelector
|
||||
110
src/components/SimpleQRCodeModal.tsx
Normal file
110
src/components/SimpleQRCodeModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {View, Text} from '@tarojs/components'
|
||||
import {Popup} from '@nutui/nutui-react-taro'
|
||||
import {Close, QrCode} from '@nutui/icons-react-taro'
|
||||
|
||||
export interface SimpleQRCodeModalProps {
|
||||
/** 是否显示弹窗 */
|
||||
visible: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 二维码内容(礼品卡code码) */
|
||||
qrContent: string
|
||||
/** 礼品卡名称 */
|
||||
giftName?: string
|
||||
/** 礼品卡面值 */
|
||||
faceValue?: string
|
||||
}
|
||||
|
||||
const SimpleQRCodeModal: React.FC<SimpleQRCodeModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
qrContent,
|
||||
giftName,
|
||||
faceValue
|
||||
}) => {
|
||||
|
||||
// const copyToClipboard = () => {
|
||||
// if (qrContent) {
|
||||
// Taro.setClipboardData({
|
||||
// data: qrContent,
|
||||
// success: () => {
|
||||
// Taro.showToast({
|
||||
// title: '兑换码已复制',
|
||||
// icon: 'success'
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
position="center"
|
||||
closeable
|
||||
closeIcon={<Close/>}
|
||||
onClose={onClose}
|
||||
style={{
|
||||
width: '90%'
|
||||
}}
|
||||
>
|
||||
<View className="p-6">
|
||||
{/* 标题 */}
|
||||
<View className="mb-4">
|
||||
<Text className="text-lg font-bold">核销码</Text>
|
||||
</View>
|
||||
|
||||
{/* 礼品卡信息 */}
|
||||
{(giftName || faceValue) && (
|
||||
<View className="bg-gray-50 rounded-lg p-3 mb-4 hidden">
|
||||
{giftName && (
|
||||
<Text className="font-medium text-center mb-1">
|
||||
{giftName}
|
||||
</Text>
|
||||
)}
|
||||
{faceValue && (
|
||||
<Text className="text-lg font-bold text-red-500 text-center">
|
||||
¥{faceValue}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 二维码区域 */}
|
||||
<View className="text-center mb-4">
|
||||
<View className="p-4 bg-white border border-gray-200 rounded-lg">
|
||||
{qrContent ? (
|
||||
<View className={'flex flex-col justify-center'}>
|
||||
<img
|
||||
src={`http://127.0.0.1:9200/api/qr-code/create-encrypted-qr-image?size=300x300&expireMinutes=60&businessType=gift&data=${encodeURIComponent(qrContent)}`}
|
||||
alt="二维码"
|
||||
style={{width: '200px', height: '200px'}}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Text className="text-sm text-gray-400 mt-1 px-2">
|
||||
请向商家出示此二维码
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="bg-gray-100 rounded flex items-center justify-center mx-auto"
|
||||
style={{width: '200px', height: '200px'}}>
|
||||
<View className="text-center">
|
||||
<QrCode size="48" className="text-gray-400 mb-2"/>
|
||||
<Text className="text-gray-500 text-sm">生成中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
||||
export default SimpleQRCodeModal
|
||||
0
src/components/SpecSelector/index.scss
Normal file
0
src/components/SpecSelector/index.scss
Normal file
176
src/components/SpecSelector/index.tsx
Normal file
176
src/components/SpecSelector/index.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View } from '@tarojs/components';
|
||||
import { Popup, Button, Radio, Image, Space, Cell, CellGroup } from '@nutui/nutui-react-taro';
|
||||
import { ShopGoodsSku } from '@/api/shop/shopGoodsSku/model';
|
||||
import { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model';
|
||||
import { ShopGoods } from '@/api/shop/shopGoods/model';
|
||||
import './index.scss';
|
||||
|
||||
interface SpecSelectorProps {
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
goods: ShopGoods;
|
||||
specs: ShopGoodsSpec[];
|
||||
skus: ShopGoodsSku[];
|
||||
onConfirm: (selectedSku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => void;
|
||||
action?: 'cart' | 'buy';
|
||||
}
|
||||
|
||||
interface SpecGroup {
|
||||
specName: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
const SpecSelector: React.FC<SpecSelectorProps> = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
goods,
|
||||
specs,
|
||||
skus,
|
||||
onConfirm,
|
||||
action = 'cart'
|
||||
}) => {
|
||||
const [selectedSpecs, setSelectedSpecs] = useState<Record<string, string>>({});
|
||||
const [selectedSku, setSelectedSku] = useState<ShopGoodsSku | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [specGroups, setSpecGroups] = useState<SpecGroup[]>([]);
|
||||
|
||||
// 组织规格数据
|
||||
useEffect(() => {
|
||||
if (specs.length > 0) {
|
||||
const groups: Record<string, Set<string>> = {};
|
||||
|
||||
specs.forEach(spec => {
|
||||
if (spec.specName && spec.specValue) {
|
||||
if (!groups[spec.specName]) {
|
||||
groups[spec.specName] = new Set();
|
||||
}
|
||||
groups[spec.specName].add(spec.specValue);
|
||||
}
|
||||
});
|
||||
|
||||
const groupsArray = Object.entries(groups).map(([specName, values]) => ({
|
||||
specName,
|
||||
values: Array.from(values)
|
||||
}));
|
||||
|
||||
setSpecGroups(groupsArray);
|
||||
}
|
||||
}, [specs]);
|
||||
|
||||
// 根据选中规格找到对应SKU
|
||||
useEffect(() => {
|
||||
if (Object.keys(selectedSpecs).length === specGroups.length && skus.length > 0) {
|
||||
// 构建规格值字符串,按照规格名称排序确保一致性
|
||||
const sortedSpecNames = specGroups.map(g => g.specName).sort();
|
||||
const specValues = sortedSpecNames.map(name => selectedSpecs[name]).join('|');
|
||||
|
||||
const sku = skus.find(s => s.sku === specValues);
|
||||
setSelectedSku(sku || null);
|
||||
} else {
|
||||
setSelectedSku(null);
|
||||
}
|
||||
}, [selectedSpecs, skus, specGroups]);
|
||||
|
||||
// 选择规格值
|
||||
// const handleSpecSelect = (specName: string, specValue: string) => {
|
||||
// setSelectedSpecs(prev => ({
|
||||
// ...prev,
|
||||
// [specName]: specValue
|
||||
// }));
|
||||
// };
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (!selectedSku) {
|
||||
return;
|
||||
}
|
||||
onConfirm(selectedSku, quantity, action);
|
||||
};
|
||||
|
||||
// 检查规格值是否可选(是否有对应的SKU且有库存)
|
||||
// const isSpecValueAvailable = (specName: string, specValue: string) => {
|
||||
// const testSpecs = { ...selectedSpecs, [specName]: specValue };
|
||||
//
|
||||
// // 如果还有其他规格未选择,则认为可选
|
||||
// if (Object.keys(testSpecs).length < specGroups.length) {
|
||||
// return true;
|
||||
// }
|
||||
//
|
||||
// // 构建规格值字符串
|
||||
// const sortedSpecNames = specGroups.map(g => g.specName).sort();
|
||||
// const specValues = sortedSpecNames.map(name => testSpecs[name]).join('|');
|
||||
//
|
||||
// const sku = skus.find(s => s.sku === specValues);
|
||||
// return sku && sku.stock && sku.stock > 0 && sku.status === 0;
|
||||
// };
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
position="bottom"
|
||||
onClose={onClose}
|
||||
style={{ height: '60vh' }}
|
||||
>
|
||||
<View className="spec-selector">
|
||||
{/* 商品信息 */}
|
||||
<View className="spec-selector__header p-4">
|
||||
<Space className="flex">
|
||||
<Image
|
||||
src={selectedSku?.image || goods.image || ''}
|
||||
width="80"
|
||||
height="80"
|
||||
radius="8"
|
||||
/>
|
||||
<View className="goods-detail">
|
||||
<View className="goods-name font-medium text-lg">{goods.name}</View>
|
||||
<View className="text-red-500">
|
||||
¥{selectedSku?.price || goods.price}
|
||||
</View>
|
||||
<View className="goods-stock text-gray-500">
|
||||
库存:{selectedSku?.stock || goods.stock}
|
||||
</View>
|
||||
</View>
|
||||
</Space>
|
||||
</View>
|
||||
|
||||
{/* 规格选择 */}
|
||||
<CellGroup className="spec-selector__content">
|
||||
<Cell>
|
||||
<Space direction="vertical">
|
||||
<View className={'title'}>套餐</View>
|
||||
<Radio.Group defaultValue="1" direction="horizontal">
|
||||
<Radio shape="button" value="1">
|
||||
选项1
|
||||
</Radio>
|
||||
<Radio shape="button" value="2">
|
||||
选项2
|
||||
</Radio>
|
||||
<Radio shape="button" value="3">
|
||||
选项3
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
</Cell>
|
||||
</CellGroup>
|
||||
{/* 底部按钮 */}
|
||||
<View className="fixed bottom-7 w-full">
|
||||
<View className={'px-4'}>
|
||||
<Button
|
||||
type="success"
|
||||
size="large"
|
||||
className={'w-full'}
|
||||
block
|
||||
// disabled={!selectedSku || !selectedSku.stock || selectedSku.stock <= 0}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecSelector;
|
||||
28
src/components/TabBar.tsx
Normal file
28
src/components/TabBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Tabbar } from '@nutui/nutui-react-taro'
|
||||
import { Home, User } from '@nutui/icons-react-taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
function TabBar(){
|
||||
return (
|
||||
<Tabbar
|
||||
fixed
|
||||
onSwitch={(index) => {
|
||||
console.log(index)
|
||||
if(index == 0){
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
// if(index == 1){
|
||||
// Taro.navigateTo({ url: '/pages/detail/detail' })
|
||||
// }
|
||||
if(index == 1){
|
||||
Taro.switchTab({ url: '/pages/user/user' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabbar.Item title="首页" icon={<Home size={18} />} />
|
||||
{/*<Tabbar.Item title="分类" icon={<Date size={18} />} />*/}
|
||||
<Tabbar.Item title="我的" icon={<User size={18} />} />
|
||||
</Tabbar>
|
||||
)
|
||||
}
|
||||
export default TabBar;
|
||||
99
src/components/UserProfile.tsx
Normal file
99
src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import { Button, Avatar } from '@nutui/nutui-react-taro';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
import navTo from '@/utils/common';
|
||||
|
||||
// 用户资料组件示例
|
||||
const UserProfile: React.FC = () => {
|
||||
const {
|
||||
user,
|
||||
isLoggedIn,
|
||||
loading,
|
||||
logoutUser,
|
||||
fetchUserInfo,
|
||||
getAvatarUrl,
|
||||
getDisplayName,
|
||||
isCertified,
|
||||
getBalance,
|
||||
getPoints
|
||||
} = useUser();
|
||||
|
||||
// 处理登录跳转
|
||||
const handleLogin = () => {
|
||||
navTo('/pages/login/index');
|
||||
};
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = () => {
|
||||
logoutUser();
|
||||
navTo('/pages/index/index');
|
||||
};
|
||||
|
||||
// 刷新用户信息
|
||||
const handleRefresh = async () => {
|
||||
await fetchUserInfo();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="user-profile loading">
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<View className="user-profile not-logged-in">
|
||||
<View className="login-prompt">
|
||||
<Text>请先登录</Text>
|
||||
<Button type="primary" onClick={handleLogin}>
|
||||
立即登录
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="user-profile">
|
||||
<View className="user-header">
|
||||
<Avatar
|
||||
size="large"
|
||||
src={getAvatarUrl()}
|
||||
alt={getDisplayName()}
|
||||
/>
|
||||
<View className="user-info">
|
||||
<Text className="username">{getDisplayName()}</Text>
|
||||
<Text className="user-id">ID: {user?.userId}</Text>
|
||||
{isCertified() && (
|
||||
<Text className="certified">已实名认证</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="user-stats">
|
||||
<View className="stat-item">
|
||||
<Text className="stat-value">¥{getBalance()}</Text>
|
||||
<Text className="stat-label">余额</Text>
|
||||
</View>
|
||||
<View className="stat-item">
|
||||
<Text className="stat-value">{getPoints()}</Text>
|
||||
<Text className="stat-label">积分</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="user-actions">
|
||||
<Button onClick={handleRefresh}>
|
||||
刷新信息
|
||||
</Button>
|
||||
<Button type="danger" onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
Reference in New Issue
Block a user