feat(src): 新增文章、经销商申请、用户地址和礼物添加功能

- 新增文章添加页面,支持文章基本信息、设置、高级设置和图片上传
- 新增经销商申请页面,支持申请信息填写和审核状态显示
- 新增用户地址添加页面,支持地址信息填写和地址识别功能
- 新增礼物添加页面,功能与文章添加类似
- 统一使用 .tsx 文件格式
- 添加 .editorconfig、.eslintrc 和 .gitignore 文件,规范代码风格和项目结构
This commit is contained in:
2025-08-20 14:56:38 +08:00
commit 217bfacadd
507 changed files with 70034 additions and 0 deletions

View 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

View 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;

View 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;
}
}
}

View 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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View 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

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock } from '@nutui/icons-react-taro'
export interface CouponStatsProps {
/** 可用优惠券数量 */
availableCount: number
/** 已使用优惠券数量 */
usedCount: number
/** 已过期优惠券数量 */
expiredCount: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired') => void
}
const CouponStats: React.FC<CouponStatsProps> = ({
availableCount,
usedCount,
expiredCount,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-4 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

View File

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

View File

@@ -0,0 +1,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;
}
}
}
}

View 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;

View 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
View 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
View 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. 使用说明和注意事项条目建议简洁明了

View 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
View 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

View 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

View File

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

View File

@@ -0,0 +1,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

View 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

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock, Star } from '@nutui/icons-react-taro'
export interface GiftCardStatsProps {
/** 可用礼品卡数量 */
availableCount: number
/** 已使用礼品卡数量 */
usedCount: number
/** 已过期礼品卡数量 */
expiredCount: number
/** 礼品卡总价值 */
totalValue?: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired' | 'total') => void
}
const GiftCardStats: React.FC<GiftCardStatsProps> = ({
availableCount,
usedCount,
expiredCount,
totalValue,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired' | 'total') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-3 shadow-sm 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

View File

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

View 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

View 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
View 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;

View 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);
}
}

View 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

View 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

View 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

View 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; }
}
```

View 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 {
}
}

View 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;

View 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;
}
}
}

View 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

View 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

View File

View 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
View 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;

View 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;