feat(user): 新增收货地址管理及售后申请页面

- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
2026-07-01 12:11:56 +08:00
parent bf6ed504cc
commit 1fa58040f3
636 changed files with 58878 additions and 716 deletions

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useAddress } from '@/hooks/useAddress'
import AddressCard from '@/components/business/AddressCard'
definePageConfig({
navigationBarTitleText: '确认兑换',
})
const ExchangePage: React.FC = () => {
const { defaultAddress } = useAddress()
return (
<View className="bg-gray-100 flex flex-col" style={{ minHeight: '100vh' }}>
<View className='flex-1'>
{/* 收货地址 */}
<View
className="bg-white p-3"
onClick={() => Taro.navigateTo({ url: '/pages/user/address-list' })}
>
{defaultAddress ? (
<AddressCard address={defaultAddress} />
) : (
<View className="flex items-center justify-center py-6">
<Text className="text-sm text-gray-400"></Text>
<Text className="text-gray-300 ml-1"></Text>
</View>
)}
</View>
{/* 商品信息(确认页需从上一页传递数据,此处为占位) */}
<View className="bg-white mt-2 p-3">
<View className="flex gap-3 py-2">
<View className="w-16 h-16 rounded-md bg-orange-50 flex items-center justify-center">
<Text className="text-xs text-gray-400"></Text>
</View>
<View className="flex-1">
<Text className="text-sm text-gray-700 block mb-1"></Text>
<Text className="text-xs text-gray-400 block">x1</Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-bold text-orange-500">500 </Text>
</View>
</View>
</View>
{/* 费用明细 */}
<View className="bg-white mt-2 p-3">
<Text className="text-sm font-medium text-gray-800 mb-2 block"></Text>
<View className="flex justify-between py-1">
<Text className="text-sm text-gray-500"></Text>
<Text className="text-sm text-gray-700">500 </Text>
</View>
<View className="flex justify-between py-1">
<Text className="text-sm text-gray-500"></Text>
<Text className="text-sm text-green-600"></Text>
</View>
<View className="flex justify-between py-1 mt-1 border-t border-gray-50">
<Text className="text-sm font-medium text-gray-800"></Text>
<Text className="text-sm font-bold text-orange-500">500 </Text>
</View>
</View>
</View>
{/* 底部栏 */}
<View className="bg-white border-t border-gray-100 px-4 py-3 flex items-center justify-between" style={{ paddingBottom: '20px' }}>
<View>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-lg font-bold text-orange-500">500 </Text>
</View>
<View
className="px-8 py-2 rounded-full"
style={{ backgroundColor: '#f97316' }}
onClick={() => {
Taro.showToast({ title: '兑换成功', icon: 'success' })
setTimeout(() => Taro.navigateTo({ url: '/pages/order-list' }), 1500)
}}
>
<Text className="text-white font-medium text-sm"></Text>
</View>
</View>
</View>
)
}
export default ExchangePage

View File

@@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '积分兑换',
enablePullDownRefresh: true,
}

View File

@@ -0,0 +1,226 @@
import React, { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useRequest } from '@/hooks/useRequest'
import { listShopGoods, type ShopGoods } from '@/api/shop/shopGoods'
definePageConfig({
navigationBarTitleText: '积分兑换',
})
// 兑换分类
const categories = [
{ label: '全部', value: 0 },
{ label: '优惠券', value: 1 },
{ label: '实物商品', value: 2 },
{ label: '虚拟商品', value: 3 },
]
const ExchangePage: React.FC = () => {
const [activeCat, setActiveCat] = useState(0)
const [userPoints] = useState(1280) // 模拟用户积分
// 获取可兑换商品
const { data, loading } = useRequest(listShopGoods, {
defaultParams: [{ page: 1, limit: 20, exchangeType: 1 }], // exchangeType=1 表示积分兑换商品
onError: (err) => {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
}
})
// 模拟兑换商品数据实际应该从API获取
const exchangeProducts = [
{
id: 1,
name: '满100减10优惠券',
points: 500,
image: '',
stock: 100,
type: 1,
description: '积分兑换专属优惠券',
},
{
id: 2,
name: '满200减25优惠券',
points: 1000,
image: '',
stock: 50,
type: 1,
description: '积分兑换专属优惠券',
},
{
id: 3,
name: '品牌定制水杯',
points: 2000,
image: '',
stock: 30,
type: 2,
description: '高品质保温水杯,限量兑换',
},
{
id: 4,
name: '精美帆布袋',
points: 1500,
image: '',
stock: 50,
type: 2,
description: '环保帆布袋,时尚实用',
},
{
id: 5,
name: '视频会员月卡',
points: 3000,
image: '',
stock: 20,
type: 3,
description: '主流视频平台会员月卡',
},
]
// 筛选商品
const filteredProducts = activeCat === 0
? exchangeProducts
: exchangeProducts.filter(p => p.type === activeCat)
// 处理兑换
const handleExchange = (product: typeof exchangeProducts[0]) => {
if (userPoints < product.points) {
Taro.showToast({ title: '积分不足', icon: 'none' })
return
}
if (product.stock <= 0) {
Taro.showToast({ title: '库存不足', icon: 'none' })
return
}
Taro.showModal({
title: '确认兑换',
content: `确定使用 ${product.points} 积分兑换「${product.name}」吗?`,
confirmText: '确认兑换',
confirmColor: '#0e932e',
success: (res) => {
if (res.confirm) {
Taro.showLoading({ title: '兑换中...' })
// 这里应该调用兑换API
setTimeout(() => {
Taro.hideLoading()
Taro.showToast({ title: '兑换成功', icon: 'success' })
}, 1500)
}
}
})
}
// 获取类型标签
const getTypeLabel = (type: number) => {
const map: Record<number, string> = { 1: '优惠券', 2: '实物商品', 3: '虚拟商品' }
return map[type] || '其他'
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 用户积分信息 */}
<View className='p-4' style={{ background: 'linear-gradient(to right, #fb923c, #ef4444)' }}>
<View className='rounded-xl p-4' style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}>
<Text className='text-white text-sm block mb-2'></Text>
<View className='flex items-baseline gap-1'>
<Text className='text-white text-4xl font-bold'>{userPoints}</Text>
<Text className='text-white text-sm opacity-80'></Text>
</View>
<View className='flex gap-4 mt-3'>
<View className='flex-1 rounded-lg p-2 text-center' style={{ backgroundColor: 'rgba(255,255,255,0.3)' }}>
<Text className='text-white text-xs block'></Text>
<Text className='text-white text-sm font-bold block mt-1'>3 </Text>
</View>
<View className='flex-1 rounded-lg p-2 text-center' style={{ backgroundColor: 'rgba(255,255,255,0.3)' }}>
<Text className='text-white text-xs block'></Text>
<Text className='text-white text-sm font-bold block mt-1'></Text>
</View>
</View>
</View>
</View>
{/* 分类标签 */}
<View className='bg-white flex overflow-x-auto'>
{categories.map(cat => (
<View
key={cat.value}
className={`flex-shrink-0 px-4 py-3 relative ${
activeCat === cat.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveCat(cat.value)}
>
<Text className='text-sm'>{cat.label}</Text>
{activeCat === cat.value && (
<View className='absolute bottom-0 left-0 right-0 flex justify-center'>
<View className='w-8 h-px bg-orange-500 rounded' />
</View>
)}
</View>
))}
</View>
{/* 商品列表 */}
<ScrollView className='flex-1'>
{filteredProducts.length === 0 ? (
<View className='text-center py-16'>
<Text className='text-4xl mb-3 block'>🎁</Text>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<View className='p-3'>
{filteredProducts.map(product => {
const canExchange = userPoints >= product.points && product.stock > 0
return (
<View key={product.id} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
{/* 商品信息 */}
<View className='flex gap-3'>
{/* 商品图片 */}
<View className='w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0'>
<Text className='text-3xl'>🎁</Text>
</View>
{/* 商品详情 */}
<View className='flex-1'>
<Text className='text-sm text-gray-800 font-medium block mb-1'>{product.name}</Text>
<Text className='text-xs text-gray-400 block mb-2'>{product.description}</Text>
<View className='flex items-center gap-2'>
<View className='bg-orange-50 px-2 py-1 rounded'>
<Text className='text-xs text-orange-500 font-bold'>{product.points} </Text>
</View>
<View className='bg-blue-50 px-2 py-1 rounded'>
<Text className='text-xs text-blue-500'>{getTypeLabel(product.type)}</Text>
</View>
</View>
<View className='flex justify-between items-center mt-2'>
<Text className='text-xs text-gray-400'>: {product.stock}</Text>
<View
className={`px-3 py-1 rounded-full ${
canExchange ? 'bg-orange-500' : 'bg-gray-300'
}`}
onClick={() => canExchange && handleExchange(product)}
>
<Text className='text-xs text-white'>
{!canExchange
? userPoints < product.points ? '积分不足' : '库存不足'
: '立即兑换'
}
</Text>
</View>
</View>
</View>
</View>
</View>
)
})}
</View>
)}
</ScrollView>
</View>
)
}
export default ExchangePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '积分兑换',
}

View File

@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { pageShopPointsProduct } from '@/api/shop/shopPointsProduct'
import type { ShopPointsProduct } from '@/api/shop/shopPointsProduct/model'
import { getUserPointsInfo, type UserPointsInfo } from '@/api/system/user/points'
import EmptyState from '@/components/common/EmptyState'
const PointsPage: React.FC = () => {
const { user } = useUser()
const [pointsInfo, setPointsInfo] = useState<UserPointsInfo>({ points: 0, totalEarned: 0, totalUsed: 0, expiringSoon: 0 })
const [hotProducts, setHotProducts] = useState<ShopPointsProduct[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// 获取当前用户真实积分
getUserPointsInfo()
.then(data => setPointsInfo(data))
.catch(err => console.error('获取积分信息失败:', err))
pageShopPointsProduct({ page: 1, limit: 4, isHot: 1, status: 1 })
.then(res => setHotProducts(res?.list || []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<ScrollView scrollY className='min-h-screen bg-gray-50'>
{/* 积分卡片 */}
<View className='mx-3 mt-3 p-4 rounded-xl' style={{ background: 'linear-gradient(135deg, #0e932e, #16a34a)' }}>
<Text className='text-white text-sm opacity-80 block mb-1'></Text>
<Text className='text-white text-3xl font-bold block'>{pointsInfo.points}</Text>
<View className='flex gap-3 mt-4'>
<View
className='flex-1 py-2 rounded-full text-center'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: '/pages/points/signin' })}
>
<Text className='text-white text-sm'></Text>
</View>
<View
className='flex-1 py-2 rounded-full text-center'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: '/pages/user/points-record' })}
>
<Text className='text-white text-sm'></Text>
</View>
</View>
</View>
{/* 热门兑换 */}
<View className='mx-3 mt-4'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text
className='text-xs text-gray-400'
onClick={() => Taro.navigateTo({ url: '/pages/points/product-list/index' })}
>{`查看更多 >`}</Text>
</View>
{!loading && hotProducts.length === 0 ? (
<EmptyState text='暂无热门商品' />
) : (
<View className='grid grid-cols-2 gap-2'>
{loading
? [1, 2, 3, 4].map(i => (
<View key={i} className='bg-white rounded-lg overflow-hidden'>
<View className='w-full aspect-square bg-gray-100' />
<View className='p-2'>
<View className='h-4 bg-gray-100 rounded mb-2' />
<View className='h-3 bg-gray-100 rounded w-16' />
</View>
</View>
))
: hotProducts.map(item => (
<View
key={item.id}
className='bg-white rounded-lg overflow-hidden'
onClick={() => Taro.navigateTo({ url: `/pages/points/product-detail?id=${item.id}` })}
>
<View className='w-full aspect-square bg-orange-50 flex items-center justify-center overflow-hidden'>
{item.productImage ? (
<Image src={item.productImage} className='w-full h-full' mode='aspectFill' />
) : (
<Text className='text-xs text-gray-300'></Text>
)}
</View>
<View className='p-2'>
<Text className='text-sm text-gray-700 truncate block'>{item.productName}</Text>
<View className='flex items-center gap-1 mt-1'>
<Text className='text-sm font-bold text-orange-500'>{item.pointsPrice || 0}</Text>
{Number(item.moneyPrice) > 0 && (
<Text className='text-xs text-gray-400'>+¥{item.moneyPrice}</Text>
)}
</View>
</View>
</View>
))}
</View>
)}
</View>
{/* 积分订单入口 */}
<View
className='mx-3 mt-4 mb-4 bg-white rounded-lg p-4 flex justify-between items-center'
onClick={() => Taro.navigateTo({ url: '/pages/order-list' })}
>
<Text className='text-sm text-gray-700'></Text>
<Text className='text-gray-400 text-sm'>{'>'}</Text>
</View>
</ScrollView>
)
}
export default PointsPage

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopPointsOrder, cancelShopPointsOrder } from '@/api/shop/shopPointsOrder'
import type { ShopPointsOrder, PointsOrderStatus } from '@/api/shop/shopPointsOrder'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '积分订单',
})
// Tab 配置
const TAB_LIST = [
{ title: '全部', status: undefined },
{ title: '待发货', status: 'paid' as PointsOrderStatus },
{ title: '已完成', status: 'completed' as PointsOrderStatus },
{ title: '已取消', status: 'cancelled' as PointsOrderStatus },
]
const PointsOrderListPage: React.FC = () => {
const [tabIndex, setTabIndex] = useState(0)
const [orders, setOrders] = useState<ShopPointsOrder[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setOrders([])
setPage(1)
setFinished(false)
loadList(1)
}, [tabIndex])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const status = TAB_LIST[tabIndex]?.status
const params: any = {
page: p,
limit: 10,
}
if (status) {
params.status = status
}
const res = await listShopPointsOrder(params)
if (res?.list) {
if (p === 1) {
setOrders(res.list)
} else {
setOrders(prev => [...prev, ...res.list])
}
setFinished(res.list.length < 10)
setPage(p)
}
} catch (err) {
console.error('加载积分订单失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleLoadMore = () => {
if (!finished && !loading) {
loadList(page + 1)
}
}
// 取消订单
const handleCancel = async (id: string) => {
Taro.showModal({
title: '确认取消',
content: '确定取消该订单吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await cancelShopPointsOrder(id)
Taro.showToast({ title: '已取消', icon: 'success' })
// 重新加载列表
setOrders([])
setPage(1)
setFinished(false)
loadList(1)
} catch (err) {
console.error('取消订单失败', err)
Taro.showToast({ title: '取消失败', icon: 'none' })
}
}
}
})
}
// 查看详情
const handleDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/points/order-detail/index?id=${id}` })
}
// 获取状态文本和颜色
const getStatusInfo = (status: PointsOrderStatus) => {
const map: Record<PointsOrderStatus, { label: string; color: string }> = {
'pending': { label: '待支付', color: 'text-orange-500' },
'paid': { label: '待发货', color: 'text-blue-500' },
'shipped': { label: '已发货', color: 'text-blue-500' },
'completed': { label: '已完成', color: 'text-green-500' },
'cancelled': { label: '已取消', color: 'text-gray-400' },
}
return map[status] || { label: '未知', color: 'text-gray-400' }
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{TAB_LIST.map((tab, index) => (
<View
key={index}
className={`flex-1 text-center py-3 relative ${
tabIndex === index ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setTabIndex(index)}
>
<Text className='text-sm'>{tab.title}</Text>
{tabIndex === index && (
<View className='absolute bottom-0 w-8 h-px bg-orange-500 rounded' style={{ left: '50%', transform: 'translateX(-50%)' }} />
)}
</View>
))}
</View>
{/* 订单列表 */}
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{orders.length > 0 ? (
orders.map(order => {
const statusInfo = getStatusInfo(order.status)
return (
<View
key={order.id}
className='bg-white rounded-xl p-4 mb-3'
onClick={() => handleDetail(order.id)}
>
{/* 顶部状态 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-500'>{order.orderNo}</Text>
<Text className={`text-sm font-medium ${statusInfo.color}`}>
{statusInfo.label}
</Text>
</View>
{/* 商品列表 */}
{order.items.map((item, idx) => (
<View key={idx} className='flex items-center gap-2 mb-2'>
<View className='w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0'>
<Image
src={item.goodsImage}
className='w-10 h-10 rounded'
mode='aspectFill'
/>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 font-medium block'>{item.goodsName}</Text>
<Text className='text-xs text-gray-500 mt-0 block'>
{item.points} x {item.quantity}
</Text>
</View>
</View>
))}
{/* 底部操作 */}
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
<Text className='text-xs text-gray-400'>
{order.totalPoints}
</Text>
<View className='flex gap-2'>
{order.status === 'paid' && (
<View
className='text-center py-1 px-3 rounded-full border border-red-500'
onClick={(e) => {
e.stopPropagation()
handleCancel(order.id)
}}
>
<Text className='text-xs text-red-500'></Text>
</View>
)}
<View
className='text-center py-1 px-3 rounded-full bg-orange-500'
onClick={(e) => {
e.stopPropagation()
handleDetail(order.id)
}}
>
<Text className='text-xs text-white'></Text>
</View>
</View>
</View>
</View>
)
})
) : !loading ? (
<EmptyState text='暂无积分订单' />
) : null}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default PointsOrderListPage

View File

@@ -0,0 +1,168 @@
import React from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '积分规则',
})
const PointsRulesPage: React.FC = () => {
const scrollHeight = useScrollHeight(44)
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 积分获取规则 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='mb-4'>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>📅</Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> 5 </Text>
<Text className='block'> 7 50 </Text>
<Text className='block'> 30 200 </Text>
</View>
</View>
<View className='mb-4'>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>🛍</Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> 1 1 </Text>
<Text className='block'> 使</Text>
<Text className='block'> 退</Text>
</View>
</View>
<View className='mb-4'>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'></Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> 10 </Text>
<Text className='block'> 5 </Text>
<Text className='block'> </Text>
</View>
</View>
<View>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>🎁</Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> 50 </Text>
<Text className='block'> 20 </Text>
<Text className='block'> </Text>
</View>
</View>
</View>
{/* 积分使用规则 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'>使</Text>
<View className='mb-4'>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>💰</Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> 100 1 </Text>
<Text className='block'> 10%</Text>
<Text className='block'> </Text>
</View>
</View>
<View className='mb-4'>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>🎫</Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> </Text>
<Text className='block'> </Text>
</View>
</View>
<View>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>🎁</Text>
<Text className='text-sm font-medium text-gray-700'></Text>
</View>
<View className='text-xs text-gray-500 leading-5 pl-6'>
<Text className='block'> </Text>
<Text className='block'> </Text>
<Text className='block'> </Text>
</View>
</View>
</View>
{/* 积分有效期规则 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='text-xs text-gray-500 leading-5'>
<Text className='block mb-2'> 1 </Text>
<Text className='block mb-2'> </Text>
<Text className='block mb-2'> "积分明细"</Text>
<Text className='block'> 使</Text>
</View>
</View>
{/* 积分等级规则 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='mb-3'>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-xs text-gray-400'>0-999 </Text>
</View>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-xs text-gray-400'>1000-4999 </Text>
</View>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-xs text-gray-400'>5000-9999 </Text>
</View>
<View className='flex justify-between py-2'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-xs text-gray-400'>10000 </Text>
</View>
</View>
<View className='text-xs text-gray-400 leading-5'>
<Text className='block'> </Text>
<Text className='block'> </Text>
<Text className='block'> 1 </Text>
</View>
</View>
{/* 其他说明 */}
<View className='mx-3 mt-3 mb-3 bg-white rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='text-xs text-gray-500 leading-5'>
<Text className='block mb-2'>1. </Text>
<Text className='block mb-2'>2. </Text>
<Text className='block mb-2'>3. </Text>
<Text className='block'>4. </Text>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default PointsRulesPage

View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView, RichText } from '@tarojs/components'
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'
import { getShopPointsProduct } from '@/api/shop/shopPointsProduct'
import type { ShopPointsProduct } from '@/api/shop/shopPointsProduct/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '积分商品',
})
const PointsProductDetail: React.FC = () => {
const router = useRouter()
const id = Number(router.params.id)
const [product, setProduct] = useState<ShopPointsProduct | null>(null)
const [loading, setLoading] = useState(true)
// 微信分享
useShareAppMessage(() => {
return {
title: product?.productName || '积分商品推荐',
path: `/pages/points/product-detail?id=${id}`,
imageUrl: product?.productImage || ''
}
})
useEffect(() => {
if (id) {
loadProduct()
}
}, [id])
const loadProduct = async () => {
setLoading(true)
try {
const data = await getShopPointsProduct(id)
setProduct(data)
} catch (e) {
console.error('加载积分商品失败:', e)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const getProductTypeLabel = (type?: number) => {
switch (type) {
case 0: return '实物商品'
case 1: return '优惠券'
case 2: return '虚拟商品'
default: return '商品'
}
}
const handleExchange = () => {
if (!product) return
if ((product.stock || 0) <= 0) {
Taro.showToast({ title: '商品已售罄', icon: 'none' })
return
}
// 跳转兑换确认页,传递商品信息
Taro.navigateTo({
url: `/pages/points/exchange/index?id=${product.id}`
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50'>
<View className='w-full bg-orange-50 animate-pulse' style={{ height: '375px' }} />
<View className='bg-white p-4'>
<View className='h-6 bg-gray-200 rounded mb-2 animate-pulse' style={{ width: '70%' }} />
<View className='h-8 bg-gray-200 rounded mb-2 animate-pulse' style={{ width: '40%' }} />
<View className='h-4 bg-gray-200 rounded animate-pulse' style={{ width: '55%' }} />
</View>
</View>
)
}
if (!product) {
return (
<View className='min-h-screen bg-gray-50'>
<EmptyState icon='🎁' text='商品不存在或已下架' />
</View>
)
}
const isSoldOut = (product.stock || 0) <= 0
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 商品图片 */}
{product.productImage ? (
<Image
className='w-full'
style={{ height: '375px' }}
src={product.productImage}
mode='aspectFill'
onClick={() => Taro.previewImage({ current: product.productImage, urls: [product.productImage!] })}
/>
) : (
<View className='w-full bg-orange-50 flex items-center justify-center' style={{ height: '375px' }}>
<Text className='text-gray-400 text-sm'></Text>
</View>
)}
{/* 价格与基本信息 */}
<View className='bg-white p-4'>
<View className='flex items-center gap-2 mb-2'>
<View className='px-2 rounded' style={{ backgroundColor: '#fff7ed', paddingTop: '2px', paddingBottom: '2px' }}>
<Text className='text-xs text-orange-500'>{getProductTypeLabel(product.productType)}</Text>
</View>
{product.isHot === 1 && (
<View className='px-2 rounded' style={{ backgroundColor: '#fef2f2', paddingTop: '2px', paddingBottom: '2px' }}>
<Text className='text-xs text-red-500'></Text>
</View>
)}
</View>
<Text className='text-base font-bold text-gray-800 block leading-tight'>
{product.productName || '积分商品'}
</Text>
<View className='flex items-baseline gap-1 mt-2'>
<Text className='text-xs text-orange-500'></Text>
<Text className='text-2xl font-bold text-orange-500'>
{product.pointsPrice || 0}
</Text>
<Text className='text-xs text-orange-500'></Text>
{Number(product.moneyPrice) > 0 && (
<>
<Text className='text-xs text-gray-500 ml-1'>+</Text>
<Text className='text-base font-medium text-orange-500 ml-1'>
{'\u00A5'}{product.moneyPrice}
</Text>
</>
)}
</View>
<View className='flex items-center gap-4 mt-2'>
<Text className='text-xs text-gray-400'>
{product.sales || 0}
</Text>
<Text className={`text-xs ${isSoldOut ? 'text-red-400' : 'text-green-500'}`}>
{isSoldOut ? '已售罄' : `剩余 ${product.stock || 0}`}
</Text>
</View>
</View>
{/* 商品描述 */}
{product.description && (
<View className='bg-white mt-2 p-4'>
<Text className='text-sm font-medium text-gray-800 block mb-3'></Text>
<RichText
className='text-sm text-gray-600 leading-relaxed'
nodes={product.description}
/>
</View>
)}
{/* 底部占位,防止被固定栏遮挡 */}
<View style={{ height: '80px' }} />
</ScrollView>
{/* 底部兑换按钮 */}
<View className='bg-white border-t border-gray-100 px-4 py-3 flex items-center justify-center' style={{ paddingBottom: '20px' }}>
<View
className='w-full py-3 rounded-full text-center text-white font-medium'
style={{ backgroundColor: isSoldOut ? '#d1d5db' : '#f97316' }}
onClick={handleExchange}
>
<Text className='text-sm'>{isSoldOut ? '已售罄' : '立即兑换'}</Text>
</View>
</View>
</View>
)
}
export default PointsProductDetail

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '积分商城',
}

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopPointsProduct, pageShopPointsProduct } from '@/api/shop/shopPointsProduct'
import type { ShopPointsProduct } from '@/api/shop/shopPointsProduct/model'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '积分商城',
})
const PointsProductListPage: React.FC = () => {
const [products, setProducts] = useState<ShopPointsProduct[]>([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const limit = 20
useEffect(() => {
loadProducts(true)
}, [])
const loadProducts = async (reset = false) => {
const currentPage = reset ? 1 : page
if (reset) {
setLoading(true)
} else {
setLoadingMore(true)
}
try {
const data = await pageShopPointsProduct({
page: currentPage,
limit,
status: 1,
})
if (reset) {
setProducts(data?.list || [])
} else {
setProducts(prev => [...prev, ...(data?.list || [])])
}
setHasMore((data?.list || []).length >= limit)
setPage(currentPage + 1)
} catch (e) {
console.error('加载积分商品失败:', e)
} finally {
setLoading(false)
setLoadingMore(false)
}
}
const handleLoadMore = () => {
if (!loadingMore && hasMore) {
loadProducts(false)
}
}
const getProductTypeLabel = (type?: number) => {
switch (type) {
case 0: return '实物'
case 1: return '优惠券'
case 2: return '虚拟'
default: return '商品'
}
}
return (
<View className='min-h-screen bg-gray-50'>
{loading ? (
<View className='p-3'>
<View className='grid grid-cols-2 gap-2'>
{Array.from({ length: 6 }).map((_, i) => (
<View key={i} className='bg-white rounded-lg overflow-hidden animate-pulse'>
<View className='w-full aspect-square bg-gray-200' />
<View className='p-2'>
<View className='h-4 bg-gray-200 rounded mb-2' style={{ width: '75%' }} />
<View className='h-3 bg-gray-200 rounded' style={{ width: '50%' }} />
</View>
</View>
))}
</View>
</View>
) : products.length === 0 ? (
<EmptyState
icon='🎁'
text='暂无积分商品'
subText='平台正在准备更多好物,敬请期待'
/>
) : (
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
>
<View className='p-3'>
<View className='grid grid-cols-2 gap-2'>
{products.map(product => (
<View
key={product.id}
className='bg-white rounded-lg overflow-hidden'
onClick={() => Taro.navigateTo({ url: `/pages/points/product-detail?id=${product.id}` })}
>
<View className='w-full aspect-square bg-orange-50 relative'>
<Image
className='w-full h-full'
src={product.productImage || ''}
mode='aspectFill'
/>
<View className='absolute top-1 right-1 px-2 rounded-full bg-black bg-opacity-50' style={{ paddingTop: '2px', paddingBottom: '2px' }}>
<Text className='text-white text-xs'>{getProductTypeLabel(product.productType)}</Text>
</View>
{product.isHot === 1 && (
<View className='absolute top-1 left-1 px-2 rounded-br-lg' style={{ backgroundColor: '#f97316', paddingTop: '2px', paddingBottom: '2px' }}>
<Text className='text-white text-xs'></Text>
</View>
)}
{product.stock === 0 && (
<View className='absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center'>
<Text className='text-white font-medium'></Text>
</View>
)}
</View>
<View className='p-2'>
<Text className='text-sm text-gray-700 line-clamp-2 block'>
{product.productName || '积分商品'}
</Text>
<View className='flex items-center gap-1 mt-1'>
<Text className='text-base font-bold text-orange-500'>
{product.pointsPrice || 0}
</Text>
<Text className='text-xs text-orange-500'></Text>
{Number(product.moneyPrice) > 0 && (
<Text className='text-xs text-gray-400 ml-1'>
+¥{product.moneyPrice}
</Text>
)}
</View>
<View className='flex justify-between items-center mt-1'>
<Text className='text-xs text-gray-400'>
{product.sales || 0}
</Text>
<Text className={`text-xs ${(product.stock || 0) > 0 ? 'text-green-500' : 'text-gray-400'}`}>
{product.stock || 0}
</Text>
</View>
</View>
</View>
))}
</View>
{/* 加载更多 */}
{hasMore && (
<LoadMore loading={loadingMore} />
)}
{!hasMore && products.length > 0 && (
<View className='py-6 text-center'>
<Text className='text-xs text-gray-400'> </Text>
</View>
)}
</View>
</ScrollView>
)}
</View>
)
}
export default PointsProductListPage

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { listShopSigninRecord, addShopSigninRecord } from '@/api/shop/shopSigninRecord'
definePageConfig({
navigationBarTitleText: '每日签到',
})
const SigninPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [signedDates, setSignedDates] = useState<string[]>([])
const [todaySigned, setTodaySigned] = useState(false)
const [loading, setLoading] = useState(false)
const today = new Date()
const todayStr = today.toISOString().split('T')[0] // YYYY-MM-DD
// 获取当月已签到日期
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
loadSignedRecords()
}, [isLoggedIn])
const loadSignedRecords = async () => {
try {
const year = today.getFullYear()
const month = today.getMonth() + 1
const data = await listShopSigninRecord({
userId: (user as any)?.userId,
year,
month,
})
if (data && Array.isArray(data)) {
const dates = data.map((record: any) => record.signinDate)
setSignedDates(dates)
setTodaySigned(dates.includes(todayStr))
}
} catch (err) {
console.error('加载签到记录失败', err)
}
}
const handleSignin = async () => {
if (todaySigned) {
Taro.showToast({ title: '今日已签到', icon: 'none' })
return
}
if (loading) return
setLoading(true)
try {
await addShopSigninRecord({
userId: (user as any)?.userId,
signinDate: todayStr,
})
Taro.showToast({ title: '签到成功 +5积分', icon: 'success' })
setTodaySigned(true)
setSignedDates(prev => [...prev, todayStr])
} catch (err: any) {
console.error('签到失败', err)
Taro.showToast({ title: err.message || '签到失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 生成本周日期列表用于UI显示
const getWeekDates = () => {
const dates = []
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d)
}
return dates
}
const weekDates = getWeekDates()
const getDayOfWeek = (date: Date) => {
const days = ['日', '一', '二', '三', '四', '五', '六']
return days[date.getDay()]
}
return (
<View className='min-h-screen bg-gray-50 p-4'>
{/* 签到卡片 */}
<View className='bg-white rounded-xl p-6 text-center'>
<Text className='text-lg font-bold text-gray-800 block mb-1'></Text>
<Text className='text-sm text-gray-500 block mb-4'></Text>
{/* 本周签到 */}
<View className='grid grid-cols-7 gap-1 mb-4'>
{weekDates.map((date, idx) => {
const dateStr = date.toISOString().split('T')[0]
const dayOfWeek = getDayOfWeek(date)
const isSigned = signedDates.includes(dateStr)
const isToday = dateStr === todayStr
return (
<View key={idx} className='flex flex-col items-center py-2'>
<Text className='text-xs text-gray-400 mb-1'>{dayOfWeek}</Text>
<View
className={`w-8 h-8 rounded-full flex items-center justify-center ${
isSigned
? 'bg-green-500'
: isToday
? 'bg-green-50 border border-green-500'
: 'bg-gray-50'
}`}
>
<Text className={`text-xs ${isSigned ? 'text-white' : isToday ? 'text-green-600' : 'text-gray-400'}`}>
{date.getDate()}
</Text>
</View>
</View>
)
})}
</View>
<View
className='py-2 rounded-full'
style={{ backgroundColor: todaySigned ? '#ccc' : '#0e932e' }}
onClick={handleSignin}
>
<Text className='text-white font-medium text-sm'>
{loading ? '签到中...' : todaySigned ? '已签到' : '立即签到'}
</Text>
</View>
</View>
{/* 签到规则 */}
<View className='bg-white rounded-xl p-4 mt-4'>
<Text className='text-sm font-medium text-gray-800 mb-2'></Text>
<View className='text-xs text-gray-500 leading-6'>
<Text className='block'>1. 5 </Text>
<Text className='block'>2. 7 20 </Text>
<Text className='block'>3. 30 100 </Text>
<Text className='block'>4. </Text>
</View>
</View>
</View>
)
}
export default SigninPage