feat(address): 新增收货地址与售后申请功能
- 新增地址编辑页面,支持地址智能识别、地图选点、地区选择和默认地址设置 - 实现地址列表页面,支持地址查看、删除、设为默认及选择返回结算页 - 新增售后申请页面,支持退款类型选择、商品选择、原因填写、图片上传和提交审核 - 修复 passport 分包配置,移除不存在分包并补充缺失声明,避免 Taro 编译报错 - 新增地址类型定义,增强前端地址数据结构类型安全 - 优化页面交互体验,完善表单校验及错误提示逻辑 - 统一代码格式与命名规范,保持代码风格一致性
This commit is contained in:
87
src/components/ErrorBoundary.scss
Normal file
87
src/components/ErrorBoundary.scss
Normal 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;
|
||||
}
|
||||
75
src/components/ErrorBoundary.tsx
Normal file
75
src/components/ErrorBoundary.tsx
Normal 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
|
||||
33
src/components/NavBar/index.tsx
Normal file
33
src/components/NavBar/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
interface NavBarProps {
|
||||
title: string;
|
||||
onBack?: () => void;
|
||||
rightText?: string;
|
||||
onRightClick?: () => void;
|
||||
}
|
||||
|
||||
export default function NavBar({ title, onBack, rightText, onRightClick }: NavBarProps) {
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
Taro.navigateBack({ delta: 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>
|
||||
);
|
||||
}
|
||||
135
src/components/QRLoginScanner.tsx
Normal file
135
src/components/QRLoginScanner.tsx
Normal 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
|
||||
204
src/components/SharePoster/index.tsx
Normal file
204
src/components/SharePoster/index.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { Canvas, View } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { generateShareCode } from '@/api/share'
|
||||
|
||||
export interface PosterOptions {
|
||||
/** 封面图(网络地址或本地路径) */
|
||||
cover?: string
|
||||
/** 主标题 */
|
||||
title: string
|
||||
/** 副标题 / 描述 */
|
||||
subtitle?: string
|
||||
/** 价格(仅展示用) */
|
||||
price?: string | number
|
||||
/** 小程序码落地页,如 pages/shop/product-detail */
|
||||
page?: string
|
||||
}
|
||||
|
||||
export interface SharePosterHandle {
|
||||
/** 生成海报,返回临时图片路径,可直接作为分享 imageUrl */
|
||||
generate: (opts: PosterOptions) => Promise<string>
|
||||
}
|
||||
|
||||
// 逻辑尺寸(CSS px),实际位图按 dpr 放大
|
||||
const W = 300
|
||||
const H = 420
|
||||
const CANVAS_ID = 'sharePosterCanvas'
|
||||
|
||||
const SharePoster = forwardRef<SharePosterHandle>((_, ref) => {
|
||||
const { user } = useUser()
|
||||
const inviterId = user?.id ?? user?.userId
|
||||
|
||||
const getCanvasNode = (): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.createSelectorQuery()
|
||||
.select('#' + CANVAS_ID)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const node = res && res[0] && res[0].node
|
||||
if (node) resolve(node)
|
||||
else reject(new Error('未找到 canvas 节点'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const loadImage = (canvas: any, src: string): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!src) return reject(new Error('空图片地址'))
|
||||
const img = canvas.createImage()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = (e: any) => reject(e)
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
const drawRoundRect = (
|
||||
ctx: any,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
) => {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r)
|
||||
ctx.arcTo(x, y + h, x, y, r)
|
||||
ctx.arcTo(x, y, x + w, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
const drawRoundImage = (
|
||||
ctx: any,
|
||||
img: any,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
) => {
|
||||
ctx.save()
|
||||
drawRoundRect(ctx, x, y, w, h, r)
|
||||
ctx.clip()
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** 文本按宽度换行,最多 maxLines 行 */
|
||||
const wrapText = (ctx: any, text: string, maxWidth: number, maxLines: number): string[] => {
|
||||
const chars = (text || '').split('')
|
||||
const lines: string[] = []
|
||||
let line = ''
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i]
|
||||
if (ctx.measureText(line + ch).width > maxWidth && line) {
|
||||
lines.push(line)
|
||||
line = ch
|
||||
if (lines.length === maxLines - 1) break
|
||||
} else {
|
||||
line += ch
|
||||
}
|
||||
}
|
||||
if (lines.length < maxLines) lines.push(line)
|
||||
else if (line) lines[maxLines - 1] = lines[maxLines - 1] + '…'
|
||||
return lines
|
||||
}
|
||||
|
||||
const generate = async (opts: PosterOptions): Promise<string> => {
|
||||
const canvas = await getCanvasNode()
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = Taro.getSystemInfoSync().pixelRatio || 2
|
||||
canvas.width = W * dpr
|
||||
canvas.height = H * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// 封面图
|
||||
const coverX = 16
|
||||
const coverY = 16
|
||||
const coverW = W - 32
|
||||
const coverH = 200
|
||||
if (opts.cover) {
|
||||
try {
|
||||
const coverImg = await loadImage(canvas, opts.cover)
|
||||
drawRoundImage(ctx, coverImg, coverX, coverY, coverW, coverH, 12)
|
||||
} catch {
|
||||
ctx.fillStyle = '#f2f2f2'
|
||||
ctx.fillRect(coverX, coverY, coverW, coverH)
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = '#f2f2f2'
|
||||
ctx.fillRect(coverX, coverY, coverW, coverH)
|
||||
}
|
||||
|
||||
// 主标题
|
||||
ctx.fillStyle = '#222222'
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
const titleLines = wrapText(ctx, opts.title || '', coverW - 8, 2)
|
||||
let ty = coverY + coverH + 30
|
||||
titleLines.forEach((ln) => {
|
||||
ctx.fillText(ln, coverX, ty)
|
||||
ty += 22
|
||||
})
|
||||
|
||||
// 价格
|
||||
let py = ty + 6
|
||||
if (opts.price !== undefined && opts.price !== '' && opts.price !== null) {
|
||||
ctx.fillStyle = '#ee0a24'
|
||||
ctx.font = 'bold 20px sans-serif'
|
||||
ctx.fillText('¥' + opts.price, coverX, py)
|
||||
py += 28
|
||||
}
|
||||
|
||||
// 副标题
|
||||
if (opts.subtitle) {
|
||||
ctx.fillStyle = '#999999'
|
||||
ctx.font = '12px sans-serif'
|
||||
const subLines = wrapText(ctx, opts.subtitle, coverW, 2)
|
||||
let sy = py
|
||||
subLines.forEach((ln) => {
|
||||
ctx.fillText(ln, coverX, sy)
|
||||
sy += 16
|
||||
})
|
||||
}
|
||||
|
||||
// 小程序码
|
||||
const codeSize = 86
|
||||
const codeX = W - codeSize - 16
|
||||
const codeY = H - codeSize - 16
|
||||
try {
|
||||
const codeUrl = generateShareCode(opts.page || 'pages/index/index', inviterId)
|
||||
const dl = await Taro.downloadFile({ url: codeUrl })
|
||||
if (dl.statusCode === 200) {
|
||||
const codeImg = await loadImage(canvas, dl.tempFilePath)
|
||||
drawRoundImage(ctx, codeImg, codeX, codeY, codeSize, codeSize, 8)
|
||||
ctx.fillStyle = '#999999'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText('长按识别', codeX, codeY - 6)
|
||||
}
|
||||
} catch {
|
||||
// 小程序码生成失败不影响主海报
|
||||
}
|
||||
|
||||
const res = await Taro.canvasToTempFilePath({ canvas })
|
||||
return res.tempFilePath
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({ generate }))
|
||||
|
||||
return (
|
||||
<View style={{ position: 'fixed', left: -9999, top: 0, zIndex: -1 }}>
|
||||
<Canvas type='2d' id={CANVAS_ID} style={{ width: W + 'px', height: H + 'px' }} />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
SharePoster.displayName = 'SharePoster'
|
||||
|
||||
export default SharePoster
|
||||
121
src/components/business/AddressCard/index.tsx
Normal file
121
src/components/business/AddressCard/index.tsx
Normal 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
|
||||
113
src/components/business/CouponCard/index.tsx
Normal file
113
src/components/business/CouponCard/index.tsx
Normal 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
|
||||
185
src/components/business/CouponSelect/index.tsx
Normal file
185
src/components/business/CouponSelect/index.tsx
Normal 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
|
||||
27
src/components/business/MemberBadge/index.tsx
Normal file
27
src/components/business/MemberBadge/index.tsx
Normal 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
|
||||
122
src/components/business/PayModal/index.tsx
Normal file
122
src/components/business/PayModal/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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: 0, name: '货到付款', desc: '送达时支付', icon: 'pay-cod' },
|
||||
]
|
||||
|
||||
const PayModal: React.FC<PayModalProps> = ({ visible, amount, onClose, onConfirm }) => {
|
||||
const [selected, setSelected] = useState<number>(0)
|
||||
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 === 0 ? '货' : (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
|
||||
355
src/components/business/SkuSelector/index.tsx
Normal file
355
src/components/business/SkuSelector/index.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
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'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
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)
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
|
||||
// 控制动画
|
||||
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) {
|
||||
// VIP 会员使用 dealerPrice 作为结算价
|
||||
const vipPrice = isVip && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const fakeSku: ShopGoodsSku = {
|
||||
id: 0,
|
||||
goodsId: product.goodsId!,
|
||||
// price 字段是到手价(主价格),salePrice 是划掉的"市场价"
|
||||
price: vipPrice || product.price,
|
||||
salePrice: product.salePrice,
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
// 价格字段约定:
|
||||
// - product.salePrice / sku.salePrice : 划掉的"市场价"
|
||||
// - product.price / sku.price : 实际"到手价"(详情页主价格)
|
||||
// - product.dealerPrice : VIP 会员专享价
|
||||
// VIP 会员优先使用 dealerPrice
|
||||
const vipPrice = isVip ? (product?.dealerPrice || selectedSku?.price) : null
|
||||
const currentPrice = vipPrice || selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '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={getCompressedImageUrl(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
|
||||
42
src/components/common/ArrowRight/index.tsx
Normal file
42
src/components/common/ArrowRight/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { View } from '@tarojs/components'
|
||||
|
||||
/**
|
||||
* 右箭头图标(CSS V 形,颜色跟随 currentColor)
|
||||
*
|
||||
* 颜色通过 className 里的 text-* 类控制(如 'text-gray-400' / 'text-white'),
|
||||
* 因为内部用 currentColor,会自动继承父元素 text color。
|
||||
*
|
||||
* @example
|
||||
* <View className='flex items-center text-gray-400'>
|
||||
* <Text>全部订单</Text>
|
||||
* <ArrowRight className='ml-1' />
|
||||
* </View>
|
||||
*/
|
||||
export interface ArrowRightProps {
|
||||
/** 颜色/间距等 className(颜色用 text-* 类,跟随 currentColor) */
|
||||
className?: string
|
||||
/** V 形边长 px,默认 6 */
|
||||
size?: number
|
||||
/** 描边粗细 px,默认 1.5 */
|
||||
thickness?: number
|
||||
}
|
||||
|
||||
const ArrowRight: React.FC<ArrowRightProps> = ({
|
||||
className = '',
|
||||
size = 6,
|
||||
thickness = 1.5,
|
||||
}) => (
|
||||
<View
|
||||
className={`inline-flex items-center justify-center ${className}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderTop: `${thickness}px solid currentColor`,
|
||||
borderRight: `${thickness}px solid currentColor`,
|
||||
transform: 'rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
export default ArrowRight
|
||||
72
src/components/common/Badge/index.tsx
Normal file
72
src/components/common/Badge/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
/**
|
||||
* 数字角标
|
||||
* - count 为 0 / null / undefined 时不渲染
|
||||
* - count > max 时显示 `${max}+`(默认 99+)
|
||||
* - 通过 className 控制定位(通常配合父元素 relative + absolute -top-1 -right-1)
|
||||
*
|
||||
* @example
|
||||
* <View className='relative'>
|
||||
* <Text>📦</Text>
|
||||
* <Badge count={14} className='absolute -top-1 -right-1' />
|
||||
* </View>
|
||||
*
|
||||
* // 大尺寸 + 加粗(如门店中心卡片角标)
|
||||
* <Badge count={5} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />
|
||||
*/
|
||||
export interface BadgeProps {
|
||||
/** 数量;为 0 / null / undefined 时不渲染 */
|
||||
count?: number | null
|
||||
/** 超过此值显示 `${max}+`,默认 99 */
|
||||
max?: number
|
||||
/** 外层定位/样式 className(如 'absolute -top-1 -right-1') */
|
||||
className?: string
|
||||
/** 背景色,默认红色 #ef4444 */
|
||||
color?: string
|
||||
/** 字体大小 px,默认 10 */
|
||||
fontSize?: number
|
||||
/** 角标尺寸 px(min-width 与 height 同步),默认 16 */
|
||||
size?: number
|
||||
/** 字重,默认 'normal' */
|
||||
fontWeight?: 'normal' | 'medium' | 'bold'
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
count,
|
||||
max = 99,
|
||||
className = '',
|
||||
color = '#ef4444',
|
||||
fontSize = 10,
|
||||
size = 16,
|
||||
fontWeight = 'normal',
|
||||
}) => {
|
||||
// 0 / null / undefined / 负数 都不渲染
|
||||
const n = Number(count) || 0
|
||||
if (n <= 0) return null
|
||||
|
||||
const display = n > max ? `${max}+` : String(n)
|
||||
// 横向 padding 跟随 size:16→2px,20→3px,避免多位数字挤压
|
||||
const padding = Math.max(2, Math.round(size / 8))
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`rounded-full text-white flex items-center justify-center ${className}`}
|
||||
style={{
|
||||
background: color,
|
||||
minWidth: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${fontSize + 2}px`,
|
||||
fontWeight,
|
||||
paddingLeft: `${padding}px`,
|
||||
paddingRight: `${padding}px`,
|
||||
}}
|
||||
>
|
||||
<Text>{display}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge
|
||||
59
src/components/common/BottomButton/index.scss
Normal file
59
src/components/common/BottomButton/index.scss
Normal 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;
|
||||
}
|
||||
42
src/components/common/BottomButton/index.tsx
Normal file
42
src/components/common/BottomButton/index.tsx
Normal 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
|
||||
48
src/components/common/EmptyState/index.tsx
Normal file
48
src/components/common/EmptyState/index.tsx
Normal 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
|
||||
43
src/components/common/FixedButton/index.tsx
Normal file
43
src/components/common/FixedButton/index.tsx
Normal 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
|
||||
58
src/components/common/LazyImage/index.tsx
Normal file
58
src/components/common/LazyImage/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { View, Image, Text } from '@tarojs/components';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntersectionObserver } from '@tarojs/taro';
|
||||
import { getCompressedImageUrl } from '@/utils/image';
|
||||
|
||||
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={getCompressedImageUrl(src)}
|
||||
className={`w-full h-full ${loaded ? 'block' : 'hidden'}`}
|
||||
mode={mode}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
29
src/components/common/LoadMore/index.tsx
Normal file
29
src/components/common/LoadMore/index.tsx
Normal 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
|
||||
48
src/components/common/Loading/index.tsx
Normal file
48
src/components/common/Loading/index.tsx
Normal 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
|
||||
166
src/components/common/OrderCard/index.tsx
Normal file
166
src/components/common/OrderCard/index.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
interface OrderCardProps {
|
||||
order: ShopOrder
|
||||
onClick?: () => void
|
||||
onCloseOrder?: (order: ShopOrder) => void
|
||||
}
|
||||
|
||||
/** 综合 payStatus + deliveryStatus + orderStatus + payType 计算列表卡片状态文案 */
|
||||
const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
|
||||
const { payStatus, deliveryStatus, orderStatus, payType } = order
|
||||
const isCod = payType === 8 // 货到付款
|
||||
|
||||
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' }
|
||||
|
||||
// 终态:orderStatus=1 必须最先判断,盖过 deliveryStatus/payStatus
|
||||
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
|
||||
|
||||
// 货到付款(8):下单时后端已 setPayStatus(true),不会走到这里
|
||||
// 线下付款(9):保持 payStatus=false(待商家确认收款),应显示"待付款"与 Tab 一致
|
||||
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
|
||||
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 30) return { text: '已收货', color: '#0e932e' }
|
||||
|
||||
return { text: '已付款', color: '#0e932e' }
|
||||
}
|
||||
|
||||
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick, onCloseOrder }) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/order/detail?id=${order.orderId}` })
|
||||
}
|
||||
|
||||
const handleClose = (e: any) => {
|
||||
e?.stopPropagation?.()
|
||||
onCloseOrder?.(order)
|
||||
}
|
||||
|
||||
const { text: statusText, color: statusColor } = getCardStatus(order)
|
||||
|
||||
// 仅未付款/待确认收款的订单(payStatus=false)才显示取消按钮
|
||||
// 已付款的待发货订单需到详情页申请退款
|
||||
const showCloseButton = order.deliveryStatus === 10 && !order.payStatus && order.orderStatus !== 2 && order.orderStatus !== 3
|
||||
|
||||
// 获取首个商品图片作为封面,或显示默认占位
|
||||
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={getCompressedImageUrl(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={getCompressedImageUrl(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>
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
{showCloseButton && onCloseOrder && (
|
||||
<View className='flex justify-end mt-3 pt-2 border-t border-gray-50'>
|
||||
<View
|
||||
className='px-3 py-1 rounded-full border border-gray-300'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text className='text-xs text-gray-600'>取消订单</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderCard
|
||||
67
src/components/common/Price/index.tsx
Normal file
67
src/components/common/Price/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
|
||||
interface PriceProps {
|
||||
price: string | number
|
||||
original?: string | number
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
color?: string
|
||||
symbol?: string
|
||||
/**
|
||||
* 游客脱敏模式:
|
||||
* - 当用户未登录时,**不显示价格**,改成"登录后查看"占位文案
|
||||
* - 用于过微信审核:游客可浏览商品列表/详情,但价格需登录后可见
|
||||
* - 不传则维持原行为(游客也显示价格)
|
||||
*/
|
||||
loginMask?: boolean
|
||||
/**
|
||||
* 自定义脱敏文案(仅在 loginMask=true 且游客态下生效)
|
||||
*/
|
||||
maskText?: string
|
||||
}
|
||||
|
||||
const Price: React.FC<PriceProps> = ({
|
||||
price,
|
||||
original,
|
||||
size = 'normal',
|
||||
color = '#ee0a24',
|
||||
symbol = '¥',
|
||||
loginMask = false,
|
||||
maskText = '登录后查看价格',
|
||||
}) => {
|
||||
const fmt = (p: string | number) => {
|
||||
const num = typeof p === 'string' ? parseFloat(p) : p
|
||||
return isNaN(num) ? '0.00' : num.toFixed(2)
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: { mask: 'text-xs', symbol: 'text-xs', integer: 'text-sm font-medium', decimal: 'text-xs' },
|
||||
normal: { mask: 'text-sm', symbol: 'text-sm', integer: 'text-lg font-bold', decimal: 'text-sm' },
|
||||
large: { mask: 'text-base', symbol: 'text-lg', integer: 'text-2xl font-bold', decimal: 'text-lg' },
|
||||
}
|
||||
|
||||
const cls = sizeMap[size]
|
||||
|
||||
// 游客态 + 开启脱敏 -> 显示占位文案
|
||||
if (loginMask && isGuest()) {
|
||||
return (
|
||||
<View className='flex items-baseline'>
|
||||
<Text className={`${cls.mask} text-gray-400`}>{maskText}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
68
src/components/common/ProductCard/index.tsx
Normal file
68
src/components/common/ProductCard/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import Price from '../Price'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ShopGoods
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
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={getCompressedImageUrl(product.image || product.files || '')}
|
||||
mode='aspectFit'
|
||||
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={isVip && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
|
||||
{isVip && product.dealerPrice ? (
|
||||
// VIP 用户显示原价划掉
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{product.price}
|
||||
</Text>
|
||||
) : product.salePrice && product.salePrice !== product.price ? (
|
||||
isGuest() ? null : (
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{product.salePrice}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
{product.sales !== undefined && product.sales > 0 && (
|
||||
<Text className='text-xs text-gray-400'>
|
||||
已售{product.sales}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductCard
|
||||
69
src/components/common/VirtualList/index.tsx
Normal file
69
src/components/common/VirtualList/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user