fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top

- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api
- 更新图片上传接口地址为新的 guilixu-api 域名
- 修改用户推广页面中邀请码链接和二维码接口的域名
- 更改注册页微信登录接口请求的域名为 guilixu-api
This commit is contained in:
2026-06-16 17:15:59 +08:00
commit f3886664f7
617 changed files with 77059 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
.error-boundary {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
}
.error-boundary__container {
width: 100%;
max-width: 560px;
padding: 32px 24px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
text-align: center;
}
.error-boundary__icon {
width: 72px;
height: 72px;
margin: 0 auto 16px;
border-radius: 999px;
background: #fee2e2;
color: #dc2626;
font-size: 32px;
line-height: 72px;
font-weight: 700;
}
.error-boundary__title {
display: block;
margin-bottom: 12px;
font-size: 32px;
font-weight: 600;
color: #0f172a;
}
.error-boundary__message {
display: block;
color: #475569;
line-height: 1.6;
}
.error-boundary__details {
margin-top: 20px;
padding: 16px;
text-align: left;
border-radius: 16px;
background: #f8fafc;
}
.error-boundary__error-title {
display: block;
margin-bottom: 8px;
font-size: 24px;
font-weight: 600;
color: #0f172a;
}
.error-boundary__error-message {
display: block;
word-break: break-word;
color: #b91c1c;
font-size: 22px;
}
.error-boundary__actions {
display: flex;
gap: 16px;
margin-top: 24px;
}
.error-boundary__button {
flex: 1;
}
.error-boundary__button--primary {
color: #fff;
background: #2563eb;
}
.error-boundary__button--secondary {
color: #0f172a;
background: #e2e8f0;
}

View File

@@ -0,0 +1,75 @@
import React, { Component, type 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
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({ error, errorInfo })
this.props.onError?.(error, errorInfo)
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
private handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined })
}
private handleReload = () => {
Taro.reLaunch({ url: '/pages/index/index' })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
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>
</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,35 @@
import { View, Text } from '@tarojs/components';
import { useNavigate } from '@tarojs/taro';
interface NavBarProps {
title: string;
onBack?: () => void;
rightText?: string;
onRightClick?: () => void;
}
export default function NavBar({ title, onBack, rightText, onRightClick }: NavBarProps) {
const navigate = useNavigate();
const handleBack = () => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
};
return (
<View className="flex items-center justify-between px-4 py-3 bg-white border-b border-gray-200">
<View className="flex items-center flex-1" onClick={handleBack}>
<Text className="text-lg mr-2"></Text>
<Text className="text-base font-medium truncate">{title}</Text>
</View>
{rightText && (
<View onClick={onRightClick}>
<Text className="text-sm text-blue-500">{rightText}</Text>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Loading } from '@nutui/nutui-react-taro'
import { Failure, Scan, Success } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { confirmWechatQRLogin, parseQRContent } from '@/api/passport/qr-login'
export interface QRLoginScannerProps {
onSuccess?: (result: any) => void
onError?: (error: string) => void
className?: string
buttonText?: string
showStatus?: boolean
}
type ScanState = 'idle' | 'scanning' | 'confirming' | 'success' | 'error'
const QRLoginScanner: React.FC<QRLoginScannerProps> = ({
onSuccess,
onError,
className = '',
buttonText = '开始扫码登录',
showStatus = true,
}) => {
const [state, setState] = useState<ScanState>('idle')
const [error, setError] = useState('')
const [result, setResult] = useState<any>(null)
const reset = () => {
setState('idle')
setError('')
setResult(null)
}
const startScan = async () => {
try {
setState('scanning')
setError('')
const scanRes = await Taro.scanCode({ onlyFromCamera: false, scanType: ['qrCode'] })
const rawContent = scanRes.result || ''
const token = parseQRContent(rawContent)
const userId = Number(Taro.getStorageSync('UserId'))
if (!token) {
throw new Error('未识别到有效的登录二维码')
}
if (!userId) {
throw new Error('当前用户未登录')
}
setState('confirming')
const confirmRes = await confirmWechatQRLogin(token, userId)
setResult(confirmRes)
setState('success')
onSuccess?.(confirmRes)
} catch (err: any) {
const message = err?.errMsg?.includes('cancel')
? '已取消扫码'
: err?.message || '扫码登录失败'
setError(message)
setState('error')
onError?.(message)
}
}
const renderStatus = () => {
switch (state) {
case 'scanning':
return (
<View className="flex items-center justify-center text-blue-500">
<Loading className="mr-2" />
<Text>...</Text>
</View>
)
case 'confirming':
return (
<View className="flex items-center justify-center text-orange-500">
<Loading className="mr-2" />
<Text>...</Text>
</View>
)
case 'success':
return (
<View className="flex items-center justify-center text-green-500">
<Success className="mr-2" />
<Text></Text>
</View>
)
case 'error':
return (
<View className="flex items-center justify-center text-red-500">
<Failure className="mr-2" />
<Text>{error || '扫码登录失败'}</Text>
</View>
)
default:
return null
}
}
const loading = state === 'scanning' || state === 'confirming'
return (
<View className={`qr-login-scanner ${className}`}>
<Button
type="primary"
size="large"
loading={loading}
onClick={state === 'success' ? reset : startScan}
className="w-full"
>
{!loading && <Scan className="mr-2" />}
{state === 'success' ? '重新扫码' : state === 'error' ? '重试' : buttonText}
</Button>
{showStatus && <View className="mt-4 text-center">{renderStatus()}</View>}
{state === 'success' && result && (
<View className="mt-4 rounded-lg bg-green-50 p-4">
<Text className="text-sm text-green-700"></Text>
</View>
)}
{state === 'idle' && (
<View className="mt-4 text-center">
<Text className="text-xs text-gray-500"></Text>
</View>
)}
</View>
)
}
export default QRLoginScanner

View File

@@ -0,0 +1,121 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
interface AddressCardProps {
address: ShopUserAddress
/** 是否选中状态(用于选择地址场景,如结算页) */
selected?: boolean
/** 是否为选择模式显示radio圆圈 */
selectMode?: boolean
onClick?: () => void
showActions?: boolean
onEdit?: () => void
onDelete?: () => void
onDefault?: () => void
}
const AddressCard: React.FC<AddressCardProps> = ({
address,
selected = false,
selectMode = false,
onClick,
showActions = false,
onEdit,
onDelete,
onDefault,
}) => {
const fullAddress = [address.province, address.city, address.region, address.address].filter(Boolean).join(' ')
return (
<View
className={`bg-white rounded-xl p-3 mb-3 border-2 ${selected ? 'border-green-500' : 'border-transparent'}`}
style={{
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
}}
onClick={onClick}
>
<View className="flex gap-3">
{/* 选择模式下的radio圈 */}
{selectMode && (
<View className="flex items-center justify-center" style={{ minWidth: '20px' }}>
<View
className={`rounded-full ${selected ? 'bg-green-500' : 'border-2 border-gray-300'}`}
style={{ width: '18px', height: '18px' }}
>
{selected && (
<View className="flex items-center justify-center h-full">
<Text className="text-white text-xs" style={{ fontSize: '10px', lineHeight: '18px' }}></Text>
</View>
)}
</View>
</View>
)}
<View className="flex-1">
{/* 姓名 + 手机号 + 默认标签 */}
<View className="flex items-center gap-2 mb-1">
<Text className="text-sm font-medium text-gray-800">{address.name}</Text>
<Text className="text-sm text-gray-600">{address.phone}</Text>
{address.isDefault && (
<View className="bg-green-50 rounded px-1 py-0">
<Text className="text-green-600" style={{ fontSize: '10px' }}></Text>
</View>
)}
</View>
{/* 完整地址 */}
<Text className="text-xs text-gray-500 leading-5">
{fullAddress || address.fullAddress || address.address || '暂无地址'}
</Text>
</View>
</View>
{/* 操作栏 */}
{showActions && (
<View className="flex justify-between items-center mt-2 pt-2 border-t border-gray-100">
{/* 默认地址切换 */}
<View
className="flex items-center gap-1"
onClick={(e) => { e.stopPropagation(); onDefault?.() }}
>
<View
className={`rounded-full ${address.isDefault ? 'bg-green-500' : 'border-2 border-gray-300'}`}
style={{ width: '16px', height: '16px' }}
>
{address.isDefault && (
<View className="flex items-center justify-center h-full">
<Text className="text-white" style={{ fontSize: '9px', lineHeight: '16px' }}></Text>
</View>
)}
</View>
<Text className={`text-xs ${address.isDefault ? 'text-green-600' : 'text-gray-400'}`}></Text>
</View>
{/* 编辑/删除 */}
<View className="flex items-center gap-3">
{onEdit && (
<Text
className="text-xs text-gray-400"
onClick={(e) => { e.stopPropagation(); onEdit?.() }}
>
</Text>
)}
<Text className="text-xs text-gray-200">|</Text>
{onDelete && (
<Text
className="text-xs text-gray-400"
onClick={(e) => { e.stopPropagation(); onDelete?.() }}
>
</Text>
)}
</View>
</View>
)}
</View>
)
}
export default AddressCard

View File

@@ -0,0 +1,113 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
import { getCouponTypeText, formatCouponValue, isExpiringSoon } from '@/hooks/useCoupon'
interface CouponCardProps {
coupon: ShopUserCoupon
disabled?: boolean
onClick?: () => void
showDelete?: boolean
onDelete?: () => void
}
const CouponCard: React.FC<CouponCardProps> = ({
coupon,
disabled = false,
onClick,
showDelete = false,
onDelete,
}) => {
const isUsed = coupon.status === 1
const isExpired = coupon.status === 2 || coupon.isExpire === 1
const expiringSoon = isExpiringSoon(coupon)
const getValidTime = () => {
if (coupon.startTime && coupon.endTime) {
return `${coupon.startTime.slice(0, 10)} - ${coupon.endTime.slice(0, 10)}`
}
return '永久有效'
}
const getRangeText = () => {
switch (coupon.applyRange) {
case 10: return '全场通用'
case 20: return '指定商品'
case 30: return '指定分类'
default: return '全场通用'
}
}
return (
<View
className={`bg-white rounded-lg mb-3 overflow-hidden ${disabled ? 'opacity-60' : ''}`}
onClick={disabled ? undefined : onClick}
>
<View className='flex'>
{/* 左侧金额区域 */}
<View className={`w-24 py-4 flex flex-col items-center justify-center ${isUsed || isExpired ? 'bg-gray-200' : (
coupon.type === 40 ? 'bg-gradient-to-br from-blue-500 to-blue-600' :
coupon.type === 50 ? 'bg-gradient-to-br from-cyan-500 to-teal-500' :
'bg-gradient-to-br from-red-500 to-orange-500'
)}`}>
<Text className='text-white text-lg font-bold'>{formatCouponValue(coupon)}</Text>
{(() => {
if (coupon.type === 50) {
// 场地使用券
const parts: string[] = []
if (coupon.useDuration && coupon.useDuration > 0) parts.push(`${coupon.useDuration}分钟`)
return parts.length > 0 ? (
<Text className='text-white text-xs mt-1'>{parts.join(' | ')}</Text>
) : null
}
if (coupon.type === 40 || !coupon.minPrice || Number(coupon.minPrice) <= 0) return null
return <Text className='text-white text-xs mt-1'>{coupon.minPrice}</Text>
})()}
</View>
{/* 右侧信息区域 */}
<View className='flex-1 p-3'>
<View className='flex justify-between items-start'>
<View className='flex-1'>
<View className='flex items-center gap-2'>
<Text className='text-sm font-medium text-gray-800'>{coupon.name || getCouponTypeText(coupon.type)}</Text>
{expiringSoon && !isUsed && !isExpired && (
<Text className='text-xs text-orange-500 bg-orange-50 px-1 py-0 rounded'></Text>
)}
</View>
<Text className='text-xs text-gray-400 mt-1'>{getRangeText()}</Text>
<Text className='text-xs text-gray-400 mt-1'>{getValidTime()}</Text>
{coupon.description && (
<Text className='text-xs text-gray-500 mt-1 line-clamp-1'>{coupon.description}</Text>
)}
</View>
{/* 状态标签 */}
<View className='ml-2'>
{isUsed && (
<Text className='text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded'>使</Text>
)}
{isExpired && (
<Text className='text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded'></Text>
)}
{!isUsed && !isExpired && (
<View className='w-4 h-4 rounded-full border border-gray-300' />
)}
</View>
</View>
{/* 删除按钮 */}
{showDelete && (isUsed || isExpired) && (
<View className='flex justify-end mt-2 pt-2 border-t border-gray-100'>
<Text className='text-xs text-red-500' onClick={(e) => { e.stopPropagation(); onDelete?.() }}>
</Text>
</View>
)}
</View>
</View>
</View>
)
}
export default CouponCard

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useMemo } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
interface CouponSelectProps {
visible: boolean
coupons: ShopUserCoupon[]
minAmount?: number
selectedCouponId?: string
onClose: () => void
onSelect: (coupon: ShopUserCoupon | null) => void
}
const CouponSelect: React.FC<CouponSelectProps> = ({
visible,
coupons,
minAmount = 0,
selectedCouponId,
onClose,
onSelect,
}) => {
const [showOverlay, setShowOverlay] = React.useState(false)
const [slideUp, setSlideUp] = React.useState(false)
// 控制动画
React.useEffect(() => {
if (visible) {
setShowOverlay(true)
setTimeout(() => setSlideUp(true), 10)
} else {
setSlideUp(false)
setTimeout(() => setShowOverlay(false), 300)
}
}, [visible])
const handleClose = () => {
setSlideUp(false)
setTimeout(() => {
setShowOverlay(false)
onClose()
}, 300)
}
const handleSelect = (coupon: ShopUserCoupon | null) => {
handleClose()
onSelect(coupon)
}
const availableCoupons = useMemo(() => {
return coupons.filter(c => {
if (c.status !== 0 && c.status !== undefined) return false
if (c.endTime && new Date(c.endTime) < new Date()) return false
return (Number(c.minPrice) || 0) <= minAmount
})
}, [coupons, minAmount])
if (!showOverlay) return null
return (
<View className='coupon-select-overlay'>
{/* 遮罩层 */}
<View
className={`absolute inset-0 bg-black/50 z-50 ${slideUp ? 'opacity-100' : 'opacity-0'}`}
onClick={handleClose}
/>
{/* 弹窗内容 */}
<View
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl z-50 ${slideUp ? '' : 'hidden'}`}
style={{ maxHeight: '60vh' }}
>
<View className='p-4'>
{/* 关闭按钮 */}
<View className='flex justify-end mb-2'>
<View
className='w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center'
onClick={handleClose}
>
<Text className='text-gray-400 text-sm'>×</Text>
</View>
</View>
<View className='flex justify-between items-center mb-4'>
<Text className='text-base font-medium'></Text>
<Text
className='text-sm text-green-600'
onClick={() => handleSelect(null)}
>
使
</Text>
</View>
{availableCoupons.length === 0 ? (
<View className='py-8 text-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<ScrollView scrollY style={{ maxHeight: '50vh' }}>
{availableCoupons.map((coupon) => {
const isSelected = selectedCouponId === coupon.id
return (
<View
key={coupon.id}
className='flex rounded-lg border mb-2 overflow-hidden'
style={{
borderColor: isSelected ? (
coupon.type === 40 ? '#3b82f6' :
coupon.type === 50 ? '#06b6d4' :
'#0e932e'
) : '#f0f0f0',
backgroundColor: isSelected ? (
coupon.type === 40 ? '#eff6ff' :
coupon.type === 50 ? '#ecfeff' :
'#f0fdf4'
) : '#fff',
}}
onClick={() => handleSelect(coupon)}
>
<View className='w-24 flex flex-col items-center justify-center py-3' style={{
backgroundColor: coupon.type === 40 ? '#eff6ff' :
coupon.type === 50 ? '#ecfeff' :
'#f0fdf4'
}}>
{coupon.type === 20 ? (
<View className='text-center'>
<Text className='text-lg font-bold text-green-600 block'>
{coupon.discount || 0}
</Text>
<Text className='text-xs text-gray-500'>
{coupon.minPrice && Number(coupon.minPrice) > 0 ? `${coupon.minPrice}可用` : '无门槛'}
</Text>
</View>
) : coupon.type === 50 ? (
<View className='text-center'>
<Text className='text-lg font-bold text-teal-600 block'>
{coupon.useCount && coupon.useCount > 0 ? `${coupon.useCount}` : '不限次'}
</Text>
<Text className='text-xs text-gray-500'>
{coupon.useDuration && coupon.useDuration > 0 ? `${coupon.useDuration}分钟` : '场地使用'}
</Text>
</View>
) : (
<View className='text-center'>
<Text className='text-xs font-medium block' style={{
color: coupon.type === 40 ? '#3b82f6' : '#16a34a'
}}>¥</Text>
<Text className='text-xl font-bold block' style={{
color: coupon.type === 40 ? '#3b82f6' : '#16a34a'
}}>
{coupon.reducePrice || 0}
</Text>
<Text className='text-xs text-gray-500'>
{coupon.type === 40 ? '无门槛' :
(coupon.minPrice && Number(coupon.minPrice) > 0 ? `${coupon.minPrice}可用` : '无门槛')}
</Text>
</View>
)}
</View>
<View className='flex-1 p-3 flex flex-col justify-between'>
<Text className='text-sm text-gray-800 font-medium'>
{coupon.name || coupon.description || '优惠券'}
</Text>
<Text className='text-xs text-gray-400'>
{coupon.startTime?.slice(0, 10)} ~ {coupon.endTime?.slice(0, 10)}
</Text>
</View>
{isSelected && (
<View className='flex items-center pr-3'>
<Text className='text-green-500 text-lg'>{'✓'}</Text>
</View>
)}
</View>
)
})}
</ScrollView>
)}
</View>
</View>
</View>
)
}
export default CouponSelect

View File

@@ -0,0 +1,27 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
interface MemberBadgeProps {
levelName?: string
size?: 'small' | 'normal'
}
const MemberBadge: React.FC<MemberBadgeProps> = ({ levelName, size = 'normal' }) => {
if (!levelName) return null
const sizeClass = size === 'small' ? 'text-xs px-1 py-0' : 'text-sm px-2 py-1'
return (
<View
className={`inline-flex items-center rounded-full ${sizeClass}`}
style={{
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
color: '#78350f',
}}
>
<Text className='font-medium'>{levelName}</Text>
</View>
)
}
export default MemberBadge

View File

@@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import Price from '@/components/common/Price'
interface PayModalProps {
visible: boolean
amount: string
onClose: () => void
onConfirm: (payType: number) => void
}
const PAY_TYPES = [
{ id: 1, name: '微信支付', desc: '推荐使用', icon: 'pay-wechat' },
{ id: 0, name: '余额支付', desc: '使用账户余额', icon: 'pay-balance' },
]
const PayModal: React.FC<PayModalProps> = ({ visible, amount, onClose, onConfirm }) => {
const [selected, setSelected] = useState<number>(1)
const [showOverlay, setShowOverlay] = useState(false)
const [slideUp, setSlideUp] = useState(false)
// 控制动画
useEffect(() => {
if (visible) {
setShowOverlay(true)
setTimeout(() => setSlideUp(true), 10)
} else {
setSlideUp(false)
setTimeout(() => setShowOverlay(false), 300)
}
}, [visible])
const handleClose = () => {
setSlideUp(false)
setTimeout(() => {
setShowOverlay(false)
onClose()
}, 300)
}
const handleConfirm = () => {
handleClose()
onConfirm(selected)
}
if (!showOverlay) return null
return (
<View className='pay-modal-overlay'>
{/* 遮罩层 */}
<View
className={`absolute inset-0 bg-black/50 z-50 ${slideUp ? 'opacity-100' : 'opacity-0'}`}
onClick={handleClose}
/>
{/* 弹窗内容 */}
<View
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl z-50 ${slideUp ? '' : 'hidden'}`}
>
<View className='p-4'>
{/* 关闭按钮 */}
<View className='flex justify-end mb-2'>
<View
className='w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center'
onClick={handleClose}
>
<Text className='text-gray-400 text-sm'>×</Text>
</View>
</View>
<View className='text-center py-3 border-b border-gray-100'>
<Text className='text-base font-medium'></Text>
<View className='mt-2'>
<Price price={amount} size='large' />
</View>
</View>
<View className='py-3'>
{PAY_TYPES.map((item) => (
<View
key={item.id}
className='flex items-center justify-between py-3 px-2 rounded-lg mb-1'
style={{ backgroundColor: selected === item.id ? '#f0fdf4' : 'transparent' }}
onClick={() => setSelected(item.id)}
>
<View className='flex items-center gap-3'>
<View className='w-8 h-8 rounded-full bg-green-50 flex items-center justify-center'>
<Text className='text-sm'>
{item.id === 1 ? '微' : '余'}
</Text>
</View>
<View>
<Text className='text-sm text-gray-800'>{item.name}</Text>
<Text className='text-xs text-gray-400'>{item.desc}</Text>
</View>
</View>
<View
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selected === item.id ? 'border-green-500' : 'border-gray-300'
}`}
>
{selected === item.id && (
<View className='w-2 h-2 rounded-full bg-green-500' />
)}
</View>
</View>
))}
</View>
<View
className='w-full py-3 rounded-full text-center text-white text-sm font-medium mt-2'
style={{ backgroundColor: '#0e932e' }}
onClick={handleConfirm}
>
<Text className='text-white'></Text>
</View>
</View>
</View>
</View>
)
}
export default PayModal

View File

@@ -0,0 +1,342 @@
import React, { useState, useEffect, useMemo } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
import Price from '@/components/common/Price'
interface SkuSelectorProps {
visible: boolean
product: ShopGoods | null
mode?: 'cart' | 'buy'
onClose: () => void
onConfirm: (sku: ShopGoodsSku, quantity: number) => void
}
// 将平铺的规格值列表转换为按规格ID分组的格式
interface SpecGroup {
specId: number
specName: string
values: Array<{ specValueId: number; specValue: string }>
}
// 解析可能为JSON字符串的规格值
const parseSpecValue = (value: string | undefined): string => {
if (!value) return ''
const trimmed = value.trim()
// 处理带引号的JSON字符串如 "红色" -> 红色
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
try {
return JSON.parse(trimmed)
} catch {
return trimmed.slice(1, -1)
}
}
return trimmed
}
const SkuSelector: React.FC<SkuSelectorProps> = ({
visible,
product,
mode = 'cart',
onClose,
onConfirm,
}) => {
const [selectedSpecs, setSelectedSpecs] = useState<Record<number, number>>({})
const [selectedSku, setSelectedSku] = useState<ShopGoodsSku | null>(null)
const [quantity, setQuantity] = useState(1)
const [showOverlay, setShowOverlay] = useState(false)
const [slideUp, setSlideUp] = useState(false)
// 控制动画
useEffect(() => {
if (visible) {
setShowOverlay(true)
// 延迟一帧触发滑入动画
setTimeout(() => setSlideUp(true), 10)
} else {
setSlideUp(false)
setTimeout(() => setShowOverlay(false), 300)
}
}, [visible])
// 将平铺的goodsSpecs转换为按规格分组的格式
const specGroups = useMemo<SpecGroup[]>(() => {
if (!product?.goodsSpecs?.length) return []
const groupMap = new Map<number, SpecGroup>()
product.goodsSpecs.forEach((spec: ShopGoodsSpec, idx: number) => {
if (!groupMap.has(spec.specId!)) {
groupMap.set(spec.specId!, {
specId: spec.specId!,
specName: spec.specName || `规格${groupMap.size + 1}`,
values: []
})
}
groupMap.get(spec.specId!)!.values.push({
specValueId: spec.id || idx, // 优先使用规格值ID否则用索引
specValue: parseSpecValue(spec.specValue)
})
})
return Array.from(groupMap.values())
}, [product?.goodsSpecs])
// 当弹窗重新打开时,重置状态
useEffect(() => {
if (visible) {
setSelectedSpecs({})
setSelectedSku(null)
setQuantity(1)
}
}, [visible])
// 根据已选规格查找匹配的 SKU
useEffect(() => {
if (!product?.goodsSkus?.length) {
// 单规格商品,使用商品本身的价格
setSelectedSku(null)
return
}
const specValues = Object.values(selectedSpecs)
const specCount = specGroups.length
if (specValues.length < specCount) {
setSelectedSku(null)
return
}
// 根据选中的 specValueId 找到对应的中文值,拼接后排序匹配
const selectedValues: string[] = []
specGroups.forEach(group => {
const valueId = selectedSpecs[group.specId]
if (valueId !== undefined) {
const value = group.values.find(v => v.specValueId === valueId)
if (value) selectedValues.push(value.specValue)
}
})
const selectedStr = selectedValues.sort().join('|')
const matched = product.goodsSkus.find(sku => {
const skuValue = sku.sku || ''
const skuParts = skuValue.split('|').map(s => parseSpecValue(s.trim()))
const skuStr = skuParts.sort().join('|')
return skuStr === selectedStr
})
setSelectedSku(matched || null)
}, [selectedSpecs, product, specGroups])
const handleSpecClick = (specId: number, valueId: number) => {
setSelectedSpecs(prev => {
const next = { ...prev }
if (next[specId] === valueId) {
delete next[specId]
} else {
next[specId] = valueId
}
return next
})
setQuantity(1)
}
const handleClose = () => {
setSlideUp(false)
setTimeout(() => {
setShowOverlay(false)
onClose()
}, 300)
}
const handleConfirm = () => {
try {
// 单规格商品无SKU列表 或 SKU列表为空数组
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
const fakeSku: ShopGoodsSku = {
id: 0,
goodsId: product.goodsId!,
price: product.salePrice || product.price,
salePrice: product.salePrice || product.price,
stock: product.stock,
image: product.image,
}
handleClose()
onConfirm(fakeSku, quantity)
return
}
// 多规格商品 - 必须已选择 SKU
if (selectedSku) {
handleClose()
onConfirm(selectedSku, quantity)
return
}
// 有规格但未选择完整
if (specGroups.length > 0) {
Taro.showToast({ title: '请选择完整的规格', icon: 'none' })
return
}
// 其他情况
Taro.showToast({ title: '无法添加商品', icon: 'none' })
} catch (err) {
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
const currentPrice = selectedSku?.salePrice || selectedSku?.price || product?.salePrice || product?.price || '0'
const currentStock = selectedSku?.stock ?? product?.stock ?? 0
const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[0]) || ''
// 单规格商品可以确认,或者多规格商品已选择了 SKU
const canConfirm = (!product?.goodsSkus || product.goodsSkus.length === 0) || selectedSku !== null
// 获取未选择完整的规格提示
const getUnselectedSpec = () => {
if (specGroups.length === 0) return null
const unselected = specGroups.find(
spec => !selectedSpecs[spec.specId]
)
return unselected?.specName
}
// 获取选中的规格文字描述
const getSelectedText = () => {
const selected: string[] = []
specGroups.forEach(group => {
const valueId = selectedSpecs[group.specId]
if (valueId !== undefined) {
const value = group.values.find(v => v.specValueId === valueId)
if (value) selected.push(value.specValue)
}
})
return selected.length > 0 ? selected.join(', ') : '默认'
}
// 如果不需要显示直接返回null
if (!showOverlay) return null
return (
<View className='sku-selector-overlay'>
{/* 遮罩层 */}
<View
className={`absolute inset-0 bg-black/50 ${slideUp ? 'opacity-100' : 'opacity-0'}`}
style={{ zIndex: 110 }}
onClick={handleClose}
/>
{/* 弹窗内容 */}
<View
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl ${slideUp ? '' : 'hidden'}`}
style={{ maxHeight: '70vh', zIndex: 110, paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<View className='p-4'>
{/* 关闭按钮 */}
<View className='flex justify-end mb-2'>
<View
className='w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center'
onClick={handleClose}
>
<Text className='text-gray-400 text-sm'>×</Text>
</View>
</View>
{/* 商品信息 */}
<View className='flex gap-3 pb-4 border-b border-gray-100'>
<Image className='w-20 h-20 rounded-md bg-gray-100' src={currentImage} mode='aspectFill' />
<View className='flex-1'>
<Price price={currentPrice} size='large' />
<Text className='text-xs text-gray-500 block' style={{ marginTop: '4px' }}>: {currentStock}</Text>
<Text className='text-xs text-gray-400 block' style={{ marginTop: '2px' }}>
: {getSelectedText()}
</Text>
</View>
</View>
{/* 规格选择 */}
<ScrollView scrollY style={{ maxHeight: '40vh' }}>
{specGroups.map((group) => (
<View key={group.specId} className='py-3 border-b border-gray-50'>
<Text className='text-sm font-medium text-gray-700 block' style={{ marginBottom: '8px' }}>
{group.specName}
</Text>
<View className='flex flex-wrap' style={{ gap: '8px' }}>
{group.values.map((value) => {
const isActive = selectedSpecs[group.specId] === value.specValueId
return (
<View
key={value.specValueId}
className={`px-3 py-1 rounded-full text-sm ${
isActive
? 'bg-green-50 text-green-600 border border-green-500'
: 'bg-gray-50 text-gray-600 border border-gray-200'
}`}
onClick={() => handleSpecClick(group.specId, value.specValueId)}
>
<Text className={isActive ? 'text-green-600' : 'text-gray-600'}>{value.specValue}</Text>
</View>
)
})}
</View>
</View>
))}
{/* 无规格时显示提示 */}
{specGroups.length === 0 && product?.goodsSkus?.length === 0 && (
<View className='py-4 text-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)}
</ScrollView>
{/* 数量 */}
<View className='py-3 flex items-center justify-between'>
<Text className='text-sm text-gray-700'></Text>
<View className='flex items-center' style={{ gap: '12px' }}>
<View
className={`w-7 h-7 rounded-full border flex items-center justify-center ${
quantity <= 1 ? 'border-gray-200 text-gray-300' : 'border-gray-300 text-gray-500'
}`}
onClick={() => quantity > 1 && setQuantity(prev => prev - 1)}
>
<Text className={quantity <= 1 ? 'text-gray-300' : 'text-gray-500'}>-</Text>
</View>
<Text className='text-sm font-medium w-8 text-center'>{quantity}</Text>
<View
className={`w-7 h-7 rounded-full border flex items-center justify-center ${
quantity >= currentStock ? 'border-gray-200 text-gray-300' : 'border-gray-300 text-gray-500'
}`}
onClick={() => quantity < currentStock && setQuantity(prev => prev + 1)}
>
<Text className={quantity >= currentStock ? 'text-gray-300' : 'text-gray-500'}>+</Text>
</View>
</View>
</View>
{/* 确认按钮 */}
<View className='pt-3'>
<View
className={`w-full py-3 rounded-full text-center text-sm font-medium ${
canConfirm ? 'text-white' : 'text-white/60'
}`}
style={{
backgroundColor: canConfirm ? '#0e932e' : '#ccc',
opacity: canConfirm ? 1 : 0.7
}}
onClick={canConfirm ? handleConfirm : undefined}
>
<Text className={canConfirm ? 'text-white' : 'text-white/60'}>
{getUnselectedSpec() ? `请选择${getUnselectedSpec()}` : (mode === 'cart' ? '加入购物车' : '立即购买')}
</Text>
</View>
</View>
</View>
</View>
</View>
)
}
export default SkuSelector

View File

@@ -0,0 +1,59 @@
/* 底部悬浮按钮样式 */
.bottom-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #ffffff;
padding: 12PX 16PX;
padding-bottom: 24PX;
border-top: 1PX solid #f0f0f0;
}
.bottom-btn-subtitle {
text-align: center;
margin-bottom: 8px;
}
.subtitle-text {
font-size: 14px;
color: #666666;
}
.subtitle-price {
color: #ff6600;
font-weight: 500;
}
.bottom-btn {
width: 100%;
height: 88rpx;
background-color: #0e932e;
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
border-radius: 44rpx;
border: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 88rpx;
}
.bottom-btn::after {
border: none;
}
.bottom-btn[disabled] {
background-color: #cccccc;
color: #ffffff;
}
.bottom-btn[loading] {
background-color: #0e932e;
opacity: 0.8;
}

View File

@@ -0,0 +1,42 @@
import React from 'react'
import { View, Button, Text } from '@tarojs/components'
import styles from './index.scss'
interface BottomButtonProps {
text?: string
onClick?: () => void
disabled?: boolean
loading?: boolean
color?: string
subtitle?: string
}
const BottomButton: React.FC<BottomButtonProps> = ({
text = '确认',
onClick,
disabled = false,
loading = false,
color = '#0e932e',
subtitle
}) => {
return (
<View className='px-4 fixed bottom-5 w-full bg-white'>
{subtitle && (
<View className='bottom-btn-subtitle'>
<Text className='text-sm text-gray-500'>{subtitle}</Text>
</View>
)}
<Button
className='bottom-btn text-white rounded-full'
disabled={disabled || loading}
loading={loading}
onClick={onClick}
style={{ backgroundColor: color, borderColor: color }}
>
{loading ? '加载中...' : text}
</Button>
</View>
)
}
export default BottomButton

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button } from '@nutui/nutui-react-taro'
interface EmptyStateProps {
image?: string
text?: string
description?: string
actionText?: string
onAction?: () => void
}
const DEFAULT_IMAGE = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE0IiBmaWxsPSIjY2NjIj7or77nqIvliY3liIY8L3RleHQ+PC9zdmc+'
const EmptyState: React.FC<EmptyStateProps> = ({
image = DEFAULT_IMAGE,
text = '暂无数据',
description,
actionText,
onAction,
}) => {
return (
<View className='flex flex-col items-center justify-center py-12'>
<View className='w-24 h-24 mb-4 rounded-full overflow-hidden'>
<View
className='w-full h-full bg-cover bg-center bg-no-repeat'
style={{ backgroundImage: `url(${image})` }}
/>
</View>
<Text className='text-sm text-gray-500'>{text}</Text>
{description && (
<Text className='text-xs text-gray-400 mt-1'>{description}</Text>
)}
{actionText && onAction && (
<Button
type='primary'
size='small'
className='mt-4 rounded-full px-6'
onClick={onAction}
>
{actionText}
</Button>
)}
</View>
)
}
export default EmptyState

View File

@@ -0,0 +1,43 @@
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
}
/**
* 底部固定按钮组件
* 用于地址列表"新增地址"、地址编辑"保存"等底部固定操作
*/
const FixedButton: React.FC<FixedButtonProps> = ({ text, onClick, icon, disabled, background }) => {
return (
<>
{/* 底部安全区域占位 */}
<View className="h-20 w-full" />
<View
className="z-50 bg-white border-t border-gray-200 px-4 py-3"
style={{ paddingBottom: '12px', flexShrink: 0 }}
>
<Button
type="primary"
style={{ background }}
size="large"
block
icon={icon}
disabled={disabled}
className="px-6"
onClick={onClick}
>
{text || '确定'}
</Button>
</View>
</>
)
}
export default FixedButton

View File

@@ -0,0 +1,57 @@
import { View, Image, Text } from '@tarojs/components';
import { useState, useEffect } from 'react';
import { useIntersectionObserver } from '@tarojs/taro';
interface LazyImageProps {
src: string;
placeholder?: string;
className?: string;
style?: React.CSSProperties;
mode?: 'scaleToFill' | 'aspectFit' | 'aspectFill' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'center' | 'left' | 'right' | 'top left' | 'top right' | 'bottom left' | 'bottom right';
}
export default function LazyImage({ src, placeholder, className, style, mode = 'aspectFill' }: LazyImageProps) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(false);
const { intersectionObserver } = useIntersectionObserver();
useEffect(() => {
if (!intersectionObserver) return;
intersectionObserver.relativeToViewport();
intersectionObserver.observe({
type: 'relativeToViewport',
success: (res) => {
if (res.intersectionRatio > 0) {
setInView(true);
}
}
});
return () => {
intersectionObserver.disconnect();
};
}, [intersectionObserver]);
return (
<View className={className} style={style}>
{!loaded && (
<View className="w-full h-full bg-gray-200 flex items-center justify-center">
{placeholder ? (
<Image src={placeholder} className="w-full h-full" mode={mode} />
) : (
<Text className="text-gray-400 text-sm">...</Text>
)}
</View>
)}
{(inView || loaded) && (
<Image
src={src}
className={`w-full h-full ${loaded ? 'block' : 'hidden'}`}
mode={mode}
onLoad={() => setLoaded(true)}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
interface LoadMoreProps {
loading?: boolean
finished?: boolean
}
const LoadMore: React.FC<LoadMoreProps> = ({ loading = false, finished = false }) => {
if (finished) {
return (
<View className='py-4 flex justify-center'>
<Text className='text-xs text-gray-400'>- -</Text>
</View>
)
}
if (loading) {
return (
<View className='py-4 flex justify-center'>
<Text className='text-xs text-gray-400'>...</Text>
</View>
)
}
return null
}
export default LoadMore

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
interface LoadingProps {
/** 提示文字,默认"加载中..." */
text?: string
/** 是否全屏居中显示 */
fullscreen?: boolean
/** 自定义样式类名 */
className?: string
}
/**
* 统一加载状态组件
* - 小程序兼容(无 CSS transition / animation 依赖)
* - 支持内嵌和全屏两种模式
*/
const Loading: React.FC<LoadingProps> = ({
text = '加载中...',
fullscreen = false,
className = '',
}) => {
const content = (
<View
className={`flex flex-col items-center justify-center py-8 ${fullscreen ? 'fixed inset-0 z-50 bg-white/80' : ''} ${className}`}
>
{/* 简单的旋转加载指示器 */}
<View
className='w-7 h-7 rounded-full border-2 border-gray-200 mb-2'
style={{
borderTopColor: '#0e932e',
borderRightColor: '#0e932e',
animation: 'spin 0.8s linear infinite' as unknown as string,
}}
/>
<Text className='text-xs text-gray-400'>{text}</Text>
</View>
)
// 全屏模式需要外层容器
if (fullscreen) {
return <>{content}</>
}
return content
}
export default Loading

View File

@@ -0,0 +1,137 @@
import React from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
import Price from '../Price'
interface OrderCardProps {
order: ShopOrder
onClick?: () => void
}
/** 综合 payStatus + deliveryStatus + orderStatus 计算列表卡片状态文案 */
const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
const { payStatus, deliveryStatus, orderStatus } = order
if (orderStatus === 2) return { text: '已取消', color: '#999' }
if (orderStatus === 3) return { text: '取消中', color: '#ff7d00' }
if (orderStatus === 6) return { text: '已退款', color: '#999' }
if (orderStatus === 4 || orderStatus === 7) return { text: '退款申请中', color: '#ee0a24' }
if (orderStatus === 5) return { text: '退款被拒绝', color: '#ee0a24' }
if (!payStatus) return { text: '待付款', color: '#ee0a24' }
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
if (deliveryStatus === 20 || deliveryStatus === 30) return { text: '待收货', color: '#4b9cf5' }
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
return { text: '已付款', color: '#0e932e' }
}
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
const handleClick = () => {
if (onClick) {
onClick()
return
}
Taro.navigateTo({ url: `/pages/order/detail?id=${order.orderId}` })
}
const { text: statusText, color: statusColor } = getCardStatus(order)
// 获取首个商品图片作为封面,或显示默认占位
const coverImage = order.orderGoods?.[0]?.image || ''
const hasGoods = (order.orderGoods?.length || 0) > 0
return (
<View className='bg-white rounded-lg p-3 mb-3 shadow-sm' onClick={handleClick}>
{/* 头部:订单号 + 状态 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-sm font-medium text-gray-800'>
: {order.orderNo}
</Text>
<Text className='text-sm font-medium' style={{ color: statusColor }}>
{statusText}
</Text>
</View>
{/* 商品信息区域 */}
{hasGoods ? (
<View className='mb-3'>
{order.orderGoods?.map((item, idx) => (
<View key={idx} className='flex flex-row items-start mb-2 last:mb-0'>
{/* 商品缩略图 */}
<View className='w-16 h-16 rounded-md bg-gray-100 flex-shrink-0 overflow-hidden'>
{item.image ? (
<Image
className='w-full h-full'
src={item.image}
mode='aspectFill'
/>
) : (
<View className='w-full h-full flex items-center justify-center'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
{/* 商品信息 */}
<View className='flex-1 ml-2 flex flex-col justify-between h-16'>
<View>
<Text className='text-sm text-gray-800 font-medium line-clamp-1'>
{item.goodsName || '未知商品'}
</Text>
{item.spec ? (
<Text className='text-xs text-gray-500 mt-1'>
: {item.spec}
</Text>
) : null}
</View>
<View className='flex justify-between items-center'>
<Price price={item.price || '0'} size='small' />
<Text className='text-xs text-gray-500'>
x{item.totalNum || 1}
</Text>
</View>
</View>
</View>
))}
</View>
) : (
/* 无商品信息时的 fallback */
<View className='flex flex-row items-start mb-3'>
<View className='w-16 h-16 rounded-md bg-gray-100 flex-shrink-0 flex items-center justify-center overflow-hidden'>
{coverImage ? (
<Image className='w-full h-full' src={coverImage} mode='aspectFill' />
) : (
<Text className='text-xs text-gray-400'></Text>
)}
</View>
<View className='flex-1 ml-2 flex flex-col justify-center h-16'>
<Text className='text-sm text-gray-800 font-medium line-clamp-2'>
{order.title || '订单商品'}
</Text>
<Text className='text-xs text-gray-500 mt-1'>
{order.totalNum || 0}
</Text>
</View>
</View>
)}
{/* 底部:日期 + 金额汇总 */}
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
<Text className='text-xs text-gray-500'>
{order.createTime?.slice(0, 10)}
</Text>
<View className='flex items-center'>
<Text className='text-xs text-gray-500 mr-1'>
{order.totalNum || order.orderGoods?.reduce((sum, g) => sum + (g.totalNum || 1), 0) || 0}
</Text>
<Text className='text-xs text-gray-500 mr-1'></Text>
<Price price={order.payPrice || order.totalPrice || '0'} size='small' />
</View>
</View>
</View>
)
}
export default OrderCard

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
interface PriceProps {
price: string | number
original?: string | number
size?: 'small' | 'normal' | 'large'
color?: string
symbol?: string
}
const Price: React.FC<PriceProps> = ({
price,
original,
size = 'normal',
color = '#ee0a24',
symbol = '¥',
}) => {
const fmt = (p: string | number) => {
const num = typeof p === 'string' ? parseFloat(p) : p
return isNaN(num) ? '0.00' : num.toFixed(2)
}
const sizeMap = {
small: { symbol: 'text-xs', integer: 'text-sm font-medium', decimal: 'text-xs' },
normal: { symbol: 'text-sm', integer: 'text-lg font-bold', decimal: 'text-sm' },
large: { symbol: 'text-lg', integer: 'text-2xl font-bold', decimal: 'text-lg' },
}
const cls = sizeMap[size]
return (
<View className='flex items-baseline'>
<Text className={`${cls.symbol} font-medium`} style={{ color }}>{symbol}</Text>
<Text className={cls.integer} style={{ color }}>{fmt(price).split('.')[0]}</Text>
<Text className={cls.decimal} style={{ color }}>.{fmt(price).split('.')[1]}</Text>
{original && (
<Text className='text-xs text-gray-400 line-through ml-1'>¥{fmt(original)}</Text>
)}
</View>
)
}
export default Price

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import Price from '../Price'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
interface ProductCardProps {
product: ShopGoods
onClick?: () => void
}
const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
const handleClick = () => {
if (onClick) {
onClick()
return
}
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${product.goodsId}` })
}
return (
<View className='bg-white rounded-lg overflow-hidden shadow-sm' onClick={handleClick}>
<View className='w-full' style={{ paddingTop: '100%', position: 'relative' }}>
<Image
className='absolute top-0 left-0 w-full h-full'
src={product.image || product.files || ''}
mode='aspectFill'
lazyLoad
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
/>
</View>
<View className='p-2'>
<Text className='text-sm text-gray-800 line-clamp-2 leading-5'>
{product.name || product.goodsName}
</Text>
<View className='flex items-end justify-between mt-2'>
<View className='flex-1'>
<Price price={product.price || '0'} size='small' />
{product.salePrice && product.salePrice !== product.price && (
<Text className='text-xs text-gray-400 line-through ml-1'>
¥{product.salePrice}
</Text>
)}
</View>
{product.sales !== undefined && product.sales > 0 && (
<Text className='text-xs text-gray-400'>
{product.sales}
</Text>
)}
</View>
</View>
</View>
)
}
export default ProductCard

View File

@@ -0,0 +1,69 @@
import { View, ScrollView } from '@tarojs/components';
import { useState, useEffect, useRef, useCallback } from 'react';
interface VirtualListProps<T> {
data: T[];
itemHeight: number;
containerHeight: number;
renderItem: (item: T, index: number) => JSX.Element;
keyExtractor: (item: T, index: number) => string | number;
onEndReached?: () => void;
onEndReachedThreshold?: number;
}
export default function VirtualList<T>({
data,
itemHeight,
containerHeight,
renderItem,
keyExtractor,
onEndReached,
onEndReachedThreshold = 0
}: VirtualListProps<T>) {
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(Math.ceil(containerHeight / itemHeight) + 5);
const scrollViewRef = useRef<any>(null);
const visibleItemCount = Math.ceil(containerHeight / itemHeight) + 10;
const offsetY = startIndex * itemHeight;
const handleScroll = useCallback((e: any) => {
const scrollTop = e.detail.scrollTop;
const newStartIndex = Math.floor(scrollTop / itemHeight);
const newEndIndex = newStartIndex + visibleItemCount;
setStartIndex(Math.max(0, newStartIndex - 5));
setEndIndex(Math.min(data.length, newEndIndex + 5));
// 触底加载
if (onEndReached && onEndReachedThreshold > 0) {
const { scrollHeight, clientHeight } = e.detail;
if (scrollHeight - scrollTop - clientHeight < onEndReachedThreshold) {
onEndReached();
}
}
}, [itemHeight, visibleItemCount, data.length, onEndReached, onEndReachedThreshold]);
const visibleData = data.slice(startIndex, endIndex);
return (
<ScrollView
ref={scrollViewRef}
className="w-full"
style={{ height: containerHeight }}
scrollY
onScroll={handleScroll}
scrollWithAnimation
>
<View style={{ height: data.length * itemHeight, position: 'relative' }}>
<View style={{ transform: `translateY(${offsetY}px)` }}>
{visibleData.map((item, index) => (
<View key={keyExtractor(item, startIndex + index)} style={{ height: itemHeight }}>
{renderItem(item, startIndex + index)}
</View>
))}
</View>
</View>
</ScrollView>
);
}