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,3 @@
export default {
navigationBarTitleText: '活动详情',
}

View File

@@ -0,0 +1,245 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getShopActivity, signUpActivity, cancelSignUpActivity, getSignUpStatus } from '@/api/shop/shopActivity'
import type { ShopActivity, ActivityStatus } from '@/api/shop/shopActivity/model'
definePageConfig({
navigationBarTitleText: '活动详情',
})
const ActivityDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [activity, setActivity] = useState<ShopActivity | null>(null)
const [loading, setLoading] = useState(true)
const [signedUp, setSignedUp] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (id) {
fetchDetail(Number(id))
fetchSignUpStatus(Number(id))
}
}, [id])
const fetchDetail = async (activityId: number) => {
try {
const data = await getShopActivity(activityId)
setActivity(data)
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const fetchSignUpStatus = async (activityId: number) => {
try {
const res = await getSignUpStatus(activityId)
setSignedUp(res.signedUp)
} catch {
// 未登录或接口不可用时忽略
}
}
// 报名活动
const handleSignUp = () => {
if (!activity) return
Taro.showModal({
title: '确认报名',
content: '确定报名参加该活动吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
setSubmitting(true)
try {
await signUpActivity({ activityId: activity.id, userId: 0 })
setSignedUp(true)
setActivity(prev => prev ? { ...prev, participants: prev.participants + 1 } : prev)
Taro.showToast({ title: '报名成功', icon: 'success' })
} catch (err: any) {
Taro.showToast({ title: err.message || '报名失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
}
})
}
// 取消报名
const handleCancelSignUp = () => {
if (!activity) return
Taro.showModal({
title: '取消报名',
content: '确定取消报名吗?',
confirmColor: '#ef4444',
success: async (res) => {
if (res.confirm) {
setSubmitting(true)
try {
await cancelSignUpActivity(activity.id)
setSignedUp(false)
setActivity(prev => prev ? { ...prev, participants: Math.max(0, prev.participants - 1) } : prev)
Taro.showToast({ title: '已取消报名', icon: 'success' })
} catch (err: any) {
Taro.showToast({ title: err.message || '取消失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
}
})
}
// 分享活动
const handleShare = () => {
Taro.showToast({ title: '分享功能开发中', icon: 'none' })
}
// 获取状态标签
const getStatusLabel = (status: ActivityStatus) => {
const map: Record<ActivityStatus, { label: string; color: string }> = {
upcoming: { label: '未开始', color: 'text-blue-500' },
ongoing: { label: '进行中', color: 'text-green-500' },
ended: { label: '已结束', color: 'text-gray-400' },
cancelled: { label: '已取消', color: 'text-gray-400' },
}
return map[status] || { label: '未知', color: 'text-gray-400' }
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
if (!activity) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)
}
const statusInfo = getStatusLabel(activity.status)
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 活动头图 */}
{activity.image ? (
<Image src={activity.image} className='w-full' style={{ height: '192px' }} mode='aspectFill' />
) : (
<View className='w-full flex items-center justify-center' style={{ height: '192px', background: 'linear-gradient(to right, #fb923c, #ef4444)' }}>
<Text className='text-6xl'>🎉</Text>
</View>
)}
{/* 活动信息 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-xl font-bold text-gray-800 flex-1'>{activity.name}</Text>
<View className={`px-2 py-1 rounded ${activity.status === 'ongoing' ? 'bg-green-50' : activity.status === 'upcoming' ? 'bg-blue-50' : 'bg-gray-50'}`}>
<Text className={`text-xs ${statusInfo.color}`}>{statusInfo.label}</Text>
</View>
</View>
<View className='flex items-center gap-3 text-xs text-gray-500 mb-3'>
<View className='flex items-center gap-1'>
<Text>📅</Text>
<Text>{formatTime(activity.startTime)}</Text>
</View>
<View className='flex items-center gap-1'>
<Text>👥</Text>
<Text>{activity.participants}</Text>
</View>
{activity.maxParticipants && (
<View className='flex items-center gap-1'>
<Text>🔢</Text>
<Text>{activity.maxParticipants}</Text>
</View>
)}
</View>
<View className='bg-orange-50 rounded-lg p-3'>
<Text className='text-sm text-orange-500 font-medium'>{activity.typeName || activity.type}</Text>
<Text className='text-xs text-gray-600 mt-1 block leading-5'>{activity.description.split('\n')[0]}</Text>
</View>
</View>
{/* 活动详情 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{activity.description}
</Text>
</View>
{/* 活动规则 */}
{activity.rules && (
<View className='bg-white p-4 mb-3'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{activity.rules}
</Text>
</View>
)}
<View className='h-20' />
</ScrollView>
{/* 底部操作栏 */}
<View className='bg-white p-3 shadow-lg flex gap-2' style={{ paddingBottom: '20px' }}>
<View
className='flex-1 bg-gray-100 text-center py-3 rounded-full'
onClick={handleShare}
>
<Text className='text-sm text-gray-700'></Text>
</View>
{activity.status === 'ongoing' && !signedUp && (
<View
className='flex-1 bg-orange-500 text-center py-3 rounded-full'
onClick={submitting ? undefined : handleSignUp}
style={{ opacity: submitting ? 0.6 : 1 }}
>
<Text className='text-sm text-white font-bold'>{submitting ? '处理中...' : '立即报名'}</Text>
</View>
)}
{activity.status === 'ongoing' && signedUp && (
<View
className='flex-1 bg-gray-400 text-center py-3 rounded-full'
onClick={submitting ? undefined : handleCancelSignUp}
style={{ opacity: submitting ? 0.6 : 1 }}
>
<Text className='text-sm text-white font-bold'>{submitting ? '处理中...' : '取消报名'}</Text>
</View>
)}
{activity.status === 'upcoming' && (
<View className='flex-1 bg-blue-500 text-center py-3 rounded-full'>
<Text className='text-sm text-white font-bold'></Text>
</View>
)}
{(activity.status === 'ended' || activity.status === 'cancelled') && (
<View className='flex-1 bg-gray-300 text-center py-3 rounded-full'>
<Text className='text-sm text-white font-bold'></Text>
</View>
)}
</View>
</View>
)
}
export default ActivityDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '活动列表',
}

View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopActivity, signUpActivity } from '@/api/shop/shopActivity'
import type { ShopActivity, ActivityStatus, ActivityType } from '@/api/shop/shopActivity'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '活动列表',
})
const ActivityListPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(0)
const [activities, setActivities] = useState<ShopActivity[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setActivities([])
setPage(1)
setFinished(false)
loadList(1)
}, [activeTab])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
// 根据 tab 筛选状态
let status: ActivityStatus | undefined
if (activeTab === 1) status = 'ongoing'
else if (activeTab === 2) status = 'upcoming'
else if (activeTab === 3) status = 'ended'
const res = await pageShopActivity({
page: p,
limit: 10,
status,
})
if (res?.list) {
if (p === 1) {
setActivities(res.list)
} else {
setActivities(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 handleSignUp = async (id: number) => {
Taro.showModal({
title: '确认报名',
content: '确定报名参加该活动吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await signUpActivity({ activityId: id, userId: 0 })
Taro.showToast({ title: '报名成功', icon: 'success' })
} catch (err) {
console.error('报名失败', err)
Taro.showToast({ title: '报名失败', icon: 'none' })
}
}
}
})
}
// 获取状态标签
const getStatusLabel = (status: ActivityStatus) => {
const map: Record<ActivityStatus, { label: string; color: string }> = {
'upcoming': { label: '未开始', color: 'text-blue-500' },
'ongoing': { label: '进行中', color: 'text-green-500' },
'ended': { label: '已结束', color: 'text-gray-400' },
'cancelled': { label: '已取消', color: 'text-gray-400' },
}
return map[status] || { label: '未知', color: 'text-gray-400' }
}
// 获取活动类型名称
const getTypeLabel = (type: ActivityType) => {
const map: Record<ActivityType, string> = {
'discount': '限时折扣',
'full_reduction': '满减活动',
'seckill': '秒杀活动',
'group_buy': '团购活动',
'flash_sale': '闪购活动',
}
return map[type] || '其他'
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
return (
<View className='min-h-screen bg-gray-50'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{['全部', '进行中', '未开始', '已结束'].map((tab, index) => (
<View
key={index}
className={`flex-1 text-center py-3 relative ${
activeTab === index ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveTab(index)}
>
<Text className='text-sm'>{tab}</Text>
{activeTab === 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'>
{activities.length === 0 && !loading ? (
<EmptyState text='暂无活动' />
) : (
activities.map(activity => (
(() => {
const statusInfo = getStatusLabel(activity.status)
return (
<View key={activity.id} className='bg-white rounded-xl p-4 mb-3 shadow-sm' onClick={() => Taro.navigateTo({ url: `/pages/activity/detail/index?id=${activity.id}` })}>
{/* 活动图片 */}
<View className='w-full rounded-lg flex items-center justify-center mb-3' style={{ height: '160px', background: 'linear-gradient(to right, #fb923c, #ef4444)' }}>
<Text className='text-5xl'>🎉</Text>
</View>
{/* 活动信息 */}
<View className='mb-2'>
<View className='flex items-center gap-2 mb-1'>
<Text className='text-base font-bold text-gray-800 flex-1'>{activity.name}</Text>
<View className={`px-2 py-1 rounded ${activity.status === 'ongoing' ? 'bg-green-50' : activity.status === 'upcoming' ? 'bg-blue-50' : 'bg-gray-50'}`}>
<Text className={`text-xs ${statusInfo.color}`}>{statusInfo.label}</Text>
</View>
</View>
<Text className='text-sm text-gray-600 mb-2 block'>{activity.description}</Text>
<View className='flex items-center gap-3 text-xs text-gray-400'>
<View className='flex items-center gap-1'>
<Text>📅</Text>
<Text>{formatTime(activity.startTime)}</Text>
</View>
<View className='flex items-center gap-1'>
<Text>👥</Text>
<Text>{activity.participants}</Text>
</View>
</View>
</View>
{/* 活动类型标签 */}
<View className='flex items-center justify-between'>
<View className='bg-orange-100 px-2 py-1 rounded'>
<Text className='text-xs text-orange-500'>{getTypeLabel(activity.type)}</Text>
</View>
{activity.status === 'ongoing' && (
<View
className='bg-orange-500 px-4 py-1 rounded-full'
onClick={() => handleSignUp(activity.id)}
>
<Text className='text-xs text-white'></Text>
</View>
)}
{activity.status === 'upcoming' && (
<View
className='bg-blue-500 px-4 py-1 rounded-full'
onClick={() => Taro.navigateTo({ url: `/pages/activity/detail/index?id=${activity.id}` })}
>
<Text className='text-xs text-white'></Text>
</View>
)}
</View>
</View>
)
})()
))
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default ActivityListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '申请售后',
}

View File

@@ -0,0 +1,251 @@
import React, { useState } from 'react'
import { View, Text, Textarea, ScrollView, Radio, RadioGroup, Checkbox, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { applyAfterSale } from '@/api/shop/shopAfterSale'
import type { AfterSaleType } from '@/api/shop/shopAfterSale'
definePageConfig({
navigationBarTitleText: '申请售后',
})
const AfterSaleApplyPage: React.FC = () => {
const params = Taro.getCurrentInstance().router?.params || {}
const orderId = params.orderId || ''
const [orderGoods, setOrderGoods] = useState<Array<{ id: number; name: string; price: number; num: number; checked: boolean }>>([])
const [saleType, setSaleType] = useState(1) // 1:退款, 2:退货退款, 3:换货
const [submitting, setSubmitting] = useState(false)
const [reason, setReason] = useState('')
const [amount, setAmount] = useState('')
const [description, setDescription] = useState('')
const [images, setImages] = useState<string[]>([])
// 退款原因选项
const refundReasons = ['商品质量问题', '商品与描述不符', '商品破损/缺陷', '物流问题', '其他']
const returnReasons = ['商品质量问题', '商品与描述不符', '商品破损/缺陷', '尺码/颜色不合适', '不喜欢/不想要', '其他']
const exchangeReasons = ['商品质量问题', '商品与描述不符', '商品破损/缺陷', '尺码/颜色不合适', '其他']
// 获取当前原因列表
const getCurrentReasons = () => {
if (saleType === 1) return refundReasons
if (saleType === 2) return returnReasons
return exchangeReasons
}
// 处理商品选择
const handleGoodsCheck = (id: number) => {
setOrderGoods(prev =>
prev.map(g => g.id === id ? { ...g, checked: !g.checked } : g)
)
}
// 选择图片
const handleChooseImage = () => {
if (images.length >= 6) {
Taro.showToast({ title: '最多上传6张图片', icon: 'none' })
return
}
Taro.chooseImage({
count: 6 - images.length,
success: (res) => {
setImages(prev => [...prev, ...res.tempFilePaths])
}
})
}
// 删除图片
const handleDeleteImage = (index: number) => {
setImages(prev => prev.filter((_, i) => i !== index))
}
// 提交申请
const handleSubmit = () => {
const checkedGoods = orderGoods.filter(g => g.checked)
if (!reason) {
Taro.showToast({ title: '请选择原因', icon: 'none' })
return
}
if (saleType === 1 && !amount) {
Taro.showToast({ title: '请输入退款金额', icon: 'none' })
return
}
const typeMap: Record<number, AfterSaleType> = {
1: 'refund',
2: 'return',
3: 'exchange',
}
Taro.showModal({
title: '确认提交',
content: '确定提交售后申请吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
setSubmitting(true)
Taro.showLoading({ title: '提交中...' })
try {
await applyAfterSale({
orderId,
type: typeMap[saleType],
reason,
description,
amount: saleType === 1 ? Number(amount) : undefined,
evidenceImages: images,
goodsItems: checkedGoods.map(g => ({ goodsId: String(g.id), quantity: g.num })),
})
Taro.hideLoading()
Taro.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} catch (err: any) {
Taro.hideLoading()
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
}
})
}
return (
<View className='bg-gray-50 flex flex-col' style={{ minHeight: '100vh' }}>
<ScrollView scrollY className='flex-1'>
{/* 订单商品 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
{orderGoods.map(goods => (
<View
key={goods.id}
className='flex items-center gap-3 py-2 border-b border-gray-50'
onClick={() => handleGoodsCheck(goods.id)}
>
<Checkbox checked={goods.checked} color='#0e932e' />
<View className='w-12 h-12 bg-gray-100 rounded flex items-center justify-center flex-shrink-0'>
<Text className='text-xl'>🛍</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700 block'>{goods.name}</Text>
<Text className='text-xs text-gray-400 mt-1 block'>¥{goods.price} × {goods.num}</Text>
</View>
</View>
))}
</View>
{/* 售后类型 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<RadioGroup onChange={(e) => setSaleType(parseInt(e.detail.value))}>
<View className='flex flex-col gap-2'>
<View className='flex items-center gap-2'>
<Radio value='1' checked={saleType === 1} color='#0e932e' />
<Text className='text-sm text-gray-700'>退</Text>
</View>
<View className='flex items-center gap-2'>
<Radio value='2' checked={saleType === 2} color='#0e932e' />
<Text className='text-sm text-gray-700'>退退</Text>
</View>
<View className='flex items-center gap-2'>
<Radio value='3' checked={saleType === 3} color='#0e932e' />
<Text className='text-sm text-gray-700'></Text>
</View>
</View>
</RadioGroup>
</View>
{/* 退款金额 */}
{saleType === 1 && (
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'>退</Text>
<View className='flex items-center bg-gray-50 rounded-lg px-3 py-2'>
<Text className='text-gray-500 mr-1'>¥</Text>
<Input
type='digit'
value={amount}
onInput={(e) => setAmount(e.detail.value)}
placeholder='请输入退款金额'
className='flex-1'
/>
</View>
<Text className='text-xs text-gray-400 mt-2 block'>退 ¥134.00</Text>
</View>
)}
{/* 售后原因 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-col gap-2'>
{getCurrentReasons().map((r, index) => (
<View
key={index}
className={`p-2 rounded-lg border ${
reason === r ? 'border-orange-500 bg-orange-50' : 'border-gray-200'
}`}
onClick={() => setReason(r)}
>
<Text className={`text-sm ${reason === r ? 'text-orange-500' : 'text-gray-700'}`}>
{r}
</Text>
</View>
))}
</View>
</View>
{/* 问题描述 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<Textarea
value={description}
onInput={(e) => setDescription(e.detail.value)}
placeholder='请详细描述您的问题(选填)'
className='w-full min-h-20 p-2 bg-gray-50 rounded-lg text-sm'
maxlength={500}
/>
<Text className='text-xs text-gray-400 mt-1 block text-right'>{description.length}/500</Text>
</View>
{/* 上传凭证 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4 mb-3'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-wrap gap-2'>
{images.map((img, index) => (
<View key={index} className='relative'>
<View className='w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center'>
<Text className='text-2xl'>🖼</Text>
</View>
<View
className='absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => handleDeleteImage(index)}
>
<Text className='text-xs text-white'>×</Text>
</View>
</View>
))}
{images.length < 6 && (
<View
className='w-16 h-16 bg-gray-50 rounded-lg flex items-center justify-center border-2 border-dashed border-gray-300'
onClick={handleChooseImage}
>
<Text className='text-2xl text-gray-400'>+</Text>
</View>
)}
</View>
</View>
<View className='h-4' />
</ScrollView>
{/* 提交按钮 */}
<View className='bg-white p-3 shadow-lg' style={{ paddingBottom: '20px' }}>
<View
className='text-center py-3 rounded-full text-white font-bold'
style={{ background: submitting ? '#ccc' : 'linear-gradient(to right, #f97316, #ef4444)' }}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='text-white font-bold'>{submitting ? '提交中...' : '提交申请'}</Text>
</View>
</View>
</View>
)
}
export default AfterSaleApplyPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '售后记录',
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageAfterSaleList, cancelAfterSale } from '@/api/shop/shopAfterSale'
import type { AfterSaleDetail, AfterSaleStatus } from '@/api/shop/shopAfterSale'
import { formatAfterSaleStatus } from '@/api/shop/shopAfterSale'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '售后记录',
})
const AfterSaleListPage: React.FC = () => {
const [sales, setSales] = useState<AfterSaleDetail[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
loadList(1)
}, [])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await pageAfterSaleList({
page: p,
pageSize: 10,
})
const newList = res?.list || []
if (p === 1) {
setSales(newList)
} else {
setSales(prev => [...prev, ...newList])
}
setFinished(newList.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 handleDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/after-sale/progress/index?id=${id}` })
}
// 取消售后
const handleCancel = async (id: string) => {
Taro.showModal({
title: '提示',
content: '确定取消售后申请吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await cancelAfterSale(id)
Taro.showToast({ title: '已取消', icon: 'success' })
// 重新加载列表
setSales([])
setPage(1)
setFinished(false)
loadList(1)
} catch (err) {
console.error('取消售后失败', err)
Taro.showToast({ title: '取消失败', icon: 'none' })
}
}
}
})
}
// 售后类型文字
const getTypeText = (type: string) => {
const map: Record<string, string> = {
refund: '仅退款',
return: '退货退款',
exchange: '换货',
repair: '维修',
}
return map[type] || type
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1' onScrollToLower={handleLoadMore}>
{sales.length === 0 ? (
<View className='text-center py-16'>
<Text className='text-4xl mb-3 block'>📋</Text>
<Text className='text-sm text-gray-400 mb-3 block'></Text>
<View
className='inline-block bg-blue-500 text-white px-4 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}
>
<Text className='text-sm'></Text>
</View>
</View>
) : (
<View className='p-3'>
{sales.map(sale => {
const statusInfo = formatAfterSaleStatus(sale.status)
return (
<View key={sale.id} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
{/* 顶部信息 */}
<View className='flex justify-between items-center mb-3'>
<View className='flex items-center gap-2'>
<Text className='text-xs text-gray-500'>{sale.id}</Text>
<View className='bg-gray-100 px-2 py-1 rounded'>
<Text className='text-xs text-gray-600'>{getTypeText(sale.type)}</Text>
</View>
</View>
<Text className={`text-sm font-medium ${statusInfo.color}`}>
{statusInfo.text}
</Text>
</View>
{/* 商品信息 */}
<View className='flex items-center gap-2 mb-3'>
<View className='w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0'>
<Text className='text-2xl'>🛍</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700 block'>{sale.goodsName}</Text>
{sale.type === 'refund' && (
<Text className='text-xs text-red-500 mt-1 block'>退¥{sale.amount}</Text>
)}
</View>
</View>
{/* 时间 */}
<Text className='text-xs text-gray-400 mb-3 block'>
{sale.applyTime}
</Text>
{/* 操作按钮 */}
<View className='flex justify-end gap-2 pt-2 border-t border-gray-50'>
{sale.status === 'processing' && (
<View
className='px-3 py-1 rounded-full border border-red-500'
onClick={(e) => {
e.stopPropagation()
handleCancel(sale.id)
}}
>
<Text className='text-xs text-red-500'></Text>
</View>
)}
<View
className='px-3 py-1 rounded-full bg-blue-500'
onClick={() => handleDetail(sale.id)}
>
<Text className='text-xs text-white'></Text>
</View>
</View>
</View>
)
})}
</View>
)}
<LoadMore loading={loading} finished={finished} />
<View className='h-6' />
</ScrollView>
</View>
)
}
export default AfterSaleListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '售后进度',
}

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getAfterSaleDetail, cancelAfterSale, formatAfterSaleStatus } from '@/api/shop/shopAfterSale'
import type { AfterSaleDetail } from '@/api/shop/shopAfterSale'
definePageConfig({
navigationBarTitleText: '售后进度',
})
const AfterSaleProgressPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [saleInfo, setSaleInfo] = useState<AfterSaleDetail | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchDetail(id)
}
}, [id])
const fetchDetail = async (saleId: string) => {
try {
const data = await getAfterSaleDetail(saleId)
setSaleInfo(data)
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 取消售后
const handleCancel = () => {
if (!saleInfo) return
Taro.showModal({
title: '提示',
content: '确定取消售后申请吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await cancelAfterSale(saleInfo.id)
Taro.showToast({ title: '已取消', icon: 'success' })
fetchDetail(saleInfo.id)
} catch (err: any) {
Taro.showToast({ title: err.message || '取消失败', icon: 'none' })
}
}
}
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
if (!saleInfo) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)
}
const statusInfo = formatAfterSaleStatus(saleInfo.status)
// 售后类型名称
const getTypeLabel = (type: string) => {
const map: Record<string, string> = { refund: '仅退款', return: '退货退款', exchange: '换货', repair: '维修' }
return map[type] || type
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 状态卡片 */}
<View className='p-4 text-white' style={{ background: 'linear-gradient(to right, #60a5fa, #22d3ee)' }}>
<Text className='text-2xl font-bold block mb-2'>{statusInfo.text}</Text>
<Text className='text-sm opacity-80 block mb-1'>{saleInfo.id}</Text>
<Text className='text-sm opacity-80 block'>{saleInfo.applyTime}</Text>
</View>
{/* 售后信息 */}
<View className='bg-white mx-3 rounded-xl p-4 relative z-10 shadow-sm' style={{ marginTop: '-12px' }}>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{getTypeLabel(saleInfo.type)}</Text>
</View>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{saleInfo.reason}</Text>
</View>
{saleInfo.type === 'refund' && (
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'>退</Text>
<Text className='text-sm text-red-500 font-bold'>¥{saleInfo.amount}</Text>
</View>
)}
{saleInfo.rejectReason && (
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-red-500 flex-1 text-right'>{saleInfo.rejectReason}</Text>
</View>
)}
<View className='flex justify-between py-2'>
<Text className='text-sm text-gray-500'></Text>
<View className='flex items-center gap-1' onClick={() => Taro.navigateTo({ url: `/pages/order/detail?id=${saleInfo.orderId}` })}>
<Text className='text-sm text-blue-500'>{saleInfo.orderNo || saleInfo.orderId}</Text>
<Text className='text-gray-400 text-xs'></Text>
</View>
</View>
</View>
{/* 进度步骤 */}
{saleInfo.progressRecords && saleInfo.progressRecords.length > 0 && (
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
{saleInfo.progressRecords.map((record, index) => (
<View key={record.id} className={`flex mb-3 ${index === saleInfo.progressRecords.length - 1 ? 'mb-0' : ''}`}>
<View className='flex flex-col items-center mr-3'>
<View className='w-3 h-3 rounded-full mt-1 bg-green-500' />
{index < saleInfo.progressRecords.length - 1 && (
<View className='w-px flex-1 bg-green-500' />
)}
</View>
<View className='flex-1 pb-3'>
<Text className='text-sm font-medium text-gray-800 block'>{record.status}</Text>
<Text className='text-xs text-gray-500 mt-0 block'>{record.description}</Text>
{record.remark && <Text className='text-xs text-gray-400 mt-1 block'>{record.remark}</Text>}
<Text className='text-xs text-gray-400 mt-1 block'>{record.time}</Text>
</View>
</View>
))}
</View>
)}
{/* 商品信息 */}
{saleInfo.goodsName && (
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex items-center gap-2'>
<View className='w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0'>
<Text className='text-2xl'>🛍</Text>
</View>
<Text className='text-sm text-gray-700 flex-1'>{saleInfo.goodsName}</Text>
</View>
</View>
)}
{/* 操作按钮 */}
{saleInfo.status === 'pending' && (
<View className='p-3'>
<View
className='text-center py-3 rounded-full border border-red-500'
onClick={handleCancel}
>
<Text className='text-sm text-red-500'></Text>
</View>
</View>
)}
<View className='h-6' />
</ScrollView>
</View>
)
}
export default AfterSaleProgressPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '申请售后',
}

151
src_bak/pages/apply.tsx Normal file
View File

@@ -0,0 +1,151 @@
import { View, Text, Input, Textarea } from '@tarojs/components';
import { useState } from 'react';
import NavBar from '@/components/NavBar';
export default function InvoiceApplyPage() {
const [invoiceType, setInvoiceType] = useState<'personal' | 'company'>('personal');
const [formData, setFormData] = useState({
title: '',
taxNumber: '',
content: '商品明细',
amount: '',
email: '',
remark: ''
});
const handleSubmit = () => {
console.log('提交发票申请', formData);
};
return (
<View className="bg-gray-100 flex flex-col" style={{ minHeight: '100vh' }}>
<NavBar title="申请发票" />
<View className="flex-1 p-4">
{/* 发票类型 */}
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="flex" style={{ gap: '16px' }}>
<View
className={`flex-1 p-3 rounded-lg border-2 text-center ${
invoiceType === 'personal'
? 'border-red-500 bg-red-50'
: 'border-gray-300'
}`}
onClick={() => setInvoiceType('personal')}
>
<Text className={invoiceType === 'personal' ? 'text-red-500' : 'text-gray-600'}>
</Text>
</View>
<View
className={`flex-1 p-3 rounded-lg border-2 text-center ${
invoiceType === 'company'
? 'border-red-500 bg-red-50'
: 'border-gray-300'
}`}
onClick={() => setInvoiceType('company')}
>
<Text className={invoiceType === 'company' ? 'text-red-500' : 'text-gray-600'}>
</Text>
</View>
</View>
</View>
{/* 发票信息 */}
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入发票抬头"
value={formData.title}
onInput={(e: any) => setFormData(prev => ({ ...prev, title: e.detail.value }))}
/>
</View>
{invoiceType === 'company' && (
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入纳税人识别号"
value={formData.taxNumber}
onInput={(e: any) => setFormData(prev => ({ ...prev, taxNumber: e.detail.value }))}
/>
</View>
)}
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<View className="flex" style={{ gap: '16px' }}>
<View
className={`px-4 py-2 rounded-lg ${
formData.content === '商品明细' ? 'bg-red-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setFormData(prev => ({ ...prev, content: '商品明细' }))}
>
<Text></Text>
</View>
<View
className={`px-4 py-2 rounded-lg ${
formData.content === '商品类别' ? 'bg-red-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setFormData(prev => ({ ...prev, content: '商品类别' }))}
>
<Text></Text>
</View>
</View>
</View>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入发票金额"
type="digit"
value={formData.amount}
onInput={(e: any) => setFormData(prev => ({ ...prev, amount: e.detail.value }))}
/>
</View>
</View>
{/* 接收方式 */}
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入接收邮箱"
value={formData.email}
onInput={(e: any) => setFormData(prev => ({ ...prev, email: e.detail.value }))}
/>
</View>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Textarea
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="选填,可填写备注信息"
value={formData.remark}
onInput={(e: any) => setFormData(prev => ({ ...prev, remark: e.detail.value }))}
/>
</View>
</View>
</View>
<View className="p-4 bg-white border-t border-gray-200" style={{ paddingBottom: '20px' }}>
<View
className="bg-red-500 text-white rounded-full w-full h-12 flex items-center justify-center"
onClick={handleSubmit}
>
<Text className="text-white font-medium"></Text>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useRequest } from '@/hooks/useRequest'
import { pageUserBalanceLog, type UserBalanceLog } from '@/api/system/user/balance-log'
definePageConfig({
navigationBarTitleText: '余额明细',
})
// 场景类型映射
const SCENE_MAP: Record<number, string> = {
0: '充值',
1: '消费',
2: '退款',
3: '提现',
4: '收入',
5: '支出',
6: '转账',
7: '收款',
}
const tabs = [
{ label: '全部', value: -1 },
{ label: '收入', value: 1 },
{ label: '支出', value: 0 },
]
const BalanceLogPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(-1)
const [page, setPage] = useState(1)
const [logs, setLogs] = useState<UserBalanceLog[]>([])
const [hasMore, setHasMore] = useState(true)
// 获取余额日志
const { run: fetchLogs, loading } = useRequest(pageUserBalanceLog, {
manual: true,
onSuccess: (data) => {
if (data?.list) {
if (page === 1) {
setLogs(data.list)
} else {
setLogs(prev => [...prev, ...data.list])
}
setHasMore(data.list.length >= 20)
}
},
onError: (err) => {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
}
})
// 加载数据
const loadData = (pageNum: number = 1) => {
const params: any = { page: pageNum, limit: 20 }
if (activeTab === 1) {
params.moneyGt = 0 // 收入
} else if (activeTab === 0) {
params.moneyLt = 0 // 支出
}
fetchLogs(params)
}
// 初始化加载
useEffect(() => {
setPage(1)
loadData(1)
}, [activeTab])
// 加载更多
const loadMore = () => {
if (loading || !hasMore) return
const nextPage = page + 1
setPage(nextPage)
loadData(nextPage)
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
const num = parseFloat(money)
return num > 0 ? `+${num.toFixed(2)}` : num.toFixed(2)
}
// 获取场景描述
const getSceneText = (scene?: number) => {
return SCENE_MAP[scene || 0] || '其他'
}
return (
<View className='min-h-screen bg-gray-50'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{tabs.map(tab => (
<View
key={tab.value}
className={`flex-1 text-center py-3 relative ${
activeTab === tab.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveTab(tab.value)}
>
<Text className='text-sm'>{tab.label}</Text>
{activeTab === tab.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
scrollY
className='flex-1'
onScrollToLower={loadMore}
>
{logs.length === 0 && !loading ? (
<View className='text-center py-16'>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<View className='p-3'>
{logs.map(log => (
<View key={log.logId} className='bg-white rounded-lg p-4 mb-2'>
<View className='flex justify-between items-center'>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'>
{log.describe || getSceneText(log.scene)}
</Text>
<Text className='text-xs text-gray-400 mt-0 block'>{log.createTime}</Text>
</View>
<Text className={`text-base font-bold ${
parseFloat(log.money || '0') > 0 ? 'text-green-600' : 'text-red-500'
}`}>
{formatMoney(log.money)}
</Text>
</View>
{log.balance !== undefined && (
<View className='mt-2 pt-2 border-t border-gray-50'>
<Text className='text-xs text-gray-400'>
: ¥{parseFloat(String(log.balance)).toFixed(2)}
</Text>
</View>
)}
</View>
))}
{/* 加载更多 */}
{hasMore && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'>
{loading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
)}
{!hasMore && logs.length > 0 && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
)}
</ScrollView>
</View>
)
}
export default BalanceLogPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '礼品卡余额',
}

187
src_bak/pages/balance.tsx Normal file
View File

@@ -0,0 +1,187 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listMyGiftCards, getGiftCardBalance } from '@/api/shop/shopGiftCard'
import type { ShopGiftCard } from '@/api/shop/shopGiftCard/model'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '礼品卡余额',
})
const GiftCardBalancePage: React.FC = () => {
const [balance, setBalance] = useState(0)
const [cards, setCards] = useState<ShopGiftCard[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
// 加载礼品卡数据
const loadGiftCards = async () => {
try {
// 并行请求余额和列表
const [balanceRes, cardsRes] = await Promise.all([
getGiftCardBalance(),
listMyGiftCards()
])
if (balanceRes.code === 0 && balanceRes.data) {
setBalance(balanceRes.data.giftCards || 0)
}
if (cardsRes.code === 0 && cardsRes.data) {
setCards(cardsRes.data)
}
} catch (e) {
console.error('获取礼品卡失败:', e)
Taro.showToast({ title: '获取数据失败', icon: 'none' })
} finally {
setLoading(false)
}
}
useEffect(() => {
loadGiftCards()
}, [])
// 获取状态标签
const getStatusLabel = (status?: number) => {
const map: Record<number, { label: string; color: string }> = {
1: { label: '可使用', color: 'text-green-500' },
2: { label: '已用完', color: 'text-gray-400' },
3: { label: '已过期', color: 'text-red-500' },
}
return map[status || 1] || { label: '未知', color: 'text-gray-400' }
}
// 格式化兑换码
const formatCode = (code?: string) => {
if (!code) return ''
return code.split('-').join(' ')
}
// 删除礼品卡
const handleDelete = (id?: number) => {
Taro.showModal({
title: '提示',
content: '确定删除该礼品卡记录吗?',
success: (res) => {
if (res.confirm) {
setCards(prev => prev.filter(card => card.cardId !== id && card.id !== id))
Taro.showToast({ title: '删除成功', icon: 'success' })
}
}
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 总余额 */}
<View className='p-6 text-white' style={{ background: 'linear-gradient(to right, #c084fc, #f472b6)' }}>
<Text className='text-sm opacity-80 block mb-2'></Text>
<Text className='text-4xl font-bold block mb-3'>¥{balance.toFixed(2)}</Text>
<View className='flex gap-4'>
<View className='rounded-lg px-3 py-1' style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}>
<Text className='text-xs text-white'>
{cards.filter(c => c.status === 1).length}
</Text>
</View>
<View
className='rounded-lg px-3 py-1'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: '/pages/gift-card/exchange' })}
>
<Text className='text-xs text-white'> </Text>
</View>
</View>
</View>
{/* 礼品卡列表 */}
<View className='p-3'>
<Text className='text-sm text-gray-500 mb-2 block px-1'></Text>
{cards.length === 0 ? (
<View className='text-center py-16'>
<Text className='text-4xl mb-3 block'>🎁</Text>
<Text className='text-sm text-gray-400 mb-3 block'></Text>
<View
className='inline-block bg-purple-500 text-white px-4 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/gift-card/purchase' })}
>
<Text className='text-sm'></Text>
</View>
</View>
) : (
cards.map(card => {
const cardId = card.cardId || card.id
const statusInfo = getStatusLabel(card.status)
return (
<View key={cardId} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
{/* 顶部状态 */}
<View className='flex justify-between items-center mb-3'>
<Text className={`text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</Text>
<Text className='text-xs text-gray-400'> {card.expireDate || '永久'}</Text>
</View>
{/* 卡信息 */}
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-base font-bold text-gray-800'>¥{card.amount || card.faceValue || '0'}</Text>
</View>
<View className='flex justify-between items-center mb-2'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-base font-bold text-purple-500'>¥{card.remainAmount || card.balance || '0'}</Text>
</View>
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-xs text-gray-800 font-mono'>{formatCode(card.code)}</Text>
</View>
</View>
{/* 操作按钮 */}
<View className='flex gap-2'>
{card.status === 1 && (
<View
className='flex-1 text-center py-2 rounded-lg bg-purple-50'
onClick={() => {
if (card.code) {
Taro.setClipboardData({ data: card.code })
Taro.showToast({ title: '已复制到剪贴板', icon: 'none' })
}
}}
>
<Text className='text-xs text-purple-500'></Text>
</View>
)}
<View
className='flex-1 text-center py-2 rounded-lg bg-gray-50'
onClick={() => handleDelete(cardId)}
>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
</View>
)
})
)}
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default GiftCardBalancePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '穿线预约',
}

167
src_bak/pages/booking.tsx Normal file
View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '穿线预约',
})
const BookingPage: React.FC = () => {
const { isLoggedIn } = useUser()
const { storeId } = Taro.getCurrentInstance().router?.params || {}
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [serviceType, setServiceType] = useState('')
const [contactName, setContactName] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [remark, setRemark] = useState('')
const scrollHeight = useScrollHeight(100)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
}
}, [isLoggedIn])
const timeSlots = [
'09:00-10:00',
'10:00-11:00',
'11:00-12:00',
'14:00-15:00',
'15:00-16:00',
'16:00-17:00',
'17:00-18:00',
]
const serviceTypes = ['穿线服务', '穿线+手胶', '穿线+毛巾胶', '其他']
const handleSubmit = () => {
if (!date || !time || !serviceType || !contactName || !contactPhone) {
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
Taro.showModal({
title: '确认预约',
content: `预约时间:${date} ${time}\n服务类型${serviceType}`,
success: (res) => {
if (res.confirm) {
Taro.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
}
},
})
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 选择日期 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<Input
type='date'
value={date}
onInput={(e) => setDate(e.detail.value)}
className='bg-gray-50 rounded-lg p-2 text-sm'
/>
</View>
{/* 选择时间 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='grid grid-cols-3 gap-2'>
{timeSlots.map(slot => (
<View
key={slot}
className={`p-2 rounded-lg text-center text-sm ${
time === slot
? 'bg-green-500 text-white'
: 'bg-gray-50 text-gray-600'
}`}
onClick={() => setTime(slot)}
>
<Text>{slot}</Text>
</View>
))}
</View>
</View>
{/* 服务类型 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='flex flex-wrap gap-2'>
{serviceTypes.map(type => (
<View
key={type}
className={`px-3 py-1 rounded-full text-sm ${
serviceType === type
? 'bg-green-50 text-green-600 border border-green-500'
: 'bg-gray-50 text-gray-600 border border-gray-200'
}`}
onClick={() => setServiceType(type)}
>
<Text>{type}</Text>
</View>
))}
</View>
</View>
{/* 联系人信息 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='mb-2'>
<Text className='text-xs text-gray-500 mb-1 block'></Text>
<Input
value={contactName}
onInput={(e) => setContactName(e.detail.value)}
placeholder='请输入姓名'
className='bg-gray-50 rounded-lg p-2 text-sm w-full'
/>
</View>
<View>
<Text className='text-xs text-gray-500 mb-1 block'></Text>
<Input
type='number'
value={contactPhone}
onInput={(e) => setContactPhone(e.detail.value)}
placeholder='请输入手机号'
className='bg-gray-50 rounded-lg p-2 text-sm w-full'
/>
</View>
</View>
{/* 备注 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg mb-4'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<Input
value={remark}
onInput={(e) => setRemark(e.detail.value)}
placeholder='选填,如有特殊需求请注明'
className='bg-gray-50 rounded-lg p-2 text-sm w-full'
style={{ minHeight: '80px' }}
/>
</View>
</ScrollView>
{/* 提交按钮 */}
<View className='bg-white border-t border-gray-100 p-3' style={{ paddingBottom: '20px' }}>
<Button
type='primary'
className='w-full rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={handleSubmit}
>
</Button>
</View>
</View>
)
}
export default BookingPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '预约详情',
}

View File

@@ -0,0 +1,242 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { getShopBooking, cancelShopBooking } from '@/api/shop/shopBooking'
import type { ShopBooking, BookingStatus } from '@/api/shop/shopBooking/model'
definePageConfig({
navigationBarTitleText: '预约详情',
})
const BookingDetailPage: React.FC = () => {
const router = useRouter()
const [order, setOrder] = useState<ShopBooking | null>(null)
const [loading, setLoading] = useState(true)
const [cancelling, setCancelling] = useState(false)
// 获取预约详情
const loadBookingDetail = async () => {
const id = router.params.id
if (!id) {
Taro.showToast({ title: '参数错误', icon: 'none' })
setLoading(false)
return
}
try {
const data = await getShopBooking(id)
setOrder(data)
} catch (e: any) {
console.error('获取预约详情失败:', e)
Taro.showToast({ title: e.message || '获取详情失败', icon: 'none' })
} finally {
setLoading(false)
}
}
useEffect(() => {
loadBookingDetail()
}, [])
// 获取状态标签
const getStatusInfo = (status: BookingStatus | undefined) => {
const map: Record<BookingStatus, { label: string; color: string; bg: string }> = {
'pending': { label: '待服务', color: 'text-orange-500', bg: 'bg-orange-50' },
'confirmed': { label: '已确认', color: 'text-blue-500', bg: 'bg-blue-50' },
'in_progress': { label: '进行中', color: 'text-blue-500', bg: 'bg-blue-50' },
'completed': { label: '已完成', color: 'text-green-500', bg: 'bg-green-50' },
'cancelled': { label: '已取消', color: 'text-gray-400', bg: 'bg-gray-100' },
'rescheduled': { label: '已改签', color: 'text-purple-500', bg: 'bg-purple-50' },
}
return map[status as BookingStatus] || { label: '未知', color: 'text-gray-400', bg: 'bg-gray-100' }
}
// 取消预约
const handleCancel = () => {
if (!order?.id) return
Taro.showModal({
title: '确认取消',
content: '确定取消该预约吗?\n\n取消后将无法恢复。',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
setCancelling(true)
await cancelShopBooking(order.id)
Taro.showToast({ title: '已取消', icon: 'success' })
// 重新加载详情
setTimeout(() => loadBookingDetail(), 1500)
} catch (e: any) {
console.error('取消预约失败:', e)
Taro.showToast({ title: e.message || '取消失败', icon: 'none' })
} finally {
setCancelling(false)
}
}
}
})
}
// 改签预约
const handleReschedule = () => {
if (order?.id) {
Taro.navigateTo({ url: `/pages/booking/reschedule/index?id=${order.id}` })
}
}
// 返回列表
const handleBackToList = () => {
Taro.navigateBack()
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
if (!order) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<View className='text-center'>
<Text className='text-gray-400 block mb-4'></Text>
<View
className='inline-block bg-orange-500 text-white px-4 py-2 rounded-full'
onClick={handleBackToList}
>
<Text className='text-sm'></Text>
</View>
</View>
</View>
)
}
const statusInfo = getStatusInfo(order.status)
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 状态卡片 */}
<View className='p-4 text-white' style={{ background: 'linear-gradient(to right, #0e932e, #2eb872)' }}>
<View className='flex items-center gap-2'>
<Text className='text-2xl'>📅</Text>
<View>
<Text className='text-xl font-bold block mb-1'>{statusInfo.label}</Text>
<Text className='text-xs opacity-80 block'>{order.bookingNo}</Text>
</View>
</View>
</View>
{/* 预约信息 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4 shadow-sm'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.serviceName || '预约服务'}</Text>
</View>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.bookingDate || '-'}</Text>
</View>
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.bookingTime || '-'}</Text>
</View>
{order.price > 0 && (
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-red-500 font-bold'>¥{order.price}</Text>
</View>
)}
<View className='flex justify-between py-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.remark || '无'}</Text>
</View>
</View>
{/* 联系人信息 */}
{(order.contactName || order.contactPhone) && (
<View className='bg-white mx-3 mt-3 rounded-xl p-4 shadow-sm'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
{order.contactName && (
<View className='flex justify-between py-2 border-b border-gray-50'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.contactName}</Text>
</View>
)}
{order.contactPhone && (
<View className='flex justify-between py-2'>
<Text className='text-sm text-gray-500'></Text>
<Text
className='text-sm text-blue-500'
onClick={() => Taro.makePhoneCall({ phoneNumber: order.contactPhone })}
>
{order.contactPhone} 📞</Text>
</View>
)}
</View>
)}
{/* 门店信息 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4 shadow-sm'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>🏪</Text>
<Text className='text-sm font-medium text-gray-800'>{order.storeName || '门店'}</Text>
</View>
{order.storePhone && (
<View className='flex items-center gap-2 mb-2'>
<Text className='text-sm text-gray-400'>📞</Text>
<Text
className='text-sm text-blue-500'
onClick={() => Taro.makePhoneCall({ phoneNumber: order.storePhone })}
>
{order.storePhone}
</Text>
</View>
)}
{order.address && (
<View className='flex items-start gap-2 mb-2'>
<Text className='text-sm text-gray-400'>📍</Text>
<Text className='text-sm text-gray-600 flex-1'>{order.address}</Text>
</View>
)}
</View>
{/* 创建时间 */}
{order.createTime && (
<View className='mx-3 mt-3 mb-4'>
<Text className='text-xs text-gray-400'>{order.createTime}</Text>
</View>
)}
{/* 操作按钮 - 仅待服务状态显示 */}
{order.status === 'pending' && (
<View className='p-3 gap-3 mx-3'>
<View
className='text-center py-3 rounded-full border border-red-500 bg-white mb-3'
onClick={handleCancel}
>
<Text className='text-red-500 font-medium'>{cancelling ? '取消中...' : '取消预约'}</Text>
</View>
<View
className='text-center py-3 rounded-full bg-green-500'
onClick={handleReschedule}
>
<Text className='text-white font-medium'></Text>
</View>
</View>
)}
<View className='h-6' />
</ScrollView>
</View>
)
}
export default BookingDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '预约订单',
}

View File

@@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopBooking, cancelShopBooking } from '@/api/shop/shopBooking'
import type { ShopBooking, BookingStatus } from '@/api/shop/shopBooking/model'
definePageConfig({
navigationBarTitleText: '预约订单',
})
const BookingListPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(0)
const [orders, setOrders] = useState<ShopBooking[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setOrders([])
setPage(1)
setFinished(false)
loadList(1)
}, [activeTab])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
// 根据 tab 筛选状态
let status: BookingStatus | undefined
if (activeTab === 1) status = 'pending'
else if (activeTab === 2) status = 'in_progress'
else if (activeTab === 3) status = 'completed'
const res = await pageShopBooking({
page: p,
limit: 10,
status,
})
if (res?.list) {
const newList = res.list
const total = res.count || 0
if (p === 1) {
setOrders(newList)
} else {
setOrders(prev => [...prev, ...newList])
}
// 判断是否已加载完所有数据
setFinished(newList.length === 0 || orders.length + newList.length >= total)
setPage(p)
}
} catch (err) {
console.error('加载预约订单失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 加载更多
const handleLoadMore = () => {
if (!finished && !loading) {
loadList(page + 1)
}
}
// 获取状态标签
const getStatusLabel = (status: BookingStatus) => {
const map: Record<BookingStatus, { label: string; color: string }> = {
'pending': { label: '待服务', color: 'text-orange-500' },
'confirmed': { label: '已确认', color: 'text-blue-500' },
'in_progress': { label: '进行中', color: 'text-blue-500' },
'completed': { label: '已完成', color: 'text-green-500' },
'cancelled': { label: '已取消', color: 'text-gray-400' },
'rescheduled': { label: '已改签', color: 'text-purple-500' },
}
return map[status] || { label: '未知', color: 'text-gray-400' }
}
// 取消预约
const handleCancel = async (id: string) => {
Taro.showModal({
title: '确认取消',
content: '确定取消该预约吗?\n\n取消后款项将退回您的余额。',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await cancelShopBooking(id)
Taro.showToast({ title: '已取消,款项已退回余额', icon: 'success' })
// 重新加载列表
setOrders([])
setPage(1)
setFinished(false)
loadList(1)
} catch (err: any) {
console.error('取消预约失败', err)
Taro.showToast({ title: err?.message || '取消失败', icon: 'none' })
}
}
}
})
}
// 改签预约
const handleReschedule = (id: string) => {
Taro.navigateTo({ url: `/pages/booking/reschedule/index?id=${id}` })
}
// 查看详情
const handleDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/booking/detail/index?id=${id}` })
}
return (
<View className='h-screen bg-gray-50 flex flex-col'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{['全部', '待服务', '进行中', '已完成'].map((tab, index) => (
<View
key={index}
className={`flex-1 text-center py-3 relative ${
activeTab === index ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveTab(index)}
>
<Text className='text-sm'>{tab}</Text>
{activeTab === index && (
<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 scrollY className='flex-1'>
{orders.length === 0 ? (
<View className='text-center py-16'>
<Text className='text-4xl mb-3 block'>📅</Text>
<Text className='text-sm text-gray-400 mb-3 block'></Text>
<View
className='inline-block bg-orange-500 text-white px-4 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/store/list/index' })}
>
<Text className='text-sm'></Text>
</View>
</View>
) : (
<View className='p-3'>
{orders.map(order => {
const statusInfo = getStatusLabel(order.status)
return (
<View key={order.id} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
{/* 顶部状态 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-500'>{order.bookingNo}</Text>
<Text className={`text-sm font-medium ${statusInfo.color}`}>
{statusInfo.label}
</Text>
</View>
{/* 预约信息 */}
<View className='flex items-center gap-2 mb-3'>
<View className='w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center flex-shrink-0'>
<Text className='text-xl'>🏪</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 font-medium block'>{order.storeName}</Text>
<Text className='text-xs text-gray-500 mt-0 block'>{order.serviceName}</Text>
</View>
</View>
{/* 预约时间 */}
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.bookingDate}</Text>
</View>
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{order.bookingTime}</Text>
</View>
</View>
{/* 操作按钮 */}
{order.status === 'pending' && (
<View className='flex gap-2 pt-2 border-t border-gray-50'>
<View
className='flex-1 text-center py-2 rounded-full border border-red-500'
onClick={() => handleCancel(order.id)}
>
<Text className='text-xs text-red-500'></Text>
</View>
<View
className='flex-1 text-center py-2 rounded-full bg-orange-500'
onClick={() => handleDetail(order.id)}
>
<Text className='text-xs text-white'></Text>
</View>
</View>
)}
{order.status !== 'pending' && (
<View className='flex justify-end pt-2 border-t border-gray-50'>
<View
className='text-center py-2 px-4 rounded-full bg-orange-500'
onClick={() => handleDetail(order.id)}
>
<Text className='text-xs text-white'></Text>
</View>
</View>
)}
</View>
)
})}
</View>
)}
</ScrollView>
</View>
)
}
export default BookingListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '改签预约',
}

View File

@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { getShopBooking, rescheduleShopBooking } from '@/api/shop/shopBooking'
import type { ShopBooking } from '@/api/shop/shopBooking/model'
import dayjs from 'dayjs'
definePageConfig({
navigationBarTitleText: '改签预约',
})
const BookingReschedulePage: React.FC = () => {
const router = useRouter()
const bookingId = router.params.id
const [originalBooking, setOriginalBooking] = useState<ShopBooking | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [newDate, setNewDate] = useState('')
const [newTime, setNewTime] = useState('')
const getDateRange = () => {
const dates: { value: string; label: string }[] = []
const today = dayjs()
for (let i = 1; i <= 14; i++) {
const d = today.add(i, 'day')
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
dates.push({
value: d.format('YYYY-MM-DD'),
label: `${d.month() + 1}${d.date()}${weekDays[d.day()]}`,
})
}
return dates
}
const dateRange = getDateRange()
const timeSlots = [
{ label: '09:00-10:00', value: '09:00-10:00' },
{ label: '10:00-11:00', value: '10:00-11:00' },
{ label: '11:00-12:00', value: '11:00-12:00' },
{ label: '14:00-15:00', value: '14:00-15:00' },
{ label: '15:00-16:00', value: '15:00-16:00' },
{ label: '16:00-17:00', value: '16:00-17:00' },
{ label: '17:00-18:00', value: '17:00-18:00' },
]
useEffect(() => {
if (!bookingId) {
Taro.showToast({ title: '参数错误', icon: 'none' })
setLoading(false)
return
}
fetchBookingDetail()
}, [bookingId])
const fetchBookingDetail = async () => {
try {
const data = await getShopBooking(bookingId)
setOriginalBooking(data)
} catch (e: any) {
console.error('获取预约详情失败:', e)
Taro.showToast({ title: e.message || '获取详情失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleSubmit = () => {
if (!newDate) {
Taro.showToast({ title: '请选择新日期', icon: 'none' })
return
}
if (!newTime) {
Taro.showToast({ title: '请选择新时段', icon: 'none' })
return
}
const oldDate = originalBooking?.bookingDate || '-'
const oldTime = originalBooking?.bookingTime || '-'
const confirmContent = `确定将预约从\n${oldDate} ${oldTime}\n改签至\n${newDate} ${newTime}吗?`
Taro.showModal({
title: '确认改签',
content: confirmContent,
confirmText: '确认改签',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
await submitReschedule()
}
}
})
}
const submitReschedule = async () => {
try {
setSubmitting(true)
await rescheduleShopBooking({
bookingId: bookingId,
newDate: newDate,
newTime: newTime,
})
Taro.showToast({ title: '改签成功', icon: 'success' })
setTimeout(() => {
Taro.navigateBack()
}, 1500)
} catch (e: any) {
console.error('改签失败:', e)
Taro.showToast({ title: e.message || '改签失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
if (!originalBooking) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'></Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='bg-gray-50 rounded-lg p-3'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{originalBooking.id}</Text>
</View>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{originalBooking.serviceName}</Text>
</View>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{originalBooking.bookingDate}</Text>
</View>
<View className='flex justify-between items-center'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{originalBooking.bookingTime}</Text>
</View>
</View>
</View>
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<ScrollView scrollX className='whitespace-nowrap'>
<View className='flex gap-2'>
{dateRange.map(date => (
<View
key={date.value}
className={`inline-block px-3 py-2 rounded-lg text-center min-w-20 ${
newDate === date.value
? 'bg-green-500 text-white'
: 'bg-gray-50 text-gray-600'
}`}
onClick={() => setNewDate(date.value)}
>
<Text className='text-xs block'>{date.label}</Text>
</View>
))}
</View>
</ScrollView>
</View>
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-wrap gap-2'>
{timeSlots.map(slot => (
<View
key={slot.value}
className={`px-4 py-2 rounded-lg text-center ${
newTime === slot.value
? 'bg-green-500 text-white'
: 'bg-gray-50 text-gray-600'
}`}
onClick={() => setNewTime(slot.value)}
>
<Text className='text-sm'>{slot.label}</Text>
</View>
))}
</View>
</View>
<View className='bg-white mx-3 mt-3 rounded-xl p-4 mb-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='text-xs text-gray-500 leading-6 space-y-1'>
<Text className='block'>1. </Text>
<Text className='block'>2. 2</Text>
<Text className='block'>3. </Text>
<Text className='block'>4. </Text>
</View>
</View>
</ScrollView>
<View className='bg-white p-3 border-t border-gray-100' style={{ paddingBottom: '20px' }}>
<Button
type='primary'
block
loading={submitting}
disabled={submitting || !newDate || !newTime}
className='rounded-full'
style={{ backgroundColor: '#0e932e', border: 'none' }}
onClick={handleSubmit}
>
{submitting ? '提交中...' : '确认改签'}
</Button>
</View>
</View>
)
}
export default BookingReschedulePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '佣金明细',
}

View File

@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '佣金明细',
})
const CommissionPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [records, setRecords] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchRecords()
}, [isLoggedIn])
const fetchRecords = async () => {
try {
const data = await listShopDealerWithdraw({})
setRecords(data || [])
} catch (e) {
console.error('获取佣金记录失败:', e)
} finally {
setLoading(false)
}
}
const getStatusText = (status: number) => {
const statusMap: Record<number, string> = {
0: '待审核',
10: '审核通过',
20: '待收款',
30: '已拒绝',
40: '已完成',
}
return statusMap[status] || '未知'
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : records.length === 0 ? (
<EmptyState text='暂无佣金记录' />
) : (
<View className='p-3'>
{records.map((item) => (
<View key={item.id} className='bg-white rounded-lg p-3 mb-2'>
<View className='flex justify-between items-center mb-1'>
<Text className='text-sm font-medium text-gray-800'>
{item.type === 'withdraw' ? '佣金提现' : '佣金收入'}
</Text>
<Text
className={`text-sm font-bold ${
item.amount > 0 ? 'text-green-500' : 'text-red-500'
}`}
>
{item.amount > 0 ? `+${item.amount}` : item.amount}
</Text>
</View>
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-400'>{item.createTime}</Text>
<Text className='text-xs text-gray-500'>{getStatusText(item.status)}</Text>
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default CommissionPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '在线客服',
}

View File

@@ -0,0 +1,406 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { View, Text, Button, Input, ScrollView } from '@tarojs/components'
import Taro, { useReachBottom } from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import LoadMore from '@/components/common/LoadMore'
import EmptyState from '@/components/common/EmptyState'
import { pageShopChatConversation, addShopChatConversation } from '@/api/shop/shopChatConversation'
import { pageShopChatMessage, addShopChatMessage } from '@/api/shop/shopChatMessage'
import type { ShopChatConversation, ShopChatConversationParam } from '@/api/shop/shopChatConversation/model'
import type { ShopChatMessage } from '@/api/shop/shopChatMessage/model'
import type { PageResult } from '@/api'
definePageConfig({
navigationBarTitleText: '在线客服',
})
const SERVICE_ONLINE_HOURS = '9:00-21:00'
const SERVICE_HOTLINE = '400-888-8888'
const SERVICE_WECHAT = 'shop_service'
const CustomerServicePage: React.FC = () => {
const [conversations, setConversations] = useState<ShopChatConversation[]>([])
const [currentConversation, setCurrentConversation] = useState<ShopChatConversation | null>(null)
const [messages, setMessages] = useState<ShopChatMessage[]>([])
const [inputMessage, setInputMessage] = useState('')
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [msgPage, setMsgPage] = useState(1)
const [showHistory, setShowHistory] = useState(false)
const scrollToBottom = useRef(false)
const isOnline = () => {
const now = new Date()
const h = now.getHours()
return h >= 9 && h < 21
}
// 加载会话列表
const loadConversations = useCallback(async () => {
setLoading(true)
try {
const params: ShopChatConversationParam = {
page: 1,
limit: 20,
order: 'desc',
sort: 'updateTime',
}
const result: PageResult<ShopChatConversation> = await pageShopChatConversation(params)
if (result?.list) {
setConversations(result.list)
if (result.list.length > 0) {
const latest = result.list[0]
setCurrentConversation(latest)
loadMessages(latest.id!, 1, false)
}
}
} catch (e) {
console.error('获取会话列表失败:', e)
setConversations([])
} finally {
setLoading(false)
}
}, [])
// 加载消息列表
const loadMessages = useCallback(async (conversationId: number, pageNum: number = 1, isLoadMore = false) => {
if (isLoadMore) setLoadingMore(true)
try {
const result = await pageShopChatMessage({
conversationId,
page: pageNum,
limit: 20,
order: 'asc',
sort: 'createTime',
} as any)
if (result?.list) {
if (isLoadMore) {
setMessages(prev => [...result.list!, ...prev])
} else {
setMessages(result.list)
scrollToBottom.current = true
}
setHasMore((result.list.length ?? 0) >= 20)
setMsgPage(pageNum)
}
} catch (e) {
console.error('获取消息列表失败:', e)
if (!isLoadMore) setMessages([])
} finally {
setLoadingMore(false)
}
}, [])
useEffect(() => {
loadConversations()
}, [loadConversations])
useReachBottom(() => {
if (hasMore && currentConversation && !loadingMore) {
loadMessages(currentConversation.id!, msgPage + 1, true)
}
})
// 创建新会话
const ensureConversation = async (): Promise<ShopChatConversation | null> => {
if (currentConversation) return currentConversation
try {
await addShopChatConversation({ type: 0 })
// 重新拉取会话列表获取新会话
const result = await pageShopChatConversation({ page: 1, limit: 20, order: 'desc', sort: 'updateTime' })
if (result?.list && result.list.length > 0) {
const conv = result.list[0]
setConversations(result.list)
setCurrentConversation(conv)
return conv
}
} catch (e) {
console.error('创建会话失败:', e)
}
return null
}
// 发送消息
const handleSendMessage = async () => {
const text = inputMessage.trim()
if (!text) {
Taro.showToast({ title: '请输入消息内容', icon: 'none' })
return
}
setSending(true)
setInputMessage('')
try {
const conv = await ensureConversation()
if (!conv?.id) {
Taro.showToast({ title: '无法创建会话,请稍后重试', icon: 'none' })
setInputMessage(text)
return
}
await addShopChatMessage({
content: text,
type: 'text',
toUserId: 0, // 发送给客服(服务端路由)
} as any)
// 消息发送成功后刷新消息列表
await loadMessages(conv.id, 1, false)
// 同步更新会话列表 lastMessage
setConversations(prev => prev.map(c =>
c.id === conv.id ? { ...c, lastMessage: text, updateTime: new Date().toISOString() } : c
))
} catch (e: any) {
console.error('发送消息失败:', e)
setInputMessage(text)
Taro.showToast({ title: e?.message || '发送失败,请重试', icon: 'none' })
} finally {
setSending(false)
}
}
// 拨打热线
const handleCallHotline = () => {
Taro.makePhoneCall({ phoneNumber: SERVICE_HOTLINE })
}
// 复制微信号
const handleCopyWechat = () => {
Taro.setClipboardData({
data: SERVICE_WECHAT,
success: () => Taro.showToast({ title: '已复制微信号', icon: 'success' }),
})
}
// 跳转帮助中心
const handleGoToHelp = () => {
Taro.navigateTo({ url: '/pages/user/help-center' })
}
// 选择会话
const handleSelectConversation = (conv: ShopChatConversation) => {
setCurrentConversation(conv)
setMessages([])
setMsgPage(1)
setHasMore(true)
loadMessages(conv.id!, 1, false)
}
const formatTime = (timeStr?: string) => {
if (!timeStr) return ''
const date = new Date(timeStr)
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${m}-${d} ${h}:${min}`
}
const online = isOnline()
return (
<View className="flex flex-col min-h-screen bg-gray-100">
<NavBar title="在线客服" />
<ScrollView scrollY className="flex-1">
{/* 客服状态栏 */}
<View className="bg-white px-4 py-3 flex items-center justify-between">
<View className="flex items-center gap-2">
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: online ? '#52c41a' : '#d1d5db' }}
/>
<Text className="text-sm text-gray-700">{online ? '客服在线' : '客服离线'}</Text>
</View>
<Text className="text-xs text-gray-400">: {SERVICE_ONLINE_HOURS}</Text>
</View>
{/* 微信在线客服按钮 */}
<View className="px-4 py-3 bg-white border-t border-gray-50">
<Button
openType="contact"
className="w-full h-12 rounded-lg text-white font-medium text-base border-0"
style={{ backgroundColor: '#07c160', lineHeight: '48px' }}
>
线
</Button>
</View>
{/* 消息记录入口 */}
{!showHistory && (
<View className="bg-white mx-4 mt-4 p-4 rounded-lg">
<View
className="flex items-center justify-between"
onClick={() => setShowHistory(true)}
>
<View className="flex items-center gap-2">
<Text className="text-lg">💬</Text>
<Text className="text-sm text-gray-700">
{conversations.length > 0 ? `${conversations.length}条会话)` : ''}
</Text>
</View>
<Text className="text-gray-400 text-sm"></Text>
</View>
</View>
)}
{/* 历史会话与消息列表 */}
{showHistory && (
<View className="mx-4 mt-4">
<View className="flex items-center justify-between mb-2">
<Text className="text-sm font-medium text-gray-700"></Text>
<Text
className="text-sm"
style={{ color: '#3b82f6' }}
onClick={() => setShowHistory(false)}
>
</Text>
</View>
{loading ? (
<LoadMore loading />
) : conversations.length === 0 ? (
<EmptyState message="暂无历史会话" small />
) : (
<View>
{conversations.map(conv => (
<View
key={conv.id}
className="bg-white p-3 rounded-lg mb-2"
style={{ border: currentConversation?.id === conv.id ? '2px solid #10b981' : 'none' }}
onClick={() => handleSelectConversation(conv)}
>
<View className="flex justify-between items-center">
<Text className="text-sm text-gray-700" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conv.content || conv.lastMessage || '暂无消息'}
</Text>
<Text className="text-xs text-gray-400 ml-2">{formatTime(conv.updateTime)}</Text>
</View>
</View>
))}
</View>
)}
{/* 当前会话的消息 */}
{currentConversation && messages.length > 0 && (
<View className="bg-white rounded-lg p-3 mb-4">
<Text className="text-xs text-gray-400 mb-3 block"></Text>
{loadingMore && <LoadMore loading />}
{messages.map(msg => (
<View
key={msg.id}
className={`flex mb-3 ${msg.formUserId === currentConversation.userId ? 'justify-end' : 'justify-start'}`}
>
<View
className="px-3 py-2 rounded-lg max-w-xs"
style={{
backgroundColor: msg.formUserId === currentConversation.userId ? '#10b981' : '#f3f4f6',
maxWidth: '70%',
}}
>
<Text
className="text-sm"
style={{ color: msg.formUserId === currentConversation.userId ? '#fff' : '#374151' }}
>
{msg.content}
</Text>
<Text
className="text-xs block mt-1"
style={{ color: msg.formUserId === currentConversation.userId ? 'rgba(255,255,255,0.7)' : '#9ca3af' }}
>
{formatTime(msg.createTime)}
</Text>
</View>
</View>
))}
</View>
)}
</View>
)}
{/* 其他联系方式 */}
<View className="bg-white mx-4 mt-4 p-4 rounded-lg">
<Text className="text-base font-medium text-gray-800 mb-3 block"></Text>
<View
className="flex items-center justify-between py-3 border-b border-gray-50"
onClick={handleCallHotline}
>
<View className="flex items-center gap-2">
<Text className="text-lg">📞</Text>
<View>
<Text className="text-sm text-gray-700 block">线</Text>
<Text className="text-xs text-gray-400">{SERVICE_HOTLINE}</Text>
</View>
</View>
<Text className="text-xs" style={{ color: '#3b82f6' }}></Text>
</View>
<View
className="flex items-center justify-between py-3"
onClick={handleCopyWechat}
>
<View className="flex items-center gap-2">
<Text className="text-lg">💬</Text>
<View>
<Text className="text-sm text-gray-700 block"></Text>
<Text className="text-xs text-gray-400">{SERVICE_WECHAT}</Text>
</View>
</View>
<Text className="text-xs" style={{ color: '#3b82f6' }}></Text>
</View>
</View>
{/* 帮助中心入口 */}
<View className="bg-white mx-4 mt-4 p-4 rounded-lg mb-4">
<Text className="text-base font-medium text-gray-800 mb-3 block"></Text>
<View
className="flex items-center justify-between py-2"
onClick={handleGoToHelp}
>
<Text className="text-sm text-gray-700"></Text>
<Text className="text-gray-400 text-sm"></Text>
</View>
</View>
{/* 留言(离线时) */}
{!online && (
<View className="bg-white mx-4 mb-4 p-4 rounded-lg">
<Text className="text-base font-medium text-gray-800 mb-3 block">线</Text>
<Text className="text-sm text-gray-400 block">
线线
</Text>
</View>
)}
</ScrollView>
{/* 消息输入区 - 始终显示,支持发送留言 */}
<View className="bg-white border-t border-gray-200 px-4 py-3 flex items-center gap-2">
<Input
className="flex-1 border border-gray-200 rounded-full text-sm"
style={{ paddingLeft: '16px', paddingRight: '16px', height: '40px', lineHeight: '40px' }}
placeholder={online ? '输入消息...' : '留言给客服...'}
value={inputMessage}
onInput={(e: any) => setInputMessage(e.detail.value)}
onConfirm={handleSendMessage}
disabled={sending}
/>
<View
className="rounded-full flex items-center justify-center"
style={{
width: '72px',
height: '40px',
backgroundColor: sending ? '#d1d5db' : '#10b981',
}}
onClick={sending ? undefined : handleSendMessage}
>
<Text className="text-sm text-white">{sending ? '发送中' : '发送'}</Text>
</View>
</View>
</View>
)
}
export default CustomerServicePage

198
src_bak/pages/dashboard.tsx Normal file
View File

@@ -0,0 +1,198 @@
import React, { useEffect, useState, useCallback } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import { pageShopOrder } from '@/api/shop/shopOrder'
definePageConfig({
navigationBarTitleText: '数据看板',
})
interface DashboardStats {
orders: number
revenue: number
paidOrders: number
pendingOrders: number
canceledOrders: number
}
const StatisticsDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true)
const [todayStats, setTodayStats] = useState<DashboardStats>({ orders: 0, revenue: 0, paidOrders: 0, pendingOrders: 0, canceledOrders: 0 })
const [weekStats, setWeekStats] = useState<DashboardStats>({ orders: 0, revenue: 0, paidOrders: 0, pendingOrders: 0, canceledOrders: 0 })
const formatDate = (date: Date) => {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
const calcStats = (orders: any[]): DashboardStats => {
const paidOrders = orders.filter(o => o.payStatus === true || o.payStatus === 1)
const revenue = paidOrders.reduce((sum, o) => sum + parseFloat(o.payPrice || '0'), 0)
const pendingOrders = orders.filter(o => o.orderStatus === 0 || (!o.payStatus && o.orderStatus !== 2))
const canceledOrders = orders.filter(o => o.orderStatus === 2)
return {
orders: orders.length,
revenue,
paidOrders: paidOrders.length,
pendingOrders: pendingOrders.length,
canceledOrders: canceledOrders.length,
}
}
const loadData = useCallback(async () => {
setLoading(true)
try {
const now = new Date()
const todayStr = formatDate(now)
const weekAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000)
const weekAgoStr = formatDate(weekAgo)
const [todayRes, weekRes] = await Promise.all([
pageShopOrder({ page: 1, limit: 200, startTime: `${todayStr} 00:00:00`, endTime: `${todayStr} 23:59:59` } as any).catch(() => null),
pageShopOrder({ page: 1, limit: 500, startTime: `${weekAgoStr} 00:00:00`, endTime: `${todayStr} 23:59:59` } as any).catch(() => null),
])
if (todayRes?.list) {
setTodayStats(calcStats(todayRes.list))
}
if (weekRes?.list) {
setWeekStats(calcStats(weekRes.list))
}
} catch (e) {
console.error('加载统计数据失败', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
const goTo = (path: string) => {
Taro.navigateTo({ url: path })
}
const renderStatCard = (label: string, value: string | number, color: string, sub?: string) => (
<View className="bg-white rounded-lg p-4">
<Text className="text-sm text-gray-500 mb-2 block">{label}</Text>
<Text className="text-2xl font-bold block" style={{ color }}>{value}</Text>
{sub && <Text className="text-xs mt-1 block" style={{ color: '#52c41a' }}>{sub}</Text>}
</View>
)
return (
<View className="min-h-screen bg-gray-100">
<NavBar title="数据看板" />
<ScrollView scrollY>
{loading ? (
<View className="p-8 text-center">
<Text className="text-gray-400 text-sm">...</Text>
</View>
) : (
<View>
{/* 今日数据 */}
<View className="p-4">
<View className="flex items-center justify-between mb-3">
<Text className="text-base font-medium"></Text>
<Text className="text-xs text-gray-400"></Text>
</View>
<View className="grid grid-cols-2 gap-3 mb-3">
{renderStatCard('今日订单', todayStats.orders, '#3b82f6')}
{renderStatCard('今日销售额', `¥${todayStats.revenue.toFixed(2)}`, '#ef4444')}
{renderStatCard('已支付', todayStats.paidOrders, '#10b981')}
{renderStatCard('待支付', todayStats.pendingOrders, '#f59e0b')}
</View>
{todayStats.canceledOrders > 0 && (
<View className="bg-white rounded-lg px-4 py-3 flex items-center justify-between">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-base font-medium text-gray-500">{todayStats.canceledOrders} </Text>
</View>
)}
</View>
{/* 近7天数据 */}
<View className="p-4 pt-0">
<View className="flex items-center justify-between mb-3">
<Text className="text-base font-medium">7</Text>
<Text className="text-xs text-gray-400"></Text>
</View>
<View className="bg-white rounded-lg p-4">
<View className="flex items-center justify-between py-3 border-b border-gray-50">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-base font-medium">{weekStats.orders} </Text>
</View>
<View className="flex items-center justify-between py-3 border-b border-gray-50">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-base font-medium text-red-500">¥{weekStats.revenue.toFixed(2)}</Text>
</View>
<View className="flex items-center justify-between py-3 border-b border-gray-50">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-base font-medium text-green-500">{weekStats.paidOrders} </Text>
</View>
<View className="flex items-center justify-between py-3">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-base font-medium text-orange-500">
¥{weekStats.paidOrders > 0 ? (weekStats.revenue / weekStats.paidOrders).toFixed(2) : '0.00'}
</Text>
</View>
</View>
</View>
{/* 快捷入口 */}
<View className="p-4 pt-0">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="bg-white rounded-lg overflow-hidden">
<View
className="p-4 border-b border-gray-50 flex items-center justify-between"
onClick={() => goTo('/pages/statistics/sales')}
>
<View className="flex items-center gap-3">
<Text className="text-lg">📊</Text>
<Text className="text-sm text-gray-700"></Text>
</View>
<Text className="text-gray-400"></Text>
</View>
<View
className="p-4 border-b border-gray-50 flex items-center justify-between"
onClick={() => goTo('/pages/statistics/users')}
>
<View className="flex items-center gap-3">
<Text className="text-lg">👥</Text>
<Text className="text-sm text-gray-700"></Text>
</View>
<Text className="text-gray-400"></Text>
</View>
<View
className="p-4 flex items-center justify-between"
onClick={() => goTo('/pages/order/list')}
>
<View className="flex items-center gap-3">
<Text className="text-lg">📋</Text>
<Text className="text-sm text-gray-700"></Text>
</View>
<Text className="text-gray-400"></Text>
</View>
</View>
</View>
{/* 刷新按钮 */}
<View className="p-4 pt-0">
<View
className="bg-white rounded-lg p-4 text-center"
onClick={loadData}
>
<Text className="text-sm text-blue-500"></Text>
</View>
</View>
</View>
)}
</ScrollView>
</View>
)
}
export default StatisticsDashboardPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '消息详情',
}

147
src_bak/pages/detail.tsx Normal file
View File

@@ -0,0 +1,147 @@
import React, { useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
definePageConfig({
navigationBarTitleText: '消息详情',
})
const MessageDetailPage: React.FC = () => {
const [message, setMessage] = useState({
id: 1,
type: 1,
title: '欢迎注册鑫龙家电',
content: '感谢您注册鑫龙家电,让我们一起开启美好购物体验!\n\n在鑫龙家电您可以\n• 浏览精选商品\n• 享受会员专属优惠\n• 参与精彩活动\n• 获得积分奖励\n\n如有任何问题欢迎联系客服。',
time: '2026-05-12 10:30:00',
isRead: true,
})
// 模拟获取消息详情
useEffect(() => {
const params = Taro.getCurrentInstance().router?.params
if (params?.id) {
// 这里应该调用API获取消息详情
console.log('消息ID:', params.id)
// 标记为已读
Taro.showToast({ title: '已标记为已读', icon: 'none' })
}
}, [])
// 获取类型图标
const getTypeIcon = (type: number) => {
const iconMap: Record<number, string> = {
1: '🔔',
2: '📦',
3: '🎁',
}
return iconMap[type] || '📋'
}
// 获取类型名称
const getTypeName = (type: number) => {
const nameMap: Record<number, string> = {
1: '系统通知',
2: '订单通知',
3: '活动通知',
}
return nameMap[type] || '其他'
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 删除消息
const handleDelete = () => {
Taro.showModal({
title: '提示',
content: '确定删除该消息吗?',
confirmColor: '#0e932e',
success: (res) => {
if (res.confirm) {
Taro.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => {
Taro.navigateBack()
}, 1500)
}
}
})
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 消息头部 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-3'>
<Text className='text-2xl'>{getTypeIcon(message.type)}</Text>
<View className='flex-1'>
<Text className='text-lg font-bold text-gray-800 block mb-1'>
{message.title}
</Text>
<View className='flex items-center gap-2'>
<Text className='text-xs text-gray-400'>
{formatTime(message.time)}
</Text>
<View className='bg-gray-100 px-2 py-1 rounded'>
<Text className='text-xs text-gray-500'>
{getTypeName(message.type)}
</Text>
</View>
</View>
</View>
</View>
</View>
{/* 消息内容 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{message.content}
</Text>
</View>
{/* 相关操作 */}
<View className='bg-white p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-col gap-3'>
<View
className='flex items-center justify-between py-2 border-b border-gray-50'
onClick={() => Taro.navigateTo({ url: '/pages/user/user' })}
>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-gray-400'></Text>
</View>
<View
className='flex items-center justify-between py-2 border-b border-gray-50'
onClick={() => Taro.switchTab({ url: '/pages/index/index' })}
>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-gray-400'></Text>
</View>
<View
className='flex items-center justify-between py-2'
onClick={handleDelete}
>
<Text className='text-sm text-red-500'></Text>
<Text className='text-gray-400'></Text>
</View>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default MessageDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '立即报名',
}

View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getShopEvent, getRegistrationStatus, getEventStatusLabel } from '@/api/shop/shopEvent'
import type { ShopEvent, RegistrationStatus, EventFormField } from '@/api/shop/shopEvent'
definePageConfig({ navigationBarTitleText: '赛事详情' })
const EventDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [event, setEvent] = useState<ShopEvent | null>(null)
const [regStatus, setRegStatus] = useState<RegistrationStatus | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchDetail(Number(id))
fetchRegStatus(Number(id))
}
}, [id])
const fetchDetail = async (eventId: number) => {
try {
const data = await getShopEvent(eventId)
setEvent(data)
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const fetchRegStatus = async (eventId: number) => {
try {
const data = await getRegistrationStatus(eventId)
setRegStatus(data)
} catch {
// 未登录时忽略
}
}
const formatDate = (dateStr: string) => {
const d = new Date(dateStr)
return `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
const handleRegister = () => {
if (!event) return
Taro.navigateTo({ url: `/pages/event/register/index?id=${event.id}` })
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
if (!event) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)
}
const statusInfo = getEventStatusLabel(event.status as number)
const isFull = event.maxParticipants > 0 && event.paidCount >= event.maxParticipants
const alreadyPaid = regStatus?.registered && regStatus.payStatus === 1
const pendingPay = regStatus?.registered && regStatus.payStatus === 0
// 解析动态表单字段
let formFields: EventFormField[] = []
if (event.formFields) {
if (Array.isArray(event.formFields)) {
formFields = event.formFields
} else {
try { formFields = JSON.parse(event.formFields) } catch { formFields = [] }
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 封面 */}
{event.image ? (
<Image src={event.image} className='w-full' style={{ height: '200px' }} mode='aspectFill' />
) : (
<View className='w-full flex items-center justify-center' style={{ height: '200px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<Text className='text-6xl'>🏆</Text>
</View>
)}
{/* 基本信息 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-3'>
<Text className='text-xl font-bold text-gray-800 flex-1'>{event.name}</Text>
<View className={`px-2 py-1 rounded ${event.status === 1 ? 'bg-green-50' : 'bg-gray-50'}`}>
<Text className={`text-xs ${statusInfo.color}`}>{statusInfo.label}</Text>
</View>
</View>
<View className='space-y-2 text-sm text-gray-600'>
<View className='flex items-center gap-2'>
<Text className='text-gray-400 w-16'></Text>
<Text>{formatDate(event.eventDate)}</Text>
</View>
{event.location && (
<View className='flex items-center gap-2'>
<Text className='text-gray-400 w-16'></Text>
<Text>{event.location}</Text>
</View>
)}
<View className='flex items-center gap-2'>
<Text className='text-gray-400 w-16'></Text>
<Text>{event.paidCount} / {event.maxParticipants > 0 ? event.maxParticipants : '不限'}</Text>
{isFull && <Text className='text-red-500 text-xs'></Text>}
</View>
<View className='flex items-center gap-2'>
<Text className='text-gray-400 w-16'></Text>
<Text className={event.entryFee > 0 ? 'text-orange-500 font-bold text-base' : 'text-green-500 font-bold'}>
{event.entryFee > 0 ? `¥${event.entryFee}` : '免费'}
</Text>
</View>
</View>
</View>
{/* 报名须知 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-base font-medium text-gray-800 mb-2 block'></Text>
<View className='text-xs text-gray-500 space-y-1'>
{formFields.length > 0 ? (
formFields.map(field => (
<Text key={field.key} className='block'>
{field.required === 1 ? '填写' : '选填'}{field.label}
{field.type === 'select' || field.type === 'radio' ? `${(field.options || []).join('/')}` : ''}
</Text>
))
) : (
<Text className='block'> </Text>
)}
{event.entryFee > 0 && <Text className='block'> </Text>}
</View>
</View>
{/* 赛事描述 */}
{event.description && (
<View className='bg-white p-4 mb-3'>
<Text className='text-base font-medium text-gray-800 mb-2 block'></Text>
<Text className='text-sm text-gray-700 leading-7 whitespace-pre-wrap block'>{event.description}</Text>
</View>
)}
<View className='h-24' />
</ScrollView>
{/* 底部操作栏 */}
<View className='bg-white px-4 py-3 shadow-lg' style={{ paddingBottom: '20px' }}>
{alreadyPaid && (
<View className='bg-green-500 text-center py-3 rounded-full'>
<Text className='text-sm text-white font-bold'> </Text>
</View>
)}
{pendingPay && (
<View
className='bg-orange-500 text-center py-3 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/event/my/index' })}
>
<Text className='text-sm text-white font-bold'> </Text>
</View>
)}
{!regStatus?.registered && event.status === 1 && !isFull && (
<View className='bg-orange-500 text-center py-3 rounded-full' onClick={handleRegister}>
<Text className='text-sm text-white font-bold'>
{event.entryFee > 0 ? `立即报名 ¥${event.entryFee}` : '立即报名(免费)'}
</Text>
</View>
)}
{!regStatus?.registered && event.status === 1 && isFull && (
<View className='bg-gray-300 text-center py-3 rounded-full'>
<Text className='text-sm text-white font-bold'></Text>
</View>
)}
{event.status !== 1 && !alreadyPaid && !pendingPay && (
<View className='bg-gray-300 text-center py-3 rounded-full'>
<Text className='text-sm text-white font-bold'>{statusInfo.label}</Text>
</View>
)}
</View>
</View>
)
}
export default EventDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '报名活动',
}

View File

@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopEvent, getEventStatusLabel } from '@/api/shop/shopEvent'
import type { ShopEvent } from '@/api/shop/shopEvent'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({ navigationBarTitleText: '赛事报名' })
const TABS = ['全部', '未开始', '报名中', '已截止', '已结束']
const STATUS_MAP: Record<number, number | undefined> = { 1: 0, 2: 1, 3: 2, 4: 3 }
const EventListPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(0)
const [events, setEvents] = useState<ShopEvent[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setEvents([])
setPage(1)
setFinished(false)
loadList(1)
}, [activeTab])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await pageShopEvent({
page: p,
limit: 10,
status: STATUS_MAP[activeTab],
})
if (res?.list) {
setEvents(prev => p === 1 ? res.list : [...prev, ...res.list])
setFinished(res.list.length < 10)
setPage(p)
}
} catch (err) {
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const formatDate = (dateStr: string) => {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
return (
<View className='min-h-screen bg-gray-50'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{TABS.map((tab, index) => (
<View
key={index}
className={`flex-1 text-center py-3 relative ${activeTab === index ? 'text-orange-500 font-medium' : 'text-gray-600'}`}
onClick={() => setActiveTab(index)}
>
<Text className='text-sm'>{tab}</Text>
{activeTab === 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={() => !finished && !loading && loadList(page + 1)} lowerThreshold={100}>
<View className='p-3'>
{events.length === 0 && !loading ? (
<EmptyState text='暂无赛事' />
) : (
events.map(event => {
const statusInfo = getEventStatusLabel(event.status as number)
return (
<View
key={event.id}
className='bg-white rounded-xl mb-3 shadow-sm overflow-hidden'
onClick={() => Taro.navigateTo({ url: `/pages/event/detail/index?id=${event.id}` })}
>
{/* 封面 */}
{event.image ? (
<Image src={event.image} className='w-full' style={{ height: '160px' }} mode='aspectFill' />
) : (
<View className='w-full flex items-center justify-center' style={{ height: '140px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<Text className='text-5xl'>🏆</Text>
</View>
)}
<View className='p-4'>
{/* 标题+状态 */}
<View className='flex items-center gap-2 mb-2'>
<Text className='text-base font-bold text-gray-800 flex-1'>{event.name}</Text>
<View className={`px-2 py-1 rounded ${event.status === 1 ? 'bg-green-50' : event.status === 0 ? 'bg-blue-50' : 'bg-gray-50'}`}>
<Text className={`text-xs ${statusInfo.color}`}>{statusInfo.label}</Text>
</View>
{event.isHot === 1 && (
<View className='bg-red-50 px-2 py-1 rounded'>
<Text className='text-xs text-red-500'>🔥</Text>
</View>
)}
</View>
{/* 信息行 */}
<View className='flex items-center gap-3 text-xs text-gray-500 mb-3'>
<Text>📅 {formatDate(event.eventDate)}</Text>
{event.location && <Text>📍 {event.location}</Text>}
</View>
{/* 底部:名额+费用+按钮 */}
<View className='flex items-center justify-between'>
<View className='flex items-center gap-3 text-xs text-gray-500'>
<Text>👥 {event.paidCount}/{event.maxParticipants > 0 ? event.maxParticipants : '不限'}</Text>
<Text className={event.entryFee > 0 ? 'text-orange-500 font-medium' : 'text-green-500'}>
{event.entryFee > 0 ? `¥${event.entryFee}` : '免费'}
</Text>
</View>
{event.status === 1 && (
event.maxParticipants > 0 && event.paidCount >= event.maxParticipants ? (
<View className='bg-gray-200 px-4 py-1 rounded-full'>
<Text className='text-xs text-gray-500'></Text>
</View>
) : (
<View className='bg-orange-500 px-4 py-1 rounded-full'>
<Text className='text-xs text-white font-medium'></Text>
</View>
)
)}
</View>
</View>
</View>
)
})
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default EventListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '我的报名',
}

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { getMyRegistrations, payEventRegistration, getPayStatusLabel } from '@/api/shop/shopEvent'
import type { EventRegistration, EventFormField } from '@/api/shop/shopEvent'
definePageConfig({ navigationBarTitleText: '我的报名' })
const MyRegistrationsPage: React.FC = () => {
const [list, setList] = useState<EventRegistration[]>([])
const [loading, setLoading] = useState(true)
const [paying, setPaying] = useState<number | null>(null)
useDidShow(() => {
fetchList()
})
const fetchList = async () => {
try {
const data = await getMyRegistrations()
setList(data || [])
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleRepay = async (reg: EventRegistration) => {
setPaying(reg.id)
try {
const wxParams = await payEventRegistration(reg.id)
// 后端返回已支付(回调丢失自动修复场景)
if ((wxParams as any).paid === 'true') {
Taro.showToast({ title: '支付成功', icon: 'success' })
setList(prev => prev.map(r => r.id === reg.id ? { ...r, payStatus: 1, paidAt: new Date().toISOString() } : r))
fetchList()
return
}
await Taro.requestPayment({
timeStamp: wxParams.timeStamp,
nonceStr: wxParams.nonceStr,
package: wxParams.package,
signType: wxParams.signType as any,
paySign: wxParams.paySign,
})
// 支付成功后立即更新本地状态,避免后端回调延迟导致仍显示"待缴费"
setList(prev => prev.map(r => r.id === reg.id ? { ...r, payStatus: 1, paidAt: new Date().toISOString() } : r))
Taro.showToast({ title: '支付成功', icon: 'success' })
fetchList()
} catch (err: any) {
if (!err?.errMsg?.includes('cancel')) {
Taro.showToast({ title: err.message || '支付失败', icon: 'none' })
}
} finally {
setPaying(null)
}
}
const formatDate = (dateStr: string) => {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
const fieldLabelMap: Record<string, string> = {
name: '姓名',
phone: '手机号',
idCard: '身份证号',
size: '服装尺码',
email: '邮箱',
gender: '性别',
age: '年龄',
address: '地址',
company: '单位/公司',
job: '职业',
remark: '备注',
emergencyContact: '紧急联系人',
emergencyPhone: '紧急联系人电话',
wechat: '微信号',
team: '团队/队伍',
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY className='flex-1'>
<View className='p-3'>
{list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-20'>
<Text className='text-4xl mb-3'>🏆</Text>
<Text className='text-sm text-gray-400'></Text>
<View
className='mt-4 bg-orange-500 px-6 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/event/list/index' })}
>
<Text className='text-sm text-white'></Text>
</View>
</View>
) : (
list.map(reg => {
// 后端支付回调可能延迟paidAt 有值即视为已缴费
const effectivePayStatus = reg.paidAt ? 1 : reg.payStatus
const payInfo = getPayStatusLabel(effectivePayStatus)
return (
<View key={reg.id} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
<View className='flex items-start justify-between mb-3'>
<Text className='text-base font-bold text-gray-800 flex-1 mr-2'>{reg.eventName || '赛事'}</Text>
<View className={`px-2 py-1 rounded ${effectivePayStatus === 1 ? 'bg-green-50' : effectivePayStatus === 0 ? 'bg-orange-50' : 'bg-gray-50'}`}>
<Text className={`text-xs ${payInfo.color}`}>{payInfo.label}</Text>
</View>
</View>
<View className='text-xs text-gray-500 space-y-1 mb-3'>
{/* 动态表单数据展示 */}
{(() => {
let formData: Record<string, string> = {}
if (reg.formData) {
if (typeof reg.formData === 'string') {
try { formData = JSON.parse(reg.formData) } catch { formData = {} }
} else {
formData = reg.formData as Record<string, string>
}
}
// 去重:过滤掉与英文 key 重复的中文 label key
const labelToKey: Record<string, string> = {}
Object.entries(fieldLabelMap).forEach(([k, v]) => { labelToKey[v] = k })
return Object.entries(formData).filter(([key]) => {
if (fieldLabelMap[key]) return true // 英文 key 保留
if (labelToKey[key]) return false // 中文 label key 跳过(会由英文 key 渲染)
return true // 自定义字段保留
}).map(([key, value]) => (
<Text key={key} className='block'>{fieldLabelMap[key] || key}{value}</Text>
))
})()}
<Text className='block'>
{reg.entryFee > 0 ? `¥${reg.entryFee}` : '免费'}
</Text>
<Text className='block'>{formatDate(reg.createTime)}</Text>
{reg.paidAt && <Text className='block'>{formatDate(reg.paidAt)}</Text>}
</View>
{effectivePayStatus === 0 && (
<View
className='bg-orange-500 text-center py-2 rounded-full'
style={{ opacity: paying === reg.id ? 0.6 : 1 }}
onClick={paying === reg.id ? undefined : () => handleRepay(reg)}
>
<Text className='text-sm text-white font-medium'>
{paying === reg.id ? '支付中...' : `重新支付 ¥${reg.entryFee}`}
</Text>
</View>
)}
</View>
)
})
)}
</View>
</ScrollView>
</View>
)
}
export default MyRegistrationsPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '立即报名',
}

View File

@@ -0,0 +1,317 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Input, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getShopEvent, registerEvent } from '@/api/shop/shopEvent'
import type { ShopEvent, EventFormField } from '@/api/shop/shopEvent'
definePageConfig({ navigationBarTitleText: '填写报名信息' })
/** 解析动态表单字段配置 */
function parseFormFields(event: ShopEvent): EventFormField[] {
// 1. 优先读取 formFields新架构
const raw = (event as any).formFields || (event as any).form_fields
if (raw) {
if (Array.isArray(raw)) return raw as EventFormField[]
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as EventFormField[]
} catch { /* ignore */ }
}
// 2. 兼容旧架构:根据 requireName/requirePhone/requireIdCard/requireSize 构建表单字段
const fields: EventFormField[] = []
let sort = 1
// 从 sizeOptions 解析尺码选项
const sizeOptions: string[] = []
if ((event as any).sizeOptions) {
const opts = String((event as any).sizeOptions).split(/[,]/).map(s => s.trim()).filter(Boolean)
sizeOptions.push(...opts)
}
// 姓名
if ((event as any).requireName === 1) {
fields.push({ key: 'name', label: '姓名', type: 'text', required: 1, placeholder: '请输入姓名', sort: sort++ })
}
// 手机号
if ((event as any).requirePhone === 1) {
fields.push({ key: 'phone', label: '手机号', type: 'phone', required: 1, placeholder: '请输入手机号', sort: sort++ })
}
// 身份证
if ((event as any).requireIdCard === 1) {
fields.push({ key: 'idCard', label: '身份证号', type: 'idcard', required: 1, placeholder: '请输入身份证号', sort: sort++ })
}
// 尺码
if ((event as any).requireSize === 1) {
fields.push({
key: 'size',
label: '服装尺码',
type: 'select',
required: 1,
placeholder: '请选择尺码',
options: sizeOptions.length > 0 ? sizeOptions : ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
sort: sort++,
})
}
return fields.sort((a, b) => a.sort - b.sort)
}
/** 根据字段类型返回校验规则 */
function getFieldValidator(field: EventFormField) {
return (value: string): string | null => {
if (field.required === 1 && !value.trim()) {
return `请填写${field.label}`
}
if (!value.trim()) return null
switch (field.type) {
case 'phone':
if (!/^1[3-9]\d{9}$/.test(value.trim())) {
return '手机号格式不正确'
}
break
case 'idcard':
if (!/^\d{17}[\dXx]$/.test(value.trim())) {
return '身份证号格式不正确'
}
break
case 'number':
if (!/^\d+$/.test(value.trim())) {
return `${field.label}必须为数字`
}
break
}
return null
}
}
const EventRegisterPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [event, setEvent] = useState<ShopEvent | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
// 动态表单数据key -> value
const [formValues, setFormValues] = useState<Record<string, string>>({})
useEffect(() => {
if (id) fetchEvent(Number(id))
}, [id])
const fetchEvent = async (eventId: number) => {
try {
const data = await getShopEvent(eventId)
setEvent(data)
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleFieldChange = (key: string, value: string) => {
setFormValues(prev => ({ ...prev, [key]: value }))
}
const validate = (): boolean => {
if (!event) return false
const fields = parseFormFields(event)
for (const field of fields) {
const validator = getFieldValidator(field)
const error = validator(formValues[field.key] || '')
if (error) {
Taro.showToast({ title: error, icon: 'none' })
return false
}
}
return true
}
/** 将英文 key 映射为中文 key与后端兼容 */
const buildFormData = (): Record<string, string> => {
const map: Record<string, string> = {
name: '姓名',
phone: '手机号',
idCard: '身份证号',
size: '服装尺码',
}
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(formValues)) {
const cnKey = map[key] || key
result[cnKey] = value
// 同时保留英文 key确保后端兼容
result[key] = value
}
return result
}
const handleSubmit = async () => {
if (!event || !validate()) return
setSubmitting(true)
try {
const result = await registerEvent({
eventId: event.id,
formData: buildFormData(),
})
if (result.free) {
Taro.showToast({ title: '报名成功!', icon: 'success' })
setTimeout(() => Taro.redirectTo({ url: '/pages/event/my/index' }), 2000)
return
}
// 后端返回已支付(回调丢失自动修复场景)
if ((result as any).paid === 'true') {
Taro.showToast({ title: '报名成功!', icon: 'success' })
setTimeout(() => Taro.redirectTo({ url: '/pages/event/my/index' }), 2000)
return
}
// 收费赛事:发起微信支付
await Taro.requestPayment({
timeStamp: result.timeStamp,
nonceStr: result.nonceStr,
package: result.package,
signType: result.signType as any,
paySign: result.paySign,
})
Taro.showToast({ title: '报名成功!', icon: 'success' })
// 延迟3秒跳转给微信支付异步回调充足时间
setTimeout(() => Taro.redirectTo({ url: '/pages/event/my/index' }), 3000)
} catch (err: any) {
if (err?.errMsg?.includes('cancel')) {
Taro.showModal({
title: '支付已取消',
content: '您可以在"我的报名"中重新发起支付',
showCancel: false,
confirmText: '知道了',
})
} else {
Taro.showToast({ title: err.message || '报名失败', icon: 'none' })
}
} finally {
setSubmitting(false)
}
}
if (loading || !event) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>{loading ? '加载中...' : '赛事不存在'}</Text>
</View>
)
}
const fields = parseFormFields(event)
/** 渲染单个表单字段 */
const renderField = (field: EventFormField, index: number) => {
const value = formValues[field.key] || ''
const isLast = index === fields.length - 1
const requiredMark = field.required === 1 ? <Text className='text-red-500'>*</Text> : null
const placeholder = field.placeholder || `请输入${field.label}${field.required === 1 ? '' : '(选填)'}`
switch (field.type) {
case 'select':
case 'radio': {
const options = field.options || []
return (
<View key={field.key} className={`bg-white rounded-xl p-4 mb-4`}>
<Text className='text-sm text-gray-600 mb-3 block'>
{field.label} {requiredMark}
</Text>
<View className='flex flex-wrap gap-2'>
{options.map(opt => (
<View
key={opt}
className={`px-4 py-2 rounded-lg border text-sm ${
value === opt
? 'bg-orange-500 border-orange-500 text-white'
: 'bg-white border-gray-200 text-gray-700'
}`}
onClick={() => handleFieldChange(field.key, opt)}
>
<Text>{opt}</Text>
</View>
))}
</View>
</View>
)
}
default: {
// text / number / idcard / phone
const inputType = field.type === 'number' ? 'number' : 'text'
const maxLength = field.type === 'phone' ? 11 : field.type === 'idcard' ? 18 : undefined
return (
<View
key={field.key}
className={`flex items-center px-4 py-3 ${!isLast ? 'border-b border-gray-100' : ''}`}
>
<Text className='text-sm text-gray-600 w-24'>
{field.label} {requiredMark}
</Text>
<Input
className='flex-1 text-sm text-gray-800'
placeholder={placeholder}
type={inputType}
maxlength={maxLength}
value={value}
onInput={e => handleFieldChange(field.key, e.detail.value)}
/>
</View>
)
}
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<View className='flex-1 p-4'>
{/* 赛事信息卡 */}
<View className='bg-orange-50 rounded-xl p-4 mb-4'>
<Text className='text-base font-bold text-gray-800 block mb-1'>{event.name}</Text>
<Text className='text-sm text-orange-500'>
{event.entryFee > 0 ? `报名费:¥${event.entryFee}` : '免费报名'}
</Text>
</View>
{/* 动态表单 */}
{fields.length > 0 ? (
<View className='bg-white rounded-xl overflow-hidden mb-4'>
{fields.map((field, idx) => renderField(field, idx))}
</View>
) : (
<View className='bg-white rounded-xl p-4 mb-4'>
<Text className='text-sm text-gray-400 text-center'></Text>
</View>
)}
{/* 提示 */}
{event.entryFee > 0 && (
<View className='bg-yellow-50 rounded-xl p-3 mb-4'>
<Text className='text-xs text-yellow-700'>
💡 "我的报名"
</Text>
</View>
)}
</View>
{/* 提交按钮 */}
<View className='bg-white px-4 py-3 shadow-lg' style={{ paddingBottom: '20px' }}>
<View
className='bg-orange-500 text-center py-3 rounded-full'
style={{ opacity: submitting ? 0.6 : 1 }}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='text-sm text-white font-bold'>
{submitting ? '提交中...' : event.entryFee > 0 ? `确认报名并支付 ¥${event.entryFee}` : '确认报名'}
</Text>
</View>
</View>
</View>
)
}
export default EventRegisterPage

View File

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

226
src_bak/pages/exchange.tsx Normal file
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,96 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopGoodsFavorite } from '@/api/shop/shopGoodsFavorite'
import type { ShopGoodsFavorite } from '@/api/shop/shopGoodsFavorite/model'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '我的收藏',
})
const FavoriteListPage: React.FC = () => {
const [list, setList] = useState<ShopGoodsFavorite[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [finished, setFinished] = useState(false)
useEffect(() => {
loadFavorites(1)
}, [])
const loadFavorites = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await listShopGoodsFavorite({ page: p, limit: 20 })
const newList = res || []
if (p === 1) {
setList(newList)
} else {
setList(prev => [...prev, ...newList])
}
setFinished(newList.length < 20)
setPage(p)
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' })
}
setLoading(false)
}
const handleItemClick = (goodsId: number) => {
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
}
const handleLoadMore = () => {
if (!finished && !loading) {
loadFavorites(page + 1)
}
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView
scrollY
className='h-screen'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-3'>
{list.map(item => (
<View
key={item.favoriteId}
className='bg-white rounded-lg overflow-hidden'
onClick={() => handleItemClick(item.goodsId!)}
>
<Image
className='w-full'
style={{ height: '160px' }}
src={item.goodsImage}
mode='aspectFill'
/>
<View className='p-2'>
<Text className='text-sm text-gray-800 line-clamp-2 block'>
{item.goodsName}
</Text>
<Text className='text-red-500 text-sm font-medium mt-1 block'>
¥{item.salePrice || '0'}
</Text>
</View>
</View>
))}
</View>
) : (
!loading && <EmptyState text='暂无收藏' />
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default FavoriteListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '礼品卡余额',
}

View File

@@ -0,0 +1,187 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listMyGiftCards, getGiftCardBalance } from '@/api/shop/shopGiftCard'
import type { ShopGiftCard } from '@/api/shop/shopGiftCard/model'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '礼品卡余额',
})
const GiftCardBalancePage: React.FC = () => {
const [balance, setBalance] = useState(0)
const [cards, setCards] = useState<ShopGiftCard[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
// 加载礼品卡数据
const loadGiftCards = async () => {
try {
// 并行请求余额和列表
const [balanceRes, cardsRes] = await Promise.all([
getGiftCardBalance(),
listMyGiftCards()
])
if (balanceRes.code === 0 && balanceRes.data) {
setBalance(balanceRes.data.giftCards || 0)
}
if (cardsRes.code === 0 && cardsRes.data) {
setCards(cardsRes.data)
}
} catch (e) {
console.error('获取礼品卡失败:', e)
Taro.showToast({ title: '获取数据失败', icon: 'none' })
} finally {
setLoading(false)
}
}
useEffect(() => {
loadGiftCards()
}, [])
// 获取状态标签
const getStatusLabel = (status?: number) => {
const map: Record<number, { label: string; color: string }> = {
1: { label: '可使用', color: 'text-green-500' },
2: { label: '已用完', color: 'text-gray-400' },
3: { label: '已过期', color: 'text-red-500' },
}
return map[status || 1] || { label: '未知', color: 'text-gray-400' }
}
// 格式化兑换码
const formatCode = (code?: string) => {
if (!code) return ''
return code.split('-').join(' ')
}
// 删除礼品卡
const handleDelete = (id?: number) => {
Taro.showModal({
title: '提示',
content: '确定删除该礼品卡记录吗?',
success: (res) => {
if (res.confirm) {
setCards(prev => prev.filter(card => card.cardId !== id && card.id !== id))
Taro.showToast({ title: '删除成功', icon: 'success' })
}
}
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 总余额 */}
<View className='p-6 text-white' style={{ background: 'linear-gradient(to right, #c084fc, #f472b6)' }}>
<Text className='text-sm opacity-80 block mb-2'></Text>
<Text className='text-4xl font-bold block mb-3'>¥{balance.toFixed(2)}</Text>
<View className='flex gap-4'>
<View className='rounded-lg px-3 py-1' style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}>
<Text className='text-xs text-white'>
{cards.filter(c => c.status === 1).length}
</Text>
</View>
<View
className='rounded-lg px-3 py-1'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: '/pages/gift-card/exchange/index' })}
>
<Text className='text-xs text-white'> </Text>
</View>
</View>
</View>
{/* 礼品卡列表 */}
<View className='p-3'>
<Text className='text-sm text-gray-500 mb-2 block px-1'></Text>
{cards.length === 0 ? (
<View className='text-center py-16'>
<Text className='text-4xl mb-3 block'>🎁</Text>
<Text className='text-sm text-gray-400 mb-3 block'></Text>
<View
className='inline-block bg-purple-500 text-white px-4 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/gift-card/purchase/index' })}
>
<Text className='text-sm'></Text>
</View>
</View>
) : (
cards.map(card => {
const cardId = card.cardId || card.id
const statusInfo = getStatusLabel(card.status)
return (
<View key={cardId} className='bg-white rounded-xl p-4 mb-3 shadow-sm'>
{/* 顶部状态 */}
<View className='flex justify-between items-center mb-3'>
<Text className={`text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</Text>
<Text className='text-xs text-gray-400'> {card.expireDate || '永久'}</Text>
</View>
{/* 卡信息 */}
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-base font-bold text-gray-800'>¥{card.amount || card.faceValue || '0'}</Text>
</View>
<View className='flex justify-between items-center mb-2'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-base font-bold text-purple-500'>¥{card.remainAmount || card.balance || '0'}</Text>
</View>
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-500'></Text>
<Text className='text-xs text-gray-800 font-mono'>{formatCode(card.code)}</Text>
</View>
</View>
{/* 操作按钮 */}
<View className='flex gap-2'>
{card.status === 1 && (
<View
className='flex-1 text-center py-2 rounded-lg bg-purple-50'
onClick={() => {
if (card.code) {
Taro.setClipboardData({ data: card.code })
Taro.showToast({ title: '已复制到剪贴板', icon: 'none' })
}
}}
>
<Text className='text-xs text-purple-500'></Text>
</View>
)}
<View
className='flex-1 text-center py-2 rounded-lg bg-gray-50'
onClick={() => handleDelete(cardId)}
>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
</View>
)
})
)}
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default GiftCardBalancePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '兑换礼品卡',
}

View File

@@ -0,0 +1,205 @@
import React, { useState } from 'react'
import { View, Text, Input, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { bindGiftCard } from '@/api/shop/shopGiftCard'
import request from '@/utils/request'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '兑换礼品卡',
})
const GiftCardExchangePage: React.FC = () => {
const scrollHeight = useScrollHeight(44)
const [code, setCode] = useState('')
const [step, setStep] = useState(1) // 1:输入兑换码, 2:确认兑换
const [cardInfo, setCardInfo] = useState({
code: '',
amount: 200,
fromUser: '张三',
expireDate: '2029-05-12',
})
// 查询礼品卡
const handleQuery = async () => {
if (!code.trim()) {
Taro.showToast({ title: '请输入兑换码', icon: 'none' })
return
}
if (code.length < 8) {
Taro.showToast({ title: '兑换码格式错误', icon: 'none' })
return
}
Taro.showLoading({ title: '查询中...' })
try {
// 调用 API 查询礼品卡信息
const res = await request.get<{ code: number; data: any }>(`/shop/shop-gift/by-code/${code}`)
Taro.hideLoading()
if (res.code === 0 && res.data) {
setCardInfo({
code: res.data.code || code,
amount: res.data.faceValue || 0,
fromUser: res.data.nickName || '未知用户',
expireDate: res.data.takeTime || '无限制',
})
setStep(2)
Taro.showToast({ title: '查询成功', icon: 'success' })
} else {
Taro.showToast({ title: '礼品卡不存在', icon: 'none' })
}
} catch (err: any) {
Taro.hideLoading()
Taro.showToast({ title: err.message || '查询失败', icon: 'none' })
}
}
// 确认兑换
const handleExchange = async () => {
Taro.showModal({
title: '确认兑换',
content: `确定兑换该礼品卡吗?\n面值¥${cardInfo.amount}`,
confirmText: '确认兑换',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
Taro.showLoading({ title: '兑换中...' })
try {
const result = await bindGiftCard(cardInfo.code)
Taro.hideLoading()
if (result.code === 0) {
Taro.showToast({ title: '兑换成功', icon: 'success' })
setTimeout(() => {
Taro.navigateBack()
}, 1500)
} else {
Taro.showToast({ title: result.message || '兑换失败', icon: 'none' })
}
} catch (err: any) {
Taro.hideLoading()
Taro.showToast({ title: err.message || '兑换失败', icon: 'none' })
}
}
},
})
}
// 重新输入
const handleReset = () => {
setCode('')
setStep(1)
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{step === 1 ? (
<>
{/* 输入兑换码 */}
<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='bg-gray-50 rounded-lg p-4 mb-3'>
<Input
type='text'
value={code}
onInput={(e) => setCode(e.detail.value.toUpperCase())}
placeholder='请输入礼品卡兑换码'
className='text-center text-lg font-bold tracking-widest'
maxlength={16}
/>
</View>
<View className='text-xs text-gray-400 mb-3'>
<Text className='block'> 12-16 </Text>
<Text className='block'> </Text>
</View>
<View
className={`text-center py-3 rounded-full ${
code.length >= 8 ? 'bg-purple-500' : 'bg-gray-300'
}`}
onClick={code.length >= 8 ? handleQuery : undefined}
>
<Text className='text-white font-bold'></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='text-xs text-gray-500 leading-6'>
<Text className='block'>1. </Text>
<Text className='block'>2. </Text>
<Text className='block'>3. </Text>
</View>
</View>
</>
) : (
<>
{/* 确认兑换 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<View className='text-center mb-4'>
<Text className='text-4xl mb-2 block'>🎁</Text>
<Text className='text-base font-bold text-gray-800 block'>
</Text>
</View>
{/* 礼品卡信息 */}
<View className='rounded-lg p-4 mb-3' style={{ background: 'linear-gradient(to right, #faf5ff, #fdf2f8)' }}>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-2xl font-bold text-purple-500'>
¥{cardInfo.amount}
</Text>
</View>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-sm text-gray-800 font-mono'>{cardInfo.code}</Text>
</View>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-sm text-gray-800'>{cardInfo.fromUser}</Text>
</View>
<View className='flex justify-between items-center'>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-sm text-gray-800'>{cardInfo.expireDate}</Text>
</View>
</View>
<View className='text-xs text-gray-400 mb-3 text-center'>
</View>
<View className='flex gap-3'>
<View
className='flex-1 text-center py-3 rounded-full bg-gray-200'
onClick={handleReset}
>
<Text className='text-gray-700 font-bold'></Text>
</View>
<View
className='flex-1 text-center py-3 rounded-full bg-purple-500'
onClick={handleExchange}
>
<Text className='text-white font-bold'></Text>
</View>
</View>
</View>
</>
)}
<View className='h-6' />
</ScrollView>
</View>
)
}
export default GiftCardExchangePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '购买礼品卡',
}

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react'
import { View, Text, Input, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
definePageConfig({
navigationBarTitleText: '购买礼品卡',
})
const GiftCardPurchasePage: React.FC = () => {
const [amount, setAmount] = useState('')
const [showCustom, setShowCustom] = useState(false)
// 预设金额
const presetAmounts = [100, 200, 500, 1000]
// 处理金额选择
const handleAmountSelect = (value: number) => {
setAmount(value.toString())
setShowCustom(false)
}
// 处理自定义金额
const handleCustomAmount = (value: string) => {
setAmount(value)
}
// 处理购买
const handlePurchase = () => {
Taro.showModal({
title: '提示',
content: '礼品卡购买功能暂未开放,请使用兑换功能。',
showCancel: true,
confirmText: '去兑换',
success: (res) => {
if (res.confirm) {
Taro.redirectTo({ url: '/pages/gift-card/exchange/index' })
}
}
})
}
return (
<View className='bg-gray-50 flex flex-col' style={{ minHeight: '100vh' }}>
<ScrollView scrollY className='flex-1'>
{/* 礼品卡预览 */}
<View className='mx-3 mt-3 rounded-xl p-6 text-white relative overflow-hidden' style={{ background: 'linear-gradient(to right, #c084fc, #f472b6)' }}>
<View className='absolute rounded-full' style={{ top: '-20px', right: '-20px', width: '80px', height: '80px', backgroundColor: 'rgba(255,255,255,0.2)' }} />
<View className='absolute rounded-full' style={{ bottom: '-32px', left: '-32px', width: '96px', height: '96px', backgroundColor: 'rgba(255,255,255,0.2)' }} />
<Text className='text-sm opacity-80 block mb-2'></Text>
<Text className='text-4xl font-bold block mb-3'>
¥ {amount || '0'}
</Text>
<View className='flex items-center gap-1'>
<View className='w-6 h-px' style={{ backgroundColor: 'rgba(255,255,255,0.5)' }} />
<Text className='text-xs opacity-60'>/</Text>
<View className='w-6 h-px' style={{ backgroundColor: 'rgba(255,255,255,0.5)' }} />
</View>
</View>
{/* 选择金额 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-wrap gap-3 mb-3'>
{presetAmounts.map(amt => (
<View
key={amt}
className={`flex-1 min-w-20 py-3 text-center rounded-lg border-2 ${
amount === amt.toString() && !showCustom
? 'border-purple-500 bg-purple-50'
: 'border-gray-200'
}`}
onClick={() => handleAmountSelect(amt)}
>
<Text className={`text-lg font-bold ${
amount === amt.toString() && !showCustom ? 'text-purple-500' : 'text-gray-700'
}`}>
¥{amt}
</Text>
</View>
))}
</View>
{/* 自定义金额 */}
<View
className={`py-3 text-center rounded-lg border-2 ${
showCustom ? 'border-purple-500 bg-purple-50' : 'border-gray-200'
}`}
onClick={() => {
setShowCustom(true)
setAmount('')
}}
>
{showCustom ? (
<View className='flex items-center justify-center gap-1'>
<Text className='text-lg font-bold text-purple-500'>¥</Text>
<Input
type='digit'
value={amount}
onInput={(e) => handleCustomAmount(e.detail.value)}
placeholder='输入金额'
className='text-center text-lg font-bold text-purple-500'
style={{ width: '120px' }}
/>
</View>
) : (
<Text className='text-lg text-gray-700'></Text>
)}
</View>
</View>
{/* 购买说明 */}
<View className='bg-white mx-3 mt-3 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-6'>
<Text className='block'>1. </Text>
<Text className='block'>2. 使</Text>
<Text className='block'>3. </Text>
</View>
</View>
<View className='h-4' />
</ScrollView>
{/* 购买按钮 */}
<View className='bg-white p-3 shadow-lg' style={{ paddingBottom: '20px' }}>
<View
className='text-center py-3 rounded-full text-white font-bold'
style={{ background: 'linear-gradient(to right, #a855f7, #ec4899)' }}
onClick={handlePurchase}
>
<Text className='text-white text-base font-bold'>
</Text>
</View>
</View>
</View>
)
}
export default GiftCardPurchasePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '拼团详情',
}

View File

@@ -0,0 +1,323 @@
import React, { useEffect, useState } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { getShopGroupBuy, listShopGroupBuyRecords, joinGroupBuy, createGroupBuy } from '@/api/shop/shopGroupBuy'
import { getShopGoods } from '@/api/shop/shopGoods'
import type { ShopGroupBuy, ShopGroupBuyRecord } from '@/api/shop/shopGroupBuy/model'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import type { ShopGoodsSku } from '@/api/shop/shopGoodsSku/model'
import SkuSelector from '@/components/business/SkuSelector'
definePageConfig({
navigationBarTitleText: '拼团详情',
})
const GroupBuyDetailPage: React.FC = () => {
const router = useRouter()
const groupBuyId = Number(router.params.groupBuyId || 0)
const [groupBuy, setGroupBuy] = useState<ShopGroupBuy | null>(null)
const [product, setProduct] = useState<ShopGoods | null>(null)
const [records, setRecords] = useState<ShopGroupBuyRecord[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [skuVisible, setSkuVisible] = useState(false)
const [pendingRecordId, setPendingRecordId] = useState<number | null>(null)
useEffect(() => {
if (groupBuyId) {
fetchData()
}
}, [groupBuyId])
const fetchData = async () => {
try {
setLoading(true)
const [res1, res2] = await Promise.all([
getShopGroupBuy(groupBuyId),
listShopGroupBuyRecords(groupBuyId),
])
if (res1.code === 0 && res1.data) {
const data = res1.data
setGroupBuy(data)
// 加载商品完整信息(含规格/SKU
if (data.goodsId) {
fetchGoodsDetail(data.goodsId)
}
}
if (res2.code === 0 && res2.data) {
setRecords(res2.data)
}
} catch (err) {
console.error('获取拼团详情失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const fetchGoodsDetail = async (goodsId: number) => {
try {
const res = await getShopGoods(goodsId)
if (res.code === 0 && res.data) {
setProduct(res.data)
}
} catch (err) {
console.error('获取商品详情失败', err)
}
}
// 准备商品规格并显示选择器(开团)
const handleCreateGroup = () => {
if (!groupBuy) return
setPendingRecordId(null) // 标记为开团
if (!product) {
Taro.showLoading({ title: '加载规格...' })
getShopGoods(groupBuy.goodsId)
.then(res => {
Taro.hideLoading()
if (res.code === 0 && res.data) {
setProduct(res.data)
setSkuVisible(true)
} else {
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
}
})
.catch(() => {
Taro.hideLoading()
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
})
} else {
setSkuVisible(true)
}
}
// 去凑单(参团)
const handleJoinGroup = (recordId: number) => {
if (!groupBuy) return
setPendingRecordId(recordId)
if (!product) {
Taro.showLoading({ title: '加载规格...' })
getShopGoods(groupBuy.goodsId)
.then(res => {
Taro.hideLoading()
if (res.code === 0 && res.data) {
setProduct(res.data)
setSkuVisible(true)
} else {
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
}
})
.catch(() => {
Taro.hideLoading()
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
})
} else {
setSkuVisible(true)
}
}
const handleSkuConfirm = async (sku: ShopGoodsSku, quantity: number) => {
if (!groupBuy) return
setSubmitting(true)
try {
let res: any
if (pendingRecordId && pendingRecordId > 0) {
// 参团
res = await joinGroupBuy({
recordId: pendingRecordId,
goodsId: groupBuy.goodsId,
skuId: sku.id || 0,
quantity,
})
Taro.showToast({ title: '参团成功', icon: 'success' })
} else {
// 开团
res = await createGroupBuy({
groupBuyId: groupBuy.id!,
goodsId: groupBuy.goodsId,
skuId: sku.id || 0,
quantity,
})
Taro.showToast({ title: '开团成功', icon: 'success' })
}
if (res.code === 0) {
fetchData() // 刷新数据
}
} catch (err: any) {
Taro.showToast({ title: err?.message || '操作失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<View className="min-h-screen bg-gray-50 flex items-center justify-center">
<Text className="text-gray-400 text-sm">...</Text>
</View>
)
}
if (!groupBuy) {
return (
<View className="min-h-screen bg-gray-50 flex items-center justify-center">
<Text className="text-gray-400 text-sm"></Text>
</View>
)
}
const remaining = groupBuy.groupSize - groupBuy.currentSize
const isActive = groupBuy.status === 1
return (
<View className="min-h-screen bg-gray-50 pb-20">
<ScrollView scrollY className="h-screen">
{/* 商品信息 */}
<View className="bg-white p-4 flex gap-3">
<Image
className="w-24 h-24 rounded-md bg-gray-100"
src={groupBuy.goodsImage || groupBuy.product?.image || ''}
mode="aspectFill"
/>
<View className="flex-1 flex flex-col justify-between">
<Text className="text-sm text-gray-700 font-medium line-clamp-2">
{groupBuy.goodsName || groupBuy.product?.name}
</Text>
<View className="flex items-baseline gap-2">
<Text className="text-2xl font-bold text-red-500">
¥{groupBuy.groupPrice}
</Text>
<Text className="text-xs text-gray-400 line-through">
¥{groupBuy.product?.price || 0}
</Text>
</View>
<View className="flex items-center gap-2 mt-1">
{isActive && remaining > 0 && (
<Text className="text-xs text-red-500 font-medium"></Text>
)}
{remaining === 0 && (
<Text className="text-xs text-green-500 font-medium"></Text>
)}
</View>
</View>
</View>
{/* 拼团进度 */}
<View className="bg-white mt-3 p-4">
<Text className="text-base font-medium text-gray-800 mb-3 block"></Text>
<View className="flex items-center justify-between">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-sm font-medium text-gray-800">{groupBuy.groupSize}</Text>
</View>
<View className="flex items-center justify-between mt-2">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-sm font-medium text-red-500">{groupBuy.currentSize}</Text>
</View>
<View className="mt-3 bg-gray-100 rounded-full h-2 overflow-hidden">
<View
className="h-full rounded-full"
style={{
width: `${Math.min((groupBuy.currentSize / groupBuy.groupSize) * 100, 100)}%`,
backgroundColor: '#ef4444',
}}
/>
</View>
{remaining > 0 && (
<Text className="text-xs text-gray-400 mt-2 block">
{remaining}
</Text>
)}
{remaining === 0 && (
<Text className="text-xs text-green-500 mt-2 block"></Text>
)}
</View>
{/* 正在拼团的列表 */}
{records.length > 0 && (
<View className="bg-white mt-3 p-4">
<Text className="text-base font-medium text-gray-800 mb-3 block">
</Text>
{records.map(record => (
<View key={record.id} className="flex items-center justify-between py-3 border-b border-gray-50">
<View className="flex items-center gap-3">
<View className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: '#fef2f2' }}>
<Text className="text-xs text-red-500">
{record.isLeader ? '团长' : '成员'}
</Text>
</View>
<View>
<Text className="text-sm text-gray-700">
{record.isLeader ? '团长' : `团员${record.id}`}
</Text>
<Text className="text-xs text-gray-400">
{record.groupSize - record.memberCount > 0
? `还差${record.groupSize - record.memberCount}`
: '已成团'}
</Text>
</View>
</View>
{isActive && record.status === 1 && (
<View
className="px-4 py-1 rounded-full text-white text-xs"
style={{ backgroundColor: '#ef4444' }}
onClick={() => handleJoinGroup(record.id)}
>
<Text className="text-white text-xs"></Text>
</View>
)}
</View>
))}
</View>
)}
{/* 拼团规则 */}
<View className="bg-white mt-3 p-4 mb-4">
<Text className="text-base font-medium text-gray-800 mb-3 block"></Text>
<View className="flex flex-col" style={{ gap: '8px' }}>
<Text className="text-sm text-gray-500"> 24</Text>
<Text className="text-sm text-gray-500"> 退</Text>
<Text className="text-sm text-gray-500"> 7退</Text>
</View>
</View>
</ScrollView>
{/* 底部操作栏 */}
<View className="bg-white border-t border-gray-100 px-4 py-3 flex items-center justify-between">
<View
className="flex-1 mr-2 py-3 rounded-full text-center text-sm font-medium border-2"
style={{ borderColor: '#ef4444', color: '#ef4444' }}
onClick={handleCreateGroup}
>
<Text></Text>
</View>
<View
className="flex-1 py-3 rounded-full text-white text-sm font-medium text-center"
style={{ backgroundColor: isActive ? '#ef4444' : '#d1d5db' }}
onClick={isActive ? handleCreateGroup : undefined}
>
<Text>{submitting ? '处理中...' : '立即拼团'}</Text>
</View>
</View>
{/* SKU 选择器 */}
<SkuSelector
visible={skuVisible}
product={product}
mode="buy"
onClose={() => setSkuVisible(false)}
onConfirm={handleSkuConfirm}
/>
</View>
)
}
export default GroupBuyDetailPage

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopGroupBuy } from '@/api/shop/shopGroupBuy'
import type { ShopGroupBuy } from '@/api/shop/shopGroupBuy/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '拼团活动',
})
const GroupBuyListPage: React.FC = () => {
const [groupBuyList, setGroupBuyList] = useState<ShopGroupBuy[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchGroupBuyList()
}, [])
const fetchGroupBuyList = async () => {
try {
setLoading(true)
const res = await pageShopGroupBuy({ page: 1, pageSize: 20, status: 1 })
if (res.code === 0 && res.data) {
setGroupBuyList(res.data.items || [])
}
} catch (err) {
console.error('获取拼团列表失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleGroupBuyClick = (item: ShopGroupBuy) => {
Taro.navigateTo({
url: `/pages/shop/group-buy-detail?groupBuyId=${item.id}`
})
}
const getStatusText = (item: ShopGroupBuy) => {
const remaining = item.groupSize - item.currentSize
if (remaining <= 0) return '已满员'
return `还差${remaining}人成团`
}
const getStatusColor = (item: ShopGroupBuy) => {
const remaining = item.groupSize - item.currentSize
if (remaining <= 0) return '#999'
return '#ff4d4f'
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY className='h-screen'>
<View className='p-3'>
{loading ? (
<View className='flex items-center justify-center py-20'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
) : groupBuyList.length === 0 ? (
<View className='pt-20'>
<EmptyState text='暂无拼团活动' />
</View>
) : (
<View className="flex flex-col" style={{ gap: '12px' }}>
{groupBuyList.map(item => (
<View
key={item.id}
className='bg-white rounded-lg p-3 flex gap-3'
onClick={() => handleGroupBuyClick(item)}
>
<Image
className='w-24 h-24 rounded-md bg-gray-100 flex-shrink-0'
src={item.goodsImage || item.product?.image || ''}
mode='aspectFill'
/>
<View className='flex-1 flex flex-col justify-between'>
<Text className='text-sm text-gray-700 font-medium line-clamp-2'>
{item.goodsName || item.product?.name || '商品名称'}
</Text>
<View className='flex items-center gap-2 mt-1'>
<View className='bg-red-50 px-2 py-1 rounded'>
<Text className='text-xs text-red-500'>{item.groupSize}</Text>
</View>
<Text className='text-xs' style={{ color: getStatusColor(item) }}>
{getStatusText(item)}
</Text>
</View>
<View className='flex items-center justify-between mt-1'>
<View className='flex items-baseline gap-1'>
<Text className='text-lg font-bold text-red-500'>
{'\u00A5'}{item.groupPrice}
</Text>
<Text className='text-xs text-gray-400 line-through'>
{'\u00A5'}{item.product?.price || 0}
</Text>
</View>
<View
className='px-3 py-1 rounded-full text-white text-xs'
style={{ backgroundColor: '#ff4d4f' }}
>
<Text></Text>
</View>
</View>
</View>
</View>
))}
</View>
)}
</View>
</ScrollView>
</View>
)
}
export default GroupBuyListPage

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '帮助中心',
})
interface HelpCategory {
id: number
title: string
icon: string
questions: HelpQuestion[]
}
interface HelpQuestion {
id: number
title: string
content: string
}
const helpData: HelpCategory[] = [
{
id: 1,
title: '购物指南',
icon: '🛍️',
questions: [
{ id: 101, title: '如何下单购买商品?', content: '您可以在商品详情页点击"立即购买"或"加入购物车"进行购买。支持微信支付、余额支付等多种支付方式。' },
{ id: 102, title: '购物车有什么用?', content: '购物车可以临时存放您想要购买的商品,您可以随时调整商品数量、删除商品或去结算。' },
{ id: 103, title: '如何查看我的订单?', content: '在"我的"页面点击"我的订单"即可查看所有订单,包括待付款、待发货、待收货等不同状态的订单。' },
]
},
{
id: 2,
title: '支付与退款',
icon: '💰',
questions: [
{ id: 201, title: '支持哪些支付方式?', content: '我们支持微信支付、支付宝支付(如有)、货到付款、积分抵扣等多种支付方式。' },
{ id: 202, title: '如何申请退款?', content: '在订单详情页点击"申请退款"按钮,填写退款原因和说明,提交后等待商家审核。' },
{ id: 203, title: '退款多久到账?', content: '退款审核通过后原路退回您的支付账户。微信支付一般1-3个工作日到账余额支付即时到账。' },
]
},
{
id: 3,
title: '配送与物流',
icon: '🚚',
questions: [
{ id: 301, title: '订单多久发货?', content: '一般情况下订单会在24小时内发货。特殊情况下可能会延迟我们会及时通知您。' },
{ id: 302, title: '如何查看物流信息?', content: '在订单详情页可以看到"查看物流"按钮,点击即可查看详细的物流配送信息。' },
{ id: 303, title: '可以指定送货时间吗?', content: '目前暂不支持指定送货时间,但您可以在订单备注中说明您的配送偏好,我们会尽量安排。' },
]
},
{
id: 4,
title: '会员与积分',
icon: '⭐',
questions: [
{ id: 401, title: '如何获得积分?', content: '您可以通过每日签到、购物消费、邀请好友等方式获得积分。积分可以在积分商城兑换商品或抵扣现金。' },
{ id: 402, title: '会员有什么特权?', content: '不同等级的会员享受不同的折扣优惠、专属客服、生日礼物等特权。会员等级越高,享受的特权越多。' },
{ id: 403, title: '积分会过期吗?', content: '积分有效期为一年,到期后未使用的积分将自动清零。请及时使用您的积分。' },
]
},
{
id: 5,
title: '账户与安全',
icon: '🔒',
questions: [
{ id: 501, title: '如何修改密码?', content: '在"我的"->"设置"->"修改密码"中,输入原密码和新密码即可完成修改。' },
{ id: 502, title: '忘记密码怎么办?', content: '在登录页面点击"忘记密码",通过手机号验证后可以重置密码。' },
{ id: 503, title: '如何保护账户安全?', content: '建议您设置复杂的密码、开启登录验证、不要将账户信息透露给他人,定期修改密码。' },
]
}
]
const HelpCenterPage: React.FC = () => {
const [expandedId, setExpandedId] = useState<number | null>(null)
const handleQuestionClick = (question: HelpQuestion) => {
Taro.navigateTo({
url: `/pages/user/help-detail?id=${question.id}&title=${encodeURIComponent(question.title)}&content=${encodeURIComponent(question.content)}`
})
}
const toggleCategory = (categoryId: number) => {
setExpandedId(expandedId === categoryId ? null : categoryId)
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY className='h-screen'>
<View className='p-3'>
{/* 搜索框(预留) */}
<View className='bg-white rounded-lg p-3 mb-3 flex items-center'>
<Text className='text-gray-400 text-sm'>🔍 ...</Text>
</View>
{/* 分类列表 */}
{helpData.map(category => (
<View key={category.id} className='bg-white rounded-lg mb-3 overflow-hidden'>
<View
className='p-4 flex items-center justify-between'
onClick={() => toggleCategory(category.id)}
>
<View className='flex items-center gap-2'>
<Text className='text-xl'>{category.icon}</Text>
<Text className='text-base font-medium text-gray-800'>{category.title}</Text>
</View>
<Text className='text-gray-400'>
{expandedId === category.id ? '▲' : '▼'}
</Text>
</View>
{expandedId === category.id && (
<View className='border-t border-gray-100'>
{category.questions.map((question, index) => (
<View
key={question.id}
className={`p-4 ${index < category.questions.length - 1 ? 'border-b border-gray-50' : ''}`}
onClick={() => handleQuestionClick(question)}
>
<Text className='text-sm text-gray-600'>{question.title}</Text>
</View>
))}
</View>
)}
</View>
))}
{/* 联系客服入口 */}
<View
className='bg-white rounded-lg p-4 flex items-center justify-center gap-2 mt-3'
onClick={() => Taro.navigateTo({ url: '/pages/user/customer-service' })}
>
<Text className='text-xl'>📞</Text>
<Text className='text-sm text-blue-500'>线</Text>
</View>
<View className='h-4'></View>
</View>
</ScrollView>
</View>
)
}
export default HelpCenterPage

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
definePageConfig({
navigationBarTitleText: '问题详情',
})
const HelpDetailPage: React.FC = () => {
const router = useRouter()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
useEffect(() => {
const { title: t, content: c } = router.params
if (t) setTitle(decodeURIComponent(t))
if (c) setContent(decodeURIComponent(c))
}, [router.params])
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY className='h-screen'>
<View className='p-4'>
<View className='bg-white rounded-lg p-4'>
<Text className='text-lg font-medium text-gray-800 block mb-4'>
{title}
</Text>
<View className='border-t border-gray-100 pt-4'>
<Text className='text-sm text-gray-600 leading-7'>
{content}
</Text>
</View>
</View>
{/* 相关问题推荐(预留) */}
<View className='mt-4 bg-white rounded-lg p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'>
</Text>
<Text className='text-sm text-gray-400'>
</Text>
</View>
</View>
</ScrollView>
</View>
)
}
export default HelpDetailPage

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '浏览历史',
})
interface HistoryItem {
goodsId: number
name?: string
image?: string
price?: string
timestamp: number
}
const BrowseHistoryPage: React.FC = () => {
const [list, setList] = useState<HistoryItem[]>([])
useEffect(() => {
loadHistory()
}, [])
const loadHistory = () => {
try {
const history = Taro.getStorageSync('browse_history') || []
setList(history)
} catch {
setList([])
}
}
const handleItemClick = (goodsId: number) => {
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
}
const handleClearHistory = () => {
Taro.showModal({
title: '提示',
content: '确定要清空浏览历史吗?',
success: (res) => {
if (res.confirm) {
Taro.removeStorageSync('browse_history')
setList([])
Taro.showToast({ title: '已清空', icon: 'success' })
}
}
})
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部操作栏 */}
{list.length > 0 && (
<View className='bg-white px-4 py-2 flex justify-end border-b border-gray-100'>
<Text
className='text-sm text-red-500'
onClick={handleClearHistory}
>
</Text>
</View>
)}
<ScrollView scrollY className='h-screen'>
<View className='p-3'>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-3'>
{list.map(item => (
<View
key={`${item.goodsId}-${item.timestamp}`}
className='bg-white rounded-lg overflow-hidden'
onClick={() => handleItemClick(item.goodsId)}
>
<Image
className='w-full'
style={{ height: '160px' }}
src={item.image}
mode='aspectFill'
/>
<View className='p-2'>
<Text className='text-sm text-gray-800 line-clamp-2 block'>
{item.name}
</Text>
<View className='flex items-center justify-between mt-1'>
<Text className='text-red-500 text-sm font-medium'>
¥{item.price || '0'}
</Text>
<Text className='text-xs text-gray-400'>
{formatTime(item.timestamp)}
</Text>
</View>
</View>
</View>
))}
</View>
) : (
<EmptyState text='暂无浏览历史' />
)}
</View>
</ScrollView>
</View>
)
}
export default BrowseHistoryPage

View File

@@ -0,0 +1,28 @@
import React from 'react'
import { View, Text, RichText, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '文章详情',
})
const content = '<p style="color:#666;font-size:14px;line-height:1.8;">文章内容区域后续对接CMS文章API后自动渲染。</p>'
const ArticleDetailPage: React.FC = () => {
const scrollHeight = useScrollHeight(44)
return (
<View className='min-h-screen bg-white'>
<ScrollView scrollY style={{ height: scrollHeight }}>
<View className='p-4'>
<Text className='text-lg font-bold text-gray-800 block mb-2'></Text>
<Text className='text-xs text-gray-400 block mb-4'>2026-05-11</Text>
<RichText nodes={content} />
</View>
</ScrollView>
</View>
)
}
export default ArticleDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '活动列表',
}

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import EmptyState from '@/components/common/EmptyState'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '活动列表',
})
const ArticleListPage: React.FC = () => {
const [articles, setArticles] = useState<CmsArticle[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const scrollHeight = useScrollHeight(44)
const type = (Taro.getCurrentInstance().router?.params as any)?.type || 'activity'
useEffect(() => {
fetchArticles()
}, [])
const fetchArticles = async (loadMore = false) => {
try {
const currentPage = loadMore ? page + 1 : 1
const data = await listCmsArticle({
category: type === 'activity' ? 'activity' : 'notice',
status: 1,
page: currentPage,
limit: 10,
})
if (data) {
if (loadMore) {
setArticles(prev => [...prev, ...data])
setPage(currentPage)
} else {
setArticles(data)
}
setHasMore(data.length >= 10)
}
} catch (e) {
console.error('获取文章列表失败:', e)
} finally {
setLoading(false)
}
}
const handleLoadMore = () => {
if (!hasMore || loading) return
fetchArticles(true)
}
const handleArticleClick = (id: number) => {
Taro.navigateTo({ url: `/pages/index/article-detail?id=${id}` })
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView
scrollY
style={{ height: scrollHeight }}
onScrollToLower={handleLoadMore}
>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : articles.length === 0 ? (
<EmptyState text='暂无活动' />
) : (
<View className='p-3'>
{articles.map(item => (
<View
key={item.id}
className='bg-white rounded-lg mb-3 overflow-hidden'
onClick={() => handleArticleClick(item.id)}
>
{item.coverImage && (
<Image
className='w-full'
src={item.coverImage}
mode='aspectFill'
style={{ height: '160px' }}
/>
)}
<View className='p-3'>
<Text className='text-base font-medium text-gray-800 block mb-1'>
{item.title}
</Text>
{item.summary && (
<Text className='text-sm text-gray-500 mb-2 block' numberOfLines={2}>
{item.summary}
</Text>
)}
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-400'>
{item.createTime}
</Text>
{item.isHot && (
<View className='px-2 py-0 bg-red-500 rounded-full'>
<Text className='text-xs text-white'></Text>
</View>
)}
</View>
</View>
</View>
))}
{hasMore && (
<View className='py-3 text-center'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)}
</View>
)}
</ScrollView>
</View>
)
}
export default ArticleListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '领券中心',
}

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listShopCoupon } from '@/api/shop/shopCoupon'
import type { ShopCoupon } from '@/api/shop/shopCoupon/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '领券中心',
})
const CouponCenterPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [coupons, setCoupons] = useState<ShopCoupon[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchCoupons()
}, [isLoggedIn])
const fetchCoupons = async () => {
try {
const data = await listShopCoupon({ status: 1 })
setCoupons(data || [])
} catch (e) {
console.error('获取优惠券失败:', e)
} finally {
setLoading(false)
}
}
const handleClaimCoupon = (id: number) => {
Taro.showModal({
title: '领取优惠券',
content: '确定要领取这张优惠券吗?',
success: (res) => {
if (res.confirm) {
Taro.showToast({ title: '领取成功', icon: 'success' })
fetchCoupons()
}
},
})
}
const getCouponTypeText = (type: number) => {
const typeMap: Record<number, string> = {
1: '无门槛券',
2: '满减券',
3: '折扣券',
4: '运费券',
}
return typeMap[type] || '优惠券'
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : coupons.length === 0 ? (
<EmptyState text='暂无可用优惠券' />
) : (
<View className='p-3'>
{coupons.map((item) => (
<View
key={item.id}
className='bg-white rounded-lg mb-3 overflow-hidden'
>
<View className='flex'>
<View
className='p-4 text-white flex flex-col items-center justify-center'
style={{ width: '120px', backgroundColor: '#0e932e' }}
>
<Text className='text-2xl font-bold'>
{item.discountType === 3 ? `${item.discountValue}` : `¥${item.amount}`}
</Text>
<Text className='text-xs mt-1'>
{item.minAmount ? `${item.minAmount}可用` : '无门槛'}
</Text>
</View>
<View className='flex-1 p-3'>
<Text className='text-sm font-medium text-gray-800 block mb-1'>
{getCouponTypeText(item.couponType)}
</Text>
<Text className='text-xs text-gray-500 mb-1 block'>
{item.name}
</Text>
<Text className='text-xs text-gray-400 block'>
{item.expireTime}
</Text>
</View>
<View className='flex items-center pr-3'>
<View
className='px-3 py-1 rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={() => handleClaimCoupon(item.id!)}
>
<Text className='text-xs text-white'></Text>
</View>
</View>
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default CouponCenterPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '领券中心',
}

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listCouponCenter } from '@/api/shop/shopCoupon'
import { takeCoupon } from '@/api/shop/shopUserCoupon'
import type { ShopCouponWithTake } from '@/api/shop/shopCoupon/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '领券中心',
})
const CouponCenterPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [coupons, setCoupons] = useState<ShopCouponWithTake[]>([])
const [loading, setLoading] = useState(true)
const [claiming, setClaiming] = useState<number | null>(null)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchCoupons()
}, [isLoggedIn])
const fetchCoupons = async () => {
setLoading(true)
try {
const data = await listCouponCenter({ status: 0, isExpire: 0 })
setCoupons(data || [])
} catch (e) {
console.error('获取优惠券失败:', e)
} finally {
setLoading(false)
}
}
const handleClaimCoupon = async (id: number) => {
if (claiming) return
setClaiming(id)
try {
await takeCoupon(id)
Taro.showToast({ title: '领取成功', icon: 'success' })
// 更新本地状态,避免重新请求
setCoupons(prev =>
prev.map(c => c.id === id ? { ...c, hasTake: true, userTakeNum: (c.userTakeNum || 0) + 1 } : c)
)
} catch (e: any) {
Taro.showToast({ title: e?.message || '领取失败', icon: 'none' })
} finally {
setClaiming(null)
}
}
const getCouponValueText = (item: ShopCouponWithTake) => {
switch (item.type) {
case 20: return `${item.discount}`
case 40: return `¥${item.reducePrice}`
case 50: return item.useCount && item.useCount > 0 ? `${item.useCount}` : '不限次'
default: return `¥${item.reducePrice}`
}
}
const getCouponConditionText = (item: ShopCouponWithTake) => {
if (item.type === 40) return '无门槛'
if (item.type === 50) {
const parts: string[] = []
if (item.venueType !== undefined && item.venueType !== null) parts.push(`场地类型${item.venueType}`)
if (item.useDuration && item.useDuration > 0) parts.push(`${item.useDuration}分钟`)
return parts.length > 0 ? parts.join(' | ') : '场地使用'
}
if (item.minPrice && Number(item.minPrice) > 0) return `${item.minPrice}可用`
return '无门槛'
}
const getCouponExpireText = (item: ShopCouponWithTake) => {
if (item.expireType === 10) return `领取后${item.expireDay}天内有效`
if (item.endTime) return `有效期至:${item.endTime.slice(0, 10)}`
return ''
}
const getCouponTypeText = (type?: number) => {
const map: Record<number, string> = { 10: '满减券', 20: '折扣券', 30: '免费券', 40: '无门槛券', 50: '场地使用券' }
return map[type || 0] || '优惠券'
}
const isClaimable = (item: ShopCouponWithTake) => {
if (item.hasTake) return false
if (item.limitPerUser !== -1 && (item.userTakeNum || 0) >= (item.limitPerUser || 1)) return false
if (item.totalCount !== -1 && (item.issuedCount || 0) >= (item.totalCount || 0)) return false
return true
}
if (!isLoggedIn) return null
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : coupons.length === 0 ? (
<EmptyState text='暂无可领取的优惠券' />
) : (
<View className='p-3'>
{coupons.map((item) => {
const canClaim = isClaimable(item)
return (
<View
key={item.id}
className='bg-white rounded-lg mb-3 overflow-hidden'
style={{ opacity: canClaim ? 1 : 0.6 }}
>
<View className='flex'>
<View
className='p-4 text-white flex flex-col items-center justify-center'
style={{ width: '120px', backgroundColor: canClaim ? (
item.type === 40 ? '#3b82f6' :
item.type === 50 ? '#06b6d4' :
'#0e932e'
) : '#999' }}
>
<Text className='text-2xl font-bold'>{getCouponValueText(item)}</Text>
<Text className='text-xs mt-1'>{getCouponConditionText(item)}</Text>
</View>
<View className='flex-1 p-3'>
<Text className='text-sm font-medium text-gray-800 block mb-1'>
{getCouponTypeText(item.type)}
</Text>
<Text className='text-xs text-gray-500 mb-1 block'>{item.name}</Text>
<Text className='text-xs text-gray-400 block'>{getCouponExpireText(item)}</Text>
</View>
<View className='flex items-center pr-3'>
<View
className='px-3 py-1 rounded-full'
style={{ backgroundColor: canClaim ? (
item.type === 40 ? '#3b82f6' :
item.type === 50 ? '#06b6d4' :
'#0e932e'
) : '#ccc' }}
onClick={() => canClaim && handleClaimCoupon(item.id!)}
>
<Text className='text-xs text-white'>
{claiming === item.id ? '领取中' : item.hasTake ? '已领取' : '领取'}
</Text>
</View>
</View>
</View>
</View>
)
})}
</View>
)}
</ScrollView>
</View>
)
}
export default CouponCenterPage

View File

@@ -0,0 +1,6 @@
export default {
navigationBarTitleText: '鑫龙家电',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f8f8f8'
}

View File

@@ -0,0 +1,9 @@
.index-page {
min-height: 100vh;
padding: 32px;
box-sizing: border-box;
.nut-button {
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,270 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Swiper, SwiperItem, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listCmsAd } from '@/api/cms/cmsAd'
import { listCmsArticle } from '@/api/cms/cmsArticle'
import { pageShopGoods } from '@/api/shop/shopGoods'
import type { CmsAd } from '@/api/cms/cmsAd/model'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '首页',
})
const IndexPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [banners, setBanners] = useState<CmsAd[]>([])
const [announcements, setAnnouncements] = useState<CmsArticle[]>([])
const [hotProducts, setHotProducts] = useState<ShopGoods[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
const categories = ['全部', '球拍', '球鞋', '服装', '配件']
// 功能入口8个
const featureEntries = [
{ icon: '🎁', label: '全部商品', url: '/pages/shop/index' },
{ icon: '🎯', label: '限时秒杀', url: '/pages/shop/seckill-list/index' },
{ icon: '🎫', label: '领券中心', url: '/pages/index/coupon-center/index' },
{ icon: '⭐', label: '我的收藏', url: '/pages/user/favorite-list/index' },
{ icon: '👑', label: '会员中心', url: '/pages/user/member/index' },
{ icon: '💰', label: '积分商城', url: '/pages/points/index' },
{ icon: '🏪', label: '门店信息', url: '/pages/store/list/index' },
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
]
// 获取轮播图
useEffect(() => {
const fetchBanners = async () => {
try {
const data = await listCmsAd({ adType: 'banner', status: 0 })
setBanners(data || [])
} catch (e) {
console.error('获取轮播图失败:', e)
}
}
const fetchAnnouncements = async () => {
try {
const data = await listCmsArticle({ category: 'notice', status: 1 })
setAnnouncements(data || [])
} catch (e) {
console.error('获取公告失败:', e)
} finally {
setLoading(false)
}
}
fetchBanners()
fetchAnnouncements()
fetchHotProducts()
}, [])
// 获取热销商品
const fetchHotProducts = async () => {
try {
const res = await pageShopGoods({ page: 1, limit: 4, status: 0, recommend: 1 })
if (res?.list) {
setHotProducts(res.list)
}
} catch (e) {
console.error('获取热销商品失败:', e)
}
}
const handleMessageClick = () => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
Taro.navigateTo({ url: '/pages/index/notification' })
}
// tabBar 页面列表
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/user/user']
const handleFeatureClick = (url: string) => {
if (tabBarPages.includes(url)) {
Taro.switchTab({ url })
} else {
Taro.navigateTo({ url })
}
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部搜索栏 */}
<View className='flex items-center px-3 py-2 bg-white'>
<View className='flex-1 mx-2'>
<View
className='flex items-center bg-gray-100 rounded-full px-3 py-2'
onClick={() => Taro.navigateTo({ url: '/pages/index/search' })}
>
<Text className='text-gray-400 text-sm mr-2'>🔍</Text>
<Text className='text-gray-400 text-sm flex-1'></Text>
</View>
</View>
<View onClick={handleMessageClick}>
<Text className='text-xl'>🔔</Text>
{isLoggedIn && (
<View className='absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full' />
)}
</View>
</View>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 轮播图 */}
<View className='mx-3 mt-2 rounded-lg overflow-hidden'>
{banners.length > 0 ? (
<Swiper
autoplay
interval={3000}
className='rounded-lg'
style={{ height: '160px' }}
>
{banners.map(item => (
<SwiperItem key={item.adId}>
<Image
className='w-full h-full'
src={item.imageList?.[0]?.url || item.image || ''}
mode='aspectFill'
onClick={() => {
if (item.path) Taro.navigateTo({ url: item.path })
}}
/>
</SwiperItem>
))}
</Swiper>
) : (
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: '160px' }}>
<Text className='text-gray-400 text-sm'></Text>
</View>
)}
</View>
{/* 功能入口网格 */}
<View className='mx-3 mt-3 p-3 bg-white rounded-lg'>
<View className='grid grid-cols-4 gap-2'>
{featureEntries.map(item => (
<View
key={item.label}
className='flex flex-col items-center py-2'
onClick={() => handleFeatureClick(item.url)}
>
<Text className='text-2xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
</View>
))}
</View>
</View>
{/* 公告栏 */}
{announcements.length > 0 && (
<View className='mx-3 mt-3 px-3 py-2 bg-yellow-50 rounded-lg flex items-center' style={{ display: 'none' }}>
<Text className='text-xs text-yellow-600 font-medium mr-2'></Text>
<Swiper
autoplay
direction='vertical'
interval={3000}
className='flex-1'
style={{ height: '20px' }}
>
{announcements.map(item => (
<SwiperItem key={item.articleId}>
<Text
className='text-xs text-gray-600 truncate'
onClick={() => Taro.navigateTo({ url: `/pages/index/article-detail?id=${item.articleId}` })}
>
{item.title}
</Text>
</SwiperItem>
))}
</Swiper>
</View>
)}
{/* 分类导航 */}
<View className='mx-3 mt-3 p-3 bg-white rounded-lg' style={{ display: 'none' }}>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex justify-around'>
{categories.map((item) => (
<View
key={item}
className='flex flex-col items-center'
onClick={() => Taro.navigateTo({ url: '/pages/shop/category' })}
>
<View className='w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mb-1'>
<Text className='text-xs text-gray-500'>{item.slice(0, 1)}</Text>
</View>
<Text className='text-xs text-gray-600'>{item}</Text>
</View>
))}
</View>
</View>
{/* 热销推荐 */}
<View className='mx-3 mt-3 mb-4'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800 block'></Text>
<Text
className='text-xs text-gray-400 block'
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
>
{'>'}
</Text>
</View>
<View className='grid grid-cols-2 gap-2'>
{hotProducts.length > 0 ? (
hotProducts.map((item) => (
<View
key={item.goodsId}
className='bg-white rounded-lg overflow-hidden'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${item.goodsId}` })}
>
<View className='w-full' style={{ paddingTop: '100%', position: 'relative' }}>
{item.image ? (
<Image
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={item.image}
mode='aspectFit'
/>
) : (
<View style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} className='bg-gray-100 flex items-center justify-center'>
<Text className='text-xs text-gray-300'></Text>
</View>
)}
</View>
<View className='p-2'>
<Text className='text-sm text-gray-700 truncate block'>{item.goodsName || item.name}</Text>
<View className='flex items-center mt-1'>
<Price
price={item.price || '0'}
original={item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined}
size='small'
loginMask
/>
</View>
{item.sales !== undefined && item.sales > 0 && (
<Text className='text-xs text-gray-400 mt-1'> {item.sales}</Text>
)}
</View>
</View>
))
) : (
<View className='col-span-2 py-8 text-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)}
</View>
</View>
</ScrollView>
</View>
)
}
export default IndexPage

View File

@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopMessage } from '@/api/shop/shopMessage'
import type { ShopMessage } from '@/api/shop/shopMessage'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '消息通知',
})
const NotificationPage: React.FC = () => {
const [list, setList] = useState<ShopMessage[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
loadList(1)
}, [])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await listShopMessage({
page: p,
limit: 10,
})
if (res?.list) {
if (p === 1) {
setList(res.list)
} else {
setList(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)
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1' onScrollToLower={handleLoadMore}>
{list.length === 0 ? (
<EmptyState text='暂无消息' />
) : (
<View className='p-3'>
{list.map(item => (
<View
key={item.id}
className='bg-white rounded-lg p-3 mb-2'
style={{ opacity: item.isRead ? 0.7 : 1 }}
>
<View className='flex justify-between items-center mb-1'>
<View className='flex items-center gap-2'>
{!item.isRead && <View className='w-1 h-1 rounded-full bg-red-500' />}
<Text className='text-sm font-medium text-gray-800'>{item.title}</Text>
</View>
<Text className='text-xs text-gray-400'>{item.createdAt}</Text>
</View>
<Text className='text-xs text-gray-500'>{item.content}</Text>
</View>
))}
</View>
)}
<LoadMore loading={loading} finished={finished} />
</ScrollView>
</View>
)
}
export default NotificationPage

View File

@@ -0,0 +1,115 @@
import React from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import { Input } from '@nutui/nutui-react-taro'
import ProductCard from '@/components/common/ProductCard'
import EmptyState from '@/components/common/EmptyState'
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import { pageShopGoods } from '@/api/shop/shopGoods'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '搜索',
})
const SearchPage: React.FC = () => {
const [keyword, setKeyword] = useState('')
const [list, setList] = useState<ShopGoods[]>([])
const [loading, setLoading] = useState(false)
const scrollHeight = useScrollHeight(44)
const [history, setHistory] = useState<string[]>([])
useEffect(() => {
const saved = Taro.getStorageSync('search_history')
if (saved) setHistory(JSON.parse(saved))
}, [])
const doSearch = async (kw?: string) => {
const q = kw || keyword
if (!q.trim()) return
setLoading(true)
try {
const params: ShopGoodsParam = { keywords: q, status: 0, page: 1, limit: 20 }
const res = await pageShopGoods(params)
setList(res?.list || [])
// 保存搜索历史
const newHistory = [q, ...history.filter(h => h !== q)].slice(0, 10)
setHistory(newHistory)
Taro.setStorageSync('search_history', JSON.stringify(newHistory))
} catch { /* ignore */ }
setLoading(false)
}
const clearHistory = () => {
setHistory([])
Taro.removeStorageSync('search_history')
}
return (
<View className='min-h-screen bg-white'>
{/* 搜索栏 */}
<View className='flex items-center gap-2 px-3 py-2'>
<Input
className='flex-1 bg-gray-100 rounded-full px-3 py-1 text-sm'
placeholder='搜索商品'
value={keyword}
onChange={val => setKeyword(val)}
onConfirm={() => doSearch()}
/>
<Text className='text-sm text-green-600' onClick={() => doSearch()}></Text>
</View>
<ScrollView scrollY style={{ height: scrollHeight }}>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-2 px-3 py-2'>
{list.map(item => (
<ProductCard key={item.goodsId} product={item} />
))}
</View>
) : !loading && keyword === '' ? (
<View className='px-3 pt-4'>
{history.length > 0 && (
<View className='mb-4'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm font-medium text-gray-700'></Text>
<Text className='text-xs text-gray-400' onClick={clearHistory}></Text>
</View>
<View className='flex flex-wrap gap-2'>
{history.map((h, i) => (
<View
key={i}
className='px-3 py-1 bg-gray-100 rounded-full'
onClick={() => { setKeyword(h); doSearch(h) }}
>
<Text className='text-xs text-gray-600'>{h}</Text>
</View>
))}
</View>
</View>
)}
<View className='mb-2'>
<Text className='text-sm font-medium text-gray-700 mb-2 block'></Text>
<View className='flex flex-wrap gap-2'>
{['羽毛球拍', '球鞋', '运动服', '手胶'].map((h) => (
<View
key={h}
className='px-3 py-1 bg-gray-100 rounded-full'
onClick={() => { setKeyword(h); doSearch(h) }}
>
<Text className='text-xs text-gray-600'>{h}</Text>
</View>
))}
</View>
</View>
</View>
) : (
<EmptyState text='未找到相关商品' />
)}
</ScrollView>
</View>
)
}
export default SearchPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '邀请记录',
}

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listShopUserReferee } from '@/api/shop/shopUserReferee'
import type { ShopUserReferee } from '@/api/shop/shopUserReferee/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '邀请记录',
})
const InviteRecordPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [records, setRecords] = useState<ShopUserReferee[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchRecords()
}, [isLoggedIn])
const fetchRecords = async () => {
try {
const data = await listShopUserReferee({})
setRecords(data || [])
} catch (e) {
console.error('获取邀请记录失败:', e)
} finally {
setLoading(false)
}
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : records.length === 0 ? (
<EmptyState text='暂无邀请记录' />
) : (
<View className='p-3'>
{records.map((item) => (
<View
key={item.id}
className='bg-white rounded-lg p-3 mb-2'
>
<View className='flex justify-between items-center mb-1'>
<Text className='text-sm font-medium text-gray-800'>
{item.nickname || '用户'}
</Text>
<View
className={`px-2 py-1 rounded-full text-xs ${
item.status === 1 ? 'bg-green-50 text-green-500' : 'bg-orange-50 text-orange-500'
}`}
>
<Text>{item.status === 1 ? '已注册' : '待注册'}</Text>
</View>
</View>
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-400'>
{item.createTime}
</Text>
{item.isMember && (
<Text className='text-xs text-orange-500'></Text>
)}
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default InviteRecordPage

View File

@@ -0,0 +1,153 @@
import { View, Text, Input, Textarea } from '@tarojs/components';
import { useState } from 'react';
import Taro from '@tarojs/taro';
import NavBar from '@/components/NavBar';
export default function InvoiceApplyPage() {
const [invoiceType, setInvoiceType] = useState<'personal' | 'company'>('personal');
const [formData, setFormData] = useState({
title: '',
taxNumber: '',
content: '商品明细',
amount: '',
email: '',
remark: ''
});
const handleSubmit = () => {
// TODO: 接入发票申请 API
Taro.showToast({ title: '发票申请功能开发中', icon: 'none' })
};
return (
<View className="bg-gray-100 flex flex-col" style={{ minHeight: '100vh' }}>
<NavBar title="申请发票" />
<View className="flex-1 p-4">
{/* 发票类型 */}
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="flex" style={{ gap: '16px' }}>
<View
className={`flex-1 p-3 rounded-lg border-2 text-center ${
invoiceType === 'personal'
? 'border-red-500 bg-red-50'
: 'border-gray-300'
}`}
onClick={() => setInvoiceType('personal')}
>
<Text className={invoiceType === 'personal' ? 'text-red-500' : 'text-gray-600'}>
</Text>
</View>
<View
className={`flex-1 p-3 rounded-lg border-2 text-center ${
invoiceType === 'company'
? 'border-red-500 bg-red-50'
: 'border-gray-300'
}`}
onClick={() => setInvoiceType('company')}
>
<Text className={invoiceType === 'company' ? 'text-red-500' : 'text-gray-600'}>
</Text>
</View>
</View>
</View>
{/* 发票信息 */}
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入发票抬头"
value={formData.title}
onInput={(e: any) => setFormData(prev => ({ ...prev, title: e.detail.value }))}
/>
</View>
{invoiceType === 'company' && (
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入纳税人识别号"
value={formData.taxNumber}
onInput={(e: any) => setFormData(prev => ({ ...prev, taxNumber: e.detail.value }))}
/>
</View>
)}
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<View className="flex" style={{ gap: '16px' }}>
<View
className={`px-4 py-2 rounded-lg ${
formData.content === '商品明细' ? 'bg-red-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setFormData(prev => ({ ...prev, content: '商品明细' }))}
>
<Text></Text>
</View>
<View
className={`px-4 py-2 rounded-lg ${
formData.content === '商品类别' ? 'bg-red-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setFormData(prev => ({ ...prev, content: '商品类别' }))}
>
<Text></Text>
</View>
</View>
</View>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入发票金额"
type="digit"
value={formData.amount}
onInput={(e: any) => setFormData(prev => ({ ...prev, amount: e.detail.value }))}
/>
</View>
</View>
{/* 接收方式 */}
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-base font-medium mb-3 block"></Text>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="请输入接收邮箱"
value={formData.email}
onInput={(e: any) => setFormData(prev => ({ ...prev, email: e.detail.value }))}
/>
</View>
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Textarea
className="w-full p-3 bg-gray-100 rounded-lg text-sm"
placeholder="选填,可填写备注信息"
value={formData.remark}
onInput={(e: any) => setFormData(prev => ({ ...prev, remark: e.detail.value }))}
/>
</View>
</View>
</View>
<View className="p-4 bg-white border-t border-gray-200" style={{ paddingBottom: '20px' }}>
<View
className="bg-red-500 text-white rounded-full w-full h-12 flex items-center justify-center"
onClick={handleSubmit}
>
<Text className="text-white font-medium"></Text>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,224 @@
import { View, Text, ScrollView } from '@tarojs/components';
import { useState, useEffect, useCallback } from 'react';
import { useDidShow } from '@tarojs/taro';
import Taro from '@tarojs/taro';
import NavBar from '@/components/NavBar';
import LoadMore from '@/components/common/LoadMore';
import EmptyState from '@/components/common/EmptyState';
import { pageShopInvoiceRecord } from '@/api/shop/shopInvoiceTitle';
import type { ShopInvoiceRecord, ShopInvoiceRecordParam } from '@/api/shop/shopInvoiceTitle/model';
import type { PageResult } from '@/api';
// 发票状态映射
const statusMap = {
0: { text: '待处理', color: 'text-orange-500', bgColor: 'bg-orange-100', textColor: 'text-orange-600' },
1: { text: '开票中', color: 'text-blue-500', bgColor: 'bg-blue-100', textColor: 'text-blue-600' },
2: { text: '已完成', color: 'text-green-500', bgColor: 'bg-green-100', textColor: 'text-green-600' },
3: { text: '失败', color: 'text-red-500', bgColor: 'bg-red-100', textColor: 'text-red-600' },
};
// 发票类型映射
const invoiceTypeMap = {
normal: { label: '普通发票', bgColor: 'bg-blue-100', textColor: 'text-blue-600' },
vat: { label: '增值税发票', bgColor: 'bg-orange-100', textColor: 'text-orange-600' },
};
export default function InvoiceRecordsPage() {
const [records, setRecords] = useState<ShopInvoiceRecord[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'completed'>('all');
// 过滤后的记录
const filteredRecords = records.filter(record => {
if (activeTab === 'all') return true;
if (activeTab === 'pending') return record.status === 0 || record.status === 1;
if (activeTab === 'completed') return record.status === 2;
return true;
});
// 加载数据
const loadData = useCallback(async (pageNum: number = 1, isLoadMore = false) => {
if (isLoadMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const params: ShopInvoiceRecordParam = {
page: pageNum,
limit: 10,
order: 'desc',
sort: 'createTime',
};
const result: PageResult<ShopInvoiceRecord> = await pageShopInvoiceRecord(params);
if (result && result.list) {
if (isLoadMore) {
setRecords(prev => [...prev, ...result.list]);
} else {
setRecords(result.list);
}
setHasMore(result.list.length >= 10);
setPage(pageNum);
}
} catch (e: any) {
console.error('获取发票记录失败:', e);
Taro.showToast({ title: e.message || '获取数据失败', icon: 'none' });
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
// 下拉刷新
const onPullDownRefresh = useCallback(() => {
loadData(1).then(() => {
Taro.stopPullDownRefresh();
});
}, [loadData]);
// 上拉加载更多
const onReachBottom = useCallback(() => {
if (!loadingMore && hasMore) {
loadData(page + 1, true);
}
}, [loadingMore, hasMore, page, loadData]);
useEffect(() => {
loadData(1);
}, []);
// 格式化金额
const formatMoney = (money: number | undefined) => {
return `¥${(money || 0).toFixed(2)}`;
};
// 格式化时间
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}`;
};
// 获取状态信息
const getStatusInfo = (status: number | undefined) => {
return statusMap[status as keyof typeof statusMap] || statusMap[0];
};
// 获取类型信息
const getTypeInfo = (invoiceType: string | undefined) => {
return invoiceTypeMap[invoiceType as keyof typeof invoiceTypeMap] || invoiceTypeMap.normal;
};
// 查看发票
const handleViewInvoice = (record: ShopInvoiceRecord) => {
if (record.invoiceUrl) {
Taro.previewImage({ urls: [record.invoiceUrl] });
} else {
Taro.showToast({ title: '暂无发票', icon: 'none' });
}
};
return (
<View className="min-h-screen bg-gray-100">
<NavBar title="发票记录" />
{/* 标签切换 */}
<View className="bg-white flex">
{[
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'completed', label: '已完成' }
].map(tab => (
<View
key={tab.key}
className={`flex-1 py-3 text-center relative ${
activeTab === tab.key ? 'text-red-500 font-medium' : 'text-gray-500'
}`}
onClick={() => setActiveTab(tab.key as any)}
>
<Text>{tab.label}</Text>
{activeTab === tab.key && (
<View className="absolute bottom-0 w-8 bg-red-500 rounded-t" style={{ left: '50%', transform: 'translateX(-50%)', height: '4px' }} />
)}
</View>
))}
</View>
{/* 记录列表 */}
{loading ? (
<LoadMore loading />
) : filteredRecords.length === 0 ? (
<EmptyState message="暂无发票记录" />
) : (
<ScrollView
scrollY
className="p-4"
onScrollToLower={onReachBottom}
onScrollUpperThreshold={50}
>
{filteredRecords.map(record => {
const statusInfo = getStatusInfo(record.status);
const typeInfo = getTypeInfo(record.invoiceType);
return (
<View key={record.id} className="bg-white rounded-lg p-4 mb-4">
<View className="flex justify-between items-start mb-3">
<View className="flex-1">
<View className="flex items-center mb-2">
<Text className={`text-xs px-2 py-1 rounded mr-2 ${typeInfo.bgColor} ${typeInfo.textColor}`}>
{typeInfo.label}
</Text>
<Text className={`text-xs px-2 py-1 rounded mr-2 ${statusInfo.bgColor} ${statusInfo.textColor}`}>
{statusInfo.text}
</Text>
</View>
<Text className="text-lg font-medium">{record.titleName}</Text>
{record.orderNo && (
<Text className="text-xs text-gray-500 mt-1 block">{record.orderNo}</Text>
)}
</View>
<Text className="text-xl font-bold text-red-500">{formatMoney(record.amount)}</Text>
</View>
<View className="text-sm text-gray-500 flex flex-col" style={{ gap: '4px' }}>
<View className="flex justify-between">
<Text></Text>
<Text>{formatDate(record.applyTime)}</Text>
</View>
{record.invoiceNo && (
<View className="flex justify-between">
<Text></Text>
<Text>{record.invoiceNo}</Text>
</View>
)}
</View>
{record.status === 2 && (
<View className="mt-3 pt-3 border-t border-gray-100 flex justify-end">
<View
className="px-4 py-2 bg-blue-500 text-white rounded-full"
onClick={() => handleViewInvoice(record)}
>
<Text className="text-sm"></Text>
</View>
</View>
)}
</View>
);
})}
<LoadMore loading={loadingMore} hasMore={hasMore} />
</ScrollView>
)}
</View>
);
}

View File

@@ -0,0 +1,297 @@
import { View, Text, ScrollView, Input, Button } from '@tarojs/components';
import { useState, useEffect, useCallback } from 'react';
import { useDidShow } from '@tarojs/taro';
import Taro from '@tarojs/taro';
import NavBar from '@/components/NavBar';
import LoadMore from '@/components/common/LoadMore';
import EmptyState from '@/components/common/EmptyState';
import {
listShopInvoiceTitle,
addShopInvoiceTitle,
updateShopInvoiceTitle,
removeShopInvoiceTitle,
setDefaultInvoiceTitle
} from '@/api/shop/shopInvoiceTitle';
import type { ShopInvoiceTitle, ShopInvoiceTitleParam } from '@/api/shop/shopInvoiceTitle/model';
export default function InvoiceTitlePage() {
const [titleList, setTitleList] = useState<ShopInvoiceTitle[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [editItem, setEditItem] = useState<ShopInvoiceTitle | null>(null);
const [formData, setFormData] = useState({
type: 'personal' as 'personal' | 'company',
name: '',
taxNumber: '',
email: '',
});
// 加载数据
const loadData = useCallback(async () => {
setLoading(true);
try {
const params: ShopInvoiceTitleParam = {};
const result = await listShopInvoiceTitle(params);
setTitleList(result || []);
} catch (e: any) {
console.error('获取发票抬头失败:', e);
Taro.showToast({ title: e.message || '获取数据失败', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 删除
const handleDelete = async (id: number) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除该发票抬头吗?',
success: async (res) => {
if (res.confirm) {
try {
await removeShopInvoiceTitle(id);
Taro.showToast({ title: '删除成功', icon: 'success' });
loadData();
} catch (e: any) {
Taro.showToast({ title: e.message || '删除失败', icon: 'none' });
}
}
}
});
};
// 设置默认
const handleSetDefault = async (id: number) => {
try {
await setDefaultInvoiceTitle(id);
Taro.showToast({ title: '设置成功', icon: 'success' });
loadData();
} catch (e: any) {
Taro.showToast({ title: e.message || '设置失败', icon: 'none' });
}
};
// 编辑
const handleEdit = (item: ShopInvoiceTitle) => {
setEditItem(item);
setFormData({
type: (item.type as 'personal' | 'company') || 'personal',
name: item.name || '',
taxNumber: item.taxNumber || '',
email: item.email || '',
});
setShowAddModal(true);
};
// 添加/编辑提交
const handleSubmit = async () => {
if (!formData.name.trim()) {
Taro.showToast({ title: '请输入抬头名称', icon: 'none' });
return;
}
if (formData.type === 'company' && !formData.taxNumber.trim()) {
Taro.showToast({ title: '请输入税号', icon: 'none' });
return;
}
try {
if (editItem) {
await updateShopInvoiceTitle({
id: editItem.id,
...formData,
});
Taro.showToast({ title: '修改成功', icon: 'success' });
} else {
await addShopInvoiceTitle(formData);
Taro.showToast({ title: '添加成功', icon: 'success' });
}
setShowAddModal(false);
setEditItem(null);
setFormData({ type: 'personal', name: '', taxNumber: '', email: '' });
loadData();
} catch (e: any) {
Taro.showToast({ title: e.message || '操作失败', icon: 'none' });
}
};
// 取消编辑
const handleCancel = () => {
setShowAddModal(false);
setEditItem(null);
setFormData({ type: 'personal', name: '', taxNumber: '', email: '' });
};
return (
<View className="bg-gray-100 flex flex-col" style={{ minHeight: '100vh' }}>
<NavBar title="发票抬头管理" />
<View className='flex-1'>
{loading ? (
<LoadMore loading />
) : titleList.length === 0 ? (
<EmptyState message="暂无发票抬头" />
) : (
<ScrollView scrollY className="p-4">
{titleList.map(item => (
<View key={item.id} className="bg-white rounded-lg p-4 mb-4">
<View className="flex justify-between items-start mb-2">
<View className="flex-1">
<View className="flex items-center mb-2">
<Text className={`text-xs px-2 py-1 rounded mr-2 ${
item.type === 'personal' ? 'bg-blue-100 text-blue-600' : 'bg-orange-100 text-orange-600'
}`}>
{item.type === 'personal' ? '个人' : '企业'}
</Text>
{item.isDefault === 1 && (
<Text className="text-xs px-2 py-1 rounded bg-red-100 text-red-600">
</Text>
)}
</View>
<Text className="text-lg font-medium">{item.name}</Text>
{item.type === 'company' && item.taxNumber && (
<Text className="text-sm text-gray-500 mt-1 block">{item.taxNumber}</Text>
)}
{item.email && (
<Text className="text-sm text-gray-500 mt-1 block">{item.email}</Text>
)}
</View>
</View>
<View className="flex justify-between items-center pt-3 border-t border-gray-100">
<View
className="flex items-center"
onClick={() => handleSetDefault(item.id!)}
>
<View className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
item.isDefault === 1 ? 'bg-red-500 border-red-500' : 'border-gray-300'
}`}>
{item.isDefault === 1 && <Text className="text-white text-xs"></Text>}
</View>
<Text className="text-sm text-gray-600 ml-2"></Text>
</View>
<View className="flex" style={{ gap: '16px' }}>
<Text
className="text-sm text-blue-500"
onClick={() => handleEdit(item)}
>
</Text>
<Text
className="text-sm text-red-500"
onClick={() => handleDelete(item.id!)}
>
</Text>
</View>
</View>
</View>
))}
</ScrollView>
)}
</View>
{/* 添加/编辑弹窗 */}
{showAddModal && (
<View className="absolute flex items-center justify-center z-50" style={{ top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View className="bg-white rounded-lg p-6" style={{ width: '91.667%', maxWidth: '688px' }}>
<Text className="text-xl font-bold mb-4">{editItem ? '编辑发票抬头' : '添加发票抬头'}</Text>
{/* 抬头类型 */}
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<View className="flex">
<View
className={`flex-1 py-2 text-center border rounded-l ${
formData.type === 'personal' ? 'bg-red-50 border-red-500 text-red-500' : 'border-gray-300'
}`}
onClick={() => setFormData(prev => ({ ...prev, type: 'personal' }))}
>
<Text></Text>
</View>
<View
className={`flex-1 py-2 text-center border rounded-r ${
formData.type === 'company' ? 'bg-red-50 border-red-500 text-red-500' : 'border-gray-300'
}`}
onClick={() => setFormData(prev => ({ ...prev, type: 'company' }))}
>
<Text></Text>
</View>
</View>
</View>
{/* 抬头名称 */}
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="border rounded px-3 py-2"
placeholder="请输入发票抬头名称"
value={formData.name}
onInput={(e: any) => setFormData(prev => ({ ...prev, name: e.detail.value }))}
/>
</View>
{/* 税号 */}
{formData.type === 'company' && (
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="border rounded px-3 py-2"
placeholder="请输入税号"
value={formData.taxNumber}
onInput={(e: any) => setFormData(prev => ({ ...prev, taxNumber: e.detail.value }))}
/>
</View>
)}
{/* 邮箱 */}
<View className="mb-4">
<Text className="text-sm text-gray-600 mb-2 block"></Text>
<Input
className="border rounded px-3 py-2"
type="email"
placeholder="用于接收电子发票"
value={formData.email}
onInput={(e: any) => setFormData(prev => ({ ...prev, email: e.detail.value }))}
/>
</View>
{/* 按钮 */}
<View className="flex" style={{ gap: '12px' }}>
<Button
className="flex-1 py-2 border border-gray-300 rounded"
onClick={handleCancel}
>
</Button>
<Button
className="flex-1 py-2 bg-red-500 text-white rounded"
onClick={handleSubmit}
>
</Button>
</View>
</View>
</View>
)}
{/* 底部添加按钮 */}
<View className="p-4 bg-white border-t border-gray-200" style={{ paddingBottom: '20px' }}>
<Button
className="bg-red-500 text-white rounded-full w-full h-12"
onClick={() => {
setEditItem(null);
setFormData({ type: 'personal', name: '', taxNumber: '', email: '' });
setShowAddModal(true);
}}
>
</Button>
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '门店列表',
}

131
src_bak/pages/list.tsx Normal file
View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopStore } from '@/api/shop/shopStore'
import type { ShopStore } from '@/api/shop/shopStore/model'
import EmptyState from '@/components/common/EmptyState'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '门店列表',
})
const StoreListPage: React.FC = () => {
const [stores, setStores] = useState<ShopStore[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
fetchStores()
}, [])
const fetchStores = async () => {
try {
const data = await listShopStore({ status: 1 })
setStores(data || [])
} catch (e) {
console.error('获取门店列表失败:', e)
} finally {
setLoading(false)
}
}
const handleCallStore = (phone: string) => {
Taro.makePhoneCall({
phoneNumber: phone,
fail: () => {
Taro.showToast({ title: '拨打失败', icon: 'none' })
},
})
}
const handleOpenMap = (store: ShopStore) => {
if (store.latitude && store.longitude) {
Taro.openLocation({
latitude: parseFloat(store.latitude),
longitude: parseFloat(store.longitude),
name: store.name || '',
address: store.address || '',
})
} else {
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
}
}
const handleBooking = (storeId: number) => {
Taro.navigateTo({ url: `/pages/store/booking?storeId=${storeId}` })
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : stores.length === 0 ? (
<EmptyState text='暂无门店' />
) : (
<View className='p-3'>
{stores.map(store => (
<View key={store.id} className='bg-white rounded-lg p-3 mb-3'>
<View className='flex justify-between items-start mb-2'>
<Text className='text-base font-medium text-gray-800 flex-1'>
{store.name}
</Text>
{store.distance && (
<Text className='text-xs text-gray-400 ml-2'>
{store.distance < 1
? `${Math.round(store.distance * 1000)}m`
: `${store.distance.toFixed(1)}km`}
</Text>
)}
</View>
<View className='flex items-center mb-1'>
<Text className='text-xs text-gray-400 mr-2'>📍</Text>
<Text className='text-xs text-gray-600 flex-1'>
{store.address}
</Text>
</View>
{store.businessHours && (
<View className='flex items-center mb-2'>
<Text className='text-xs text-gray-400 mr-2'>🕐</Text>
<Text className='text-xs text-gray-600'>
{store.businessHours}
</Text>
</View>
)}
<View className='flex gap-2 mt-2'>
<View
className='flex-1 py-2 bg-blue-50 rounded-lg text-center'
onClick={() => handleCallStore(store.phone || '')}
>
<Text className='text-xs text-blue-500'>📞 </Text>
</View>
<View
className='flex-1 py-2 bg-green-50 rounded-lg text-center'
onClick={() => handleOpenMap(store)}
>
<Text className='text-xs text-green-500'>📍 </Text>
</View>
{/*<View*/}
{/* className='flex-1 py-2 bg-orange-50 rounded-lg text-center'*/}
{/* onClick={() => handleBooking(store.id!)}*/}
{/*>*/}
{/* <Text className='text-xs text-orange-500'>📅 立即预约</Text>*/}
{/*</View>*/}
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default StoreListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '会员中心',
}

137
src_bak/pages/member.tsx Normal file
View File

@@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '会员中心',
})
const MemberPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [memberLevel, setMemberLevel] = useState('普通会员')
const [commission, setCommission] = useState(0)
const [totalCommission, setTotalCommission] = useState(0)
const scrollHeight = useScrollHeight(50)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
}
}, [isLoggedIn])
const memberFeatures = [
{ icon: '💰', label: '我的佣金', value: `¥${commission}`, url: '/pages/user/commission' },
{ icon: '📊', label: '佣金明细', value: '', url: '/pages/user/commission-detail' },
{ icon: '👥', label: '我的团队', value: '', url: '/pages/user/team' },
{ icon: '📋', label: '邀请记录', value: '', url: '/pages/user/invite-record' },
]
const memberBenefits = [
'享受会员专属折扣',
'获得分销佣金权益',
'优先客服支持',
'专属活动邀请',
]
const handleUpgrade = () => {
Taro.navigateTo({ url: '/pages/user/member-upgrade' })
}
const handleFeatureClick = (url: string) => {
Taro.navigateTo({ url })
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 会员卡片 */}
<View className='mx-3 mt-3 p-4 rounded-xl' style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<View className='flex items-center justify-between mb-3'>
<View>
<Text className='text-white text-lg font-bold'>{user?.nickname || '用户'}</Text>
<Text className='text-white text-sm opacity-80 block mt-1'>{memberLevel}</Text>
</View>
<View
className='px-3 py-1 rounded-full'
style={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
onClick={handleUpgrade}
>
<Text className='text-white text-sm'></Text>
</View>
</View>
<View className='flex justify-around'>
<View className='text-center'>
<Text className='text-white text-xl font-bold block'>{totalCommission}</Text>
<Text className='text-white text-xs opacity-80'></Text>
</View>
<View className='text-center'>
<Text className='text-white text-xl font-bold block'>{commission}</Text>
<Text className='text-white text-xs opacity-80'></Text>
</View>
</View>
</View>
{/* 会员功能 */}
<View className='bg-white mx-3 mt-3 rounded-lg overflow-hidden'>
{memberFeatures.map((item, idx) => (
<View
key={item.label}
className={`flex items-center justify-between px-4 py-3 ${
idx < memberFeatures.length - 1 ? 'border-b border-gray-50' : ''
}`}
onClick={() => handleFeatureClick(item.url)}
>
<View className='flex items-center gap-3'>
<Text className='text-xl'>{item.icon}</Text>
<Text className='text-sm text-gray-700'>{item.label}</Text>
</View>
<View className='flex items-center gap-2'>
{item.value && (
<Text className='text-sm font-medium text-orange-500'>{item.value}</Text>
)}
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
))}
</View>
{/* 会员权益说明 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
{memberBenefits.map((benefit, idx) => (
<View key={idx} className='flex items-center gap-2 mb-2'>
<Text className='text-green-500'></Text>
<Text className='text-sm text-gray-600'>{benefit}</Text>
</View>
))}
</View>
{/* 分销推广 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-base font-medium text-gray-800 mb-3 block'>广</Text>
<View className='bg-orange-50 rounded-lg p-3'>
<Text className='text-sm text-orange-600 mb-1 block'>广</Text>
<Text className='text-xs text-gray-500 mb-2 block'></Text>
<View
className='bg-orange-500 rounded-full py-2 text-center'
onClick={() => Taro.navigateTo({ url: '/pages/user/promotion' })}
>
<Text className='text-white text-sm'>广</Text>
</View>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default MemberPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '消息详情',
}

View File

@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getShopMessage, deleteShopMessage } from '@/api/shop/shopMessage'
import type { ShopMessage, MessageType } from '@/api/shop/shopMessage/model'
definePageConfig({
navigationBarTitleText: '消息详情',
})
const MessageDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [message, setMessage] = useState<ShopMessage | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchMessage(id)
}
}, [id])
const fetchMessage = async (msgId: string) => {
try {
const res = await getShopMessage(msgId)
if (res?.code === 0 && res.data) {
setMessage(res.data)
}
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 获取类型图标
const getTypeIcon = (type: MessageType) => {
const iconMap: Record<MessageType, string> = {
system: '🔔',
order: '📦',
activity: '🎁',
}
return iconMap[type] || '📋'
}
// 获取类型名称
const getTypeName = (type: MessageType) => {
const nameMap: Record<MessageType, string> = {
system: '系统通知',
order: '订单通知',
activity: '活动通知',
}
return nameMap[type] || '其他'
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 删除消息
const handleDelete = () => {
if (!message) return
Taro.showModal({
title: '提示',
content: '确定删除该消息吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await deleteShopMessage(message.id)
Taro.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} catch (err: any) {
Taro.showToast({ title: err.message || '删除失败', icon: 'none' })
}
}
}
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
if (!message) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 消息头部 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-3'>
<Text className='text-2xl'>{getTypeIcon(message.type)}</Text>
<View className='flex-1'>
<Text className='text-lg font-bold text-gray-800 block mb-1'>
{message.title}
</Text>
<View className='flex items-center gap-2'>
<Text className='text-xs text-gray-400'>
{formatTime(message.createdAt)}
</Text>
<View className='bg-gray-100 px-2 py-1 rounded'>
<Text className='text-xs text-gray-500'>
{getTypeName(message.type)}
</Text>
</View>
</View>
</View>
</View>
</View>
{/* 消息内容 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{message.content}
</Text>
</View>
{/* 相关操作 */}
<View className='bg-white p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-col gap-3'>
<View
className='flex items-center justify-between py-2 border-b border-gray-50'
onClick={() => Taro.navigateTo({ url: '/pages/user/user' })}
>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-gray-400'></Text>
</View>
<View
className='flex items-center justify-between py-2 border-b border-gray-50'
onClick={() => Taro.switchTab({ url: '/pages/index/index' })}
>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-gray-400'></Text>
</View>
<View
className='flex items-center justify-between py-2'
onClick={handleDelete}
>
<Text className='text-sm text-red-500'></Text>
<Text className='text-gray-400'></Text>
</View>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default MessageDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '消息通知',
}

View File

@@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopMessage, readShopMessage, deleteShopMessage, readAllShopMessage } from '@/api/shop/shopMessage'
import type { ShopMessage, MessageType } from '@/api/shop/shopMessage'
definePageConfig({
navigationBarTitleText: '消息通知',
})
// 消息类型
const messageTypes = [
{ label: '全部', value: '' },
{ label: '系统通知', value: 'system' },
{ label: '订单通知', value: 'order' },
{ label: '活动通知', value: 'activity' },
]
const MessageListPage: React.FC = () => {
const [activeType, setActiveType] = useState<string>('')
const [messages, setMessages] = useState<ShopMessage[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setMessages([])
setPage(1)
setFinished(false)
loadList(1)
}, [activeType])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const params: any = {
page: p,
limit: 10,
}
if (activeType) {
params.type = activeType
}
const res = await listShopMessage(params)
if (res?.list) {
if (p === 1) {
setMessages(res.list)
} else {
setMessages(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 markAsRead = async (id: string) => {
try {
await readShopMessage(id)
setMessages(prev =>
prev.map(msg =>
msg.id === id ? { ...msg, isRead: true } : msg
)
)
Taro.navigateTo({ url: `/pages/message/detail/index?id=${id}` })
} catch (err) {
console.error('标记已读失败', err)
}
}
// 删除消息
const deleteMessage = (id: string) => {
Taro.showModal({
title: '提示',
content: '确定删除该消息吗?',
success: async (res) => {
if (res.confirm) {
try {
await deleteShopMessage(id)
setMessages(prev => prev.filter(msg => msg.id !== id))
Taro.showToast({ title: '删除成功', icon: 'success' })
} catch (err) {
console.error('删除消息失败', err)
Taro.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 全部标记为已读
const markAllAsRead = () => {
Taro.showModal({
title: '提示',
content: '确定将所有消息标记为已读吗?',
success: async (res) => {
if (res.confirm) {
try {
await readAllShopMessage()
setMessages(prev =>
prev.map(msg => ({ ...msg, isRead: true }))
)
Taro.showToast({ title: '已全部标为已读', icon: 'success' })
} catch (err) {
console.error('全部标记已读失败', err)
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
// 获取类型图标
const getTypeIcon = (type: MessageType) => {
const iconMap: Record<MessageType, string> = {
'system': '🔔',
'order': '📦',
'activity': '🎁',
}
return iconMap[type] || '📋'
}
// 获取类型名称
const getTypeName = (type: MessageType) => {
const nameMap: Record<MessageType, string> = {
'system': '系统',
'order': '订单',
'activity': '活动',
}
return nameMap[type] || '其他'
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `今天 ${hours}:${minutes}`
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${month}-${day}`
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
{/* 顶部操作栏 */}
<View className='bg-white px-3 py-2 flex justify-between items-center border-b border-gray-100'>
<Text className='text-sm text-gray-500'>
{messages.length}
</Text>
<View onClick={markAllAsRead}>
<Text className='text-sm text-orange-500'></Text>
</View>
</View>
{/* 分类标签 */}
<View className='bg-white flex overflow-x-auto'>
{messageTypes.map(type => (
<View
key={type.value}
className={`flex-shrink-0 px-4 py-3 relative ${
activeType === type.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveType(type.value)}
>
<Text className='text-sm'>{type.label}</Text>
{activeType === type.value && (
<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'>
{messages.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'>
{messages.map(message => (
<View
key={message.id}
className={`bg-white rounded-lg p-4 mb-3 relative ${
!message.isRead ? 'border-l-4 border-orange-500' : ''
}`}
onClick={() => markAsRead(message.id)}
>
{/* 未读标识 */}
{!message.isRead && (
<View className='absolute top-4 right-4 w-2 h-2 bg-red-500 rounded-full' />
)}
{/* 消息头部 */}
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>{getTypeIcon(message.type)}</Text>
<Text className='text-sm font-medium text-gray-800 flex-1'>
{message.title}
</Text>
<Text className='text-xs text-gray-400'>
{formatTime(message.createdAt)}
</Text>
</View>
{/* 消息内容 */}
<Text className='text-sm text-gray-600 mb-2 block ml-7'>
{message.content}
</Text>
{/* 消息底部 */}
<View className='flex items-center justify-between ml-7'>
<View className='bg-gray-100 px-2 py-1 rounded'>
<Text className='text-xs text-gray-500'>
{getTypeName(message.type)}
</Text>
</View>
<View onClick={(e) => {
e.stopPropagation()
deleteMessage(message.id)
}}>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default MessageListPage

View File

@@ -0,0 +1,220 @@
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 handleDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/points/order-list/index?id=${id}` })
}
// 取消订单
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 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,340 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Input, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button, Uploader } from '@nutui/nutui-react-taro'
import { getShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '申请售后',
})
// 售后类型
const AFTER_SALE_TYPES = [
{ value: 'refund', label: '退款', desc: '仅退款,商品无需退回' },
{ value: 'return', label: '退货退款', desc: '退款并退货,商品需退回' },
]
// 退款原因选项
const REASON_OPTIONS = [
'商品损坏/有瑕疵',
'商品与描述不符',
'收到错误商品',
'商品少发/漏发',
'不想要了',
'其他原因',
]
const AfterSaleApplyPage: React.FC = () => {
const { orderId, type } = Taro.getCurrentInstance().router?.params || {}
const [order, setOrder] = useState<ShopOrder | null>(null)
const [loading, setLoading] = useState(false)
// 售后类型
const [afterSaleType, setAfterSaleType] = useState<'refund' | 'return'>(
(type as 'refund' | 'return') || 'refund'
)
// 退款原因
const [selectedReason, setSelectedReason] = useState('')
const [reasonDesc, setReasonDesc] = useState('')
// 图片上传
const [images, setImages] = useState<string[]>([])
// 选中的商品
const [selectedItems, setSelectedItems] = useState<number[]>([])
// 可退金额
const [refundAmount, setRefundAmount] = useState('0.00')
useEffect(() => {
if (orderId) {
loadOrder()
}
}, [orderId])
const loadOrder = async () => {
try {
const res = await getShopOrder(Number(orderId))
if (res) {
setOrder(res)
// 默认选中所有商品
const allItemIndices = (res.orderGoods || []).map((_, idx) => idx)
setSelectedItems(allItemIndices)
// 计算可退金额
const amount = calculateRefundAmount(res, allItemIndices)
setRefundAmount(amount)
}
} catch (err) {
console.error('加载订单失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
}
}
// 计算可退金额
const calculateRefundAmount = (orderData: ShopOrder, selectedIdx: number[]): string => {
if (!orderData.orderGoods || selectedIdx.length === 0) return '0.00'
let total = 0
orderData.orderGoods.forEach((item, idx) => {
if (selectedIdx.includes(idx)) {
total += Number(item.price || 0) * (item.num || item.quantity || 1)
}
})
// 按比例计算优惠分摊
const totalPrice = Number(orderData.totalPrice || 0)
if (totalPrice > 0) {
const payPrice = Number(orderData.payPrice || 0)
const ratio = payPrice / totalPrice
total = total * ratio
}
return total.toFixed(2)
}
// 切换商品选中
const toggleItem = (idx: number) => {
const newSelected = selectedItems.includes(idx)
? selectedItems.filter(i => i !== idx)
: [...selectedItems, idx]
setSelectedItems(newSelected)
if (order) {
setRefundAmount(calculateRefundAmount(order, newSelected))
}
}
// 全选/取消全选
const toggleAll = () => {
if (!order?.orderGoods) return
if (selectedItems.length === order.orderGoods.length) {
setSelectedItems([])
setRefundAmount('0.00')
} else {
const allIdx = order.orderGoods.map((_, idx) => idx)
setSelectedItems(allIdx)
setRefundAmount(calculateRefundAmount(order, allIdx))
}
}
// 提交申请
const handleSubmit = async () => {
if (selectedItems.length === 0) {
Taro.showToast({ title: '请选择要售后的商品', icon: 'none' })
return
}
if (!selectedReason) {
Taro.showToast({ title: '请选择退款原因', icon: 'none' })
return
}
Taro.showModal({
title: '确认提交',
content: `确认提交${afterSaleType === 'refund' ? '退款' : '退货退款'}申请?`,
success: async (res) => {
if (res.confirm) {
await submitApply()
}
},
})
}
const submitApply = async () => {
if (!selectedReason) {
Taro.showToast({ title: '请选择退款原因', icon: 'none' })
return
}
setLoading(true)
try {
const res = await applyAfterSale({
orderId: orderId || '',
type: afterSaleType,
reason: selectedReason,
description: reasonDesc,
amount: parseFloat(refundAmount),
evidenceImages: images
})
if (res.success) {
Taro.showToast({ title: '申请提交成功', icon: 'success' })
setTimeout(() => {
Taro.redirectTo({ url: '/pages/order/after-sale-list' })
}, 1500)
} else {
Taro.showToast({ title: res.message || '提交失败', icon: 'none' })
}
} catch (err) {
console.error('申请售后失败:', err)
Taro.showToast({ title: '提交失败', icon: 'none' })
} finally {
setLoading(false)
}
}
if (!order) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50 pb-20 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 售后类型选择 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex gap-3'>
{AFTER_SALE_TYPES.map(item => (
<View
key={item.value}
className={`flex-1 p-3 rounded-lg border-2 text-center ${
afterSaleType === item.value
? 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white'
}`}
onClick={() => setAfterSaleType(item.value as 'refund' | 'return')}
>
<Text className={`text-sm font-medium block ${
afterSaleType === item.value ? 'text-green-600' : 'text-gray-600'
}`}>
{item.label}
</Text>
<Text className='text-xs text-gray-400 mt-1 block'>{item.desc}</Text>
</View>
))}
</View>
</View>
{/* 商品选择 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-sm font-medium text-gray-800'></Text>
<Text
className='text-xs text-green-600'
onClick={toggleAll}
>
{selectedItems.length === order.orderGoods?.length ? '取消全选' : '全选'}
</Text>
</View>
{order.orderGoods?.map((item, idx) => (
<View
key={idx}
className='flex items-center gap-3 py-3 border-b border-gray-50'
style={idx === (order.orderGoods?.length ?? 0) - 1 ? { borderBottom: 'none' } : undefined}
onClick={() => toggleItem(idx)}
>
{/* 选中状态 */}
<View className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedItems.includes(idx)
? 'border-green-500 bg-green-500'
: 'border-gray-300'
}`}>
{selectedItems.includes(idx) && (
<Text className='text-white text-xs'></Text>
)}
</View>
{/* 商品图片 */}
<Image
className='w-16 h-16 rounded-lg'
src={item.image || ''}
mode='aspectFill'
/>
{/* 商品信息 */}
<View className='flex-1'>
<Text className='text-sm text-gray-700 line-clamp-2'>{item.goodsName}</Text>
<View className='flex justify-between items-center mt-1'>
<Price price={item.price || '0'} size='small' />
<Text className='text-xs text-gray-500'>x{item.num || item.quantity || 1}</Text>
</View>
</View>
</View>
))}
</View>
{/* 退款原因 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'>退</Text>
<View className='flex flex-wrap gap-2'>
{REASON_OPTIONS.map(reason => (
<View
key={reason}
className={`px-3 py-2 rounded-full text-sm ${
selectedReason === reason
? 'bg-green-50 text-green-600 border border-green-500'
: 'bg-gray-50 text-gray-600 border border-gray-200'
}`}
onClick={() => setSelectedReason(reason)}
>
<Text>{reason}</Text>
</View>
))}
</View>
{/* 补充说明 */}
<View className='mt-3'>
<Text className='text-xs text-gray-500 mb-1 block'></Text>
<View className='bg-gray-50 rounded-lg p-3'>
<Input
className='w-full text-sm'
placeholder='请详细描述您遇到的问题'
value={reasonDesc}
onInput={e => setReasonDesc(e.detail.value)}
style={{ minHeight: '80px' }}
/>
</View>
</View>
</View>
{/* 上传凭证 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<Uploader
value={images.map((url, idx) => ({ id: String(idx), url }))}
onChange={(files) => {
setImages(files.map(f => f.url || ''))
}}
maxCount={3}
/>
</View>
{/* 退款金额 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl mb-4'>
<View className='flex justify-between items-center'>
<Text className='text-sm font-medium text-gray-800'>退</Text>
<Text className='text-lg font-bold text-red-500'>
{'\u00A5'}{refundAmount}
</Text>
</View>
{afterSaleType === 'return' && (
<Text className='text-xs text-gray-400 mt-2 block'>
退退
</Text>
)}
</View>
</ScrollView>
{/* 提交按钮 */}
<View className='bg-white border-t border-gray-100 p-3' style={{ paddingBottom: '20px' }}>
<Button
type='primary'
className='w-full rounded-full'
loading={loading}
style={{ backgroundColor: '#0e932e' }}
onClick={handleSubmit}
>
</Button>
</View>
</View>
)
}
export default AfterSaleApplyPage

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { getAfterSaleDetail, cancelAfterSale, formatAfterSaleStatus, AFTER_SALE_TYPE_MAP } from '@/api/shop/shopAfterSale'
import type { AfterSaleDetail } from '@/api/shop/shopAfterSale'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '售后详情',
})
const AfterSaleDetailPage: React.FC = () => {
const { id, orderId } = Taro.getCurrentInstance().router?.params || {}
const [detail, setDetail] = useState<AfterSaleDetail | null>(null)
const [loading, setLoading] = useState(false)
const [cancelling, setCancelling] = useState(false)
useEffect(() => {
loadDetail()
}, [id, orderId])
const loadDetail = async () => {
setLoading(true)
try {
const res = await getAfterSaleDetail({ afterSaleId: id, orderId })
if (res?.data) {
setDetail(res.data)
}
} catch (err) {
console.error('加载售后详情失败', err)
} finally {
setLoading(false)
}
}
// 取消申请
const handleCancel = () => {
Taro.showModal({
title: '确认取消',
content: '确定要撤销售后申请吗?取消后不可恢复。',
success: async (res) => {
if (res.confirm) {
setCancelling(true)
try {
// await cancelAfterSale(id || '')
await new Promise(resolve => setTimeout(resolve, 1000))
Taro.showToast({ title: '已取消申请', icon: 'success' })
setTimeout(() => {
Taro.navigateBack()
}, 1500)
} catch (err) {
Taro.showToast({ title: '取消失败', icon: 'none' })
} finally {
setCancelling(false)
}
}
},
})
}
// 格式化时间
const formatTime = (time: string) => {
if (!time) return ''
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
if (!detail) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400 text-sm'>{loading ? '加载中...' : '加载失败'}</Text>
</View>
)
}
const statusInfo = formatAfterSaleStatus(detail.status)
const canCancel = ['pending'].includes(detail.status)
return (
<View className='min-h-screen bg-gray-50 pb-20 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 状态头部 */}
<View
className='p-5 text-center'
style={{ backgroundColor: statusInfo.color }}
>
<Text className='text-white text-xl font-medium block'>{statusInfo.text}</Text>
{detail.status === 'processing' && (
<Text className='text-white text-sm opacity-80 mt-1 block'>
1-3
</Text>
)}
{detail.status === 'pending' && (
<Text className='text-white text-sm opacity-80 mt-1 block'>
</Text>
)}
{detail.status === 'approved' && (
<Text className='text-white text-sm opacity-80 mt-1 block'>
</Text>
)}
{detail.status === 'completed' && (
<Text className='text-white text-sm opacity-80 mt-1 block'>
退
</Text>
)}
{detail.status === 'rejected' && (
<Text className='text-white text-sm opacity-80 mt-1 block'>
{detail.rejectReason || '商家拒绝了您的申请'}
</Text>
)}
</View>
{/* 进度时间线 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='relative'>
{/* 竖线 */}
<View className='absolute left-4 top-0 bottom-0 w-px bg-gray-200' />
{detail.progressRecords.map((record, idx) => (
<View key={record.id} className={`relative flex gap-3 pb-4 ${idx === detail.progressRecords.length - 1 ? 'pb-0' : ''}`}>
{/* 圆点 */}
<View
className={`w-8 h-8 rounded-full flex items-center justify-center z-10 ${
idx === 0 ? 'bg-green-500' : 'bg-gray-200'
}`}
>
{idx === 0 ? (
<Text className='text-white text-xs'></Text>
) : (
<View className='w-2 h-2 rounded-full bg-gray-400' />
)}
</View>
{/* 内容 */}
<View className='flex-1 pt-1'>
<View className='flex justify-between items-center'>
<Text className={`text-sm font-medium ${
idx === 0 ? 'text-gray-800' : 'text-gray-500'
}`}>
{record.status}
</Text>
<Text className='text-xs text-gray-400'>{formatTime(record.time)}</Text>
</View>
<Text className={`text-xs mt-1 ${idx === 0 ? 'text-gray-600' : 'text-gray-400'}`}>
{record.description}
</Text>
{record.operator && (
<Text className='text-xs text-gray-400 mt-1'>
: {record.operator}
</Text>
)}
</View>
</View>
))}
</View>
</View>
{/* 售后信息 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className="flex flex-col" style={{ gap: '8px' }}>
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>
{AFTER_SALE_TYPE_MAP[detail.type] || detail.type}
</Text>
</View>
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{detail.reason}</Text>
</View>
{detail.description && (
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800 max-w-50 text-right'>{detail.description}</Text>
</View>
)}
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{formatTime(detail.applyTime)}</Text>
</View>
{detail.contactPhone && (
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{detail.contactPhone}</Text>
</View>
)}
</View>
</View>
{/* 退款金额 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex justify-between items-center'>
<Text className='text-sm font-medium text-gray-800'>退</Text>
<Text className='text-xl font-bold text-red-500'>
{'\u00A5'}{detail.amount.toFixed(2)}
</Text>
</View>
<Text className='text-xs text-gray-400 mt-2 block'>
退退1-3
</Text>
</View>
{/* 凭证图片 */}
{detail.evidenceImages && detail.evidenceImages.length > 0 && (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex gap-2 flex-wrap'>
{detail.evidenceImages.map((url, idx) => (
<Image
key={idx}
className='w-20 h-20 rounded-lg'
src={url}
mode='aspectFill'
/>
))}
</View>
</View>
)}
{/* 订单信息 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl mb-4'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm font-medium text-gray-800'></Text>
<View
className='px-3 py-1 rounded-full bg-gray-50'
onClick={() => Taro.navigateTo({ url: `/pages/order/detail?id=${detail.orderId}` })}
>
<Text className='text-xs text-gray-500'> </Text>
</View>
</View>
<Text className='text-sm text-gray-400'>: {detail.orderNo}</Text>
</View>
</ScrollView>
{/* 底部按钮 */}
{canCancel && (
<View className='bg-white border-t border-gray-100 p-3' style={{ paddingBottom: '20px' }}>
<Button
type='default'
className='w-full rounded-full border-gray-200'
loading={cancelling}
onClick={handleCancel}
>
</Button>
</View>
)}
</View>
)
}
export default AfterSaleDetailPage

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Tabs } from '@nutui/nutui-react-taro'
import { pageAfterSaleList } from '@/api/shop/shopAfterSale'
import type { AfterSaleDetail, AfterSaleStatus } from '@/api/shop/shopAfterSale'
import { formatAfterSaleStatus, AFTER_SALE_STATUS_MAP } from '@/api/shop/shopAfterSale'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '售后列表',
})
// Tab 配置
const TAB_LIST = [
{ title: '全部', status: undefined },
{ title: '处理中', status: 'processing' as AfterSaleStatus },
{ title: '待收货', status: 'approved' as AfterSaleStatus },
{ title: '已完成', status: 'completed' as AfterSaleStatus },
{ title: '已拒绝', status: 'rejected' as AfterSaleStatus },
]
// 状态对应的 API 查询值
const STATUS_QUERY_MAP: Record<string, AfterSaleStatus | undefined> = {
'processing': 'processing',
'approved': 'approved',
'completed': 'completed',
'rejected': 'rejected',
}
const AfterSaleListPage: React.FC = () => {
const [tabIndex, setTabIndex] = useState(0)
const [list, setList] = useState<AfterSaleDetail[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setList([])
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 res = await pageAfterSaleList({
page: p,
pageSize: 10,
status: STATUS_QUERY_MAP[status || ''],
})
const newList = res?.data?.list || []
if (p === 1) {
setList(newList)
} else {
setList(prev => [...prev, ...newList])
}
setFinished(newList.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 goToDetail = (item: AfterSaleDetail) => {
Taro.navigateTo({
url: `/pages/order/after-sale-detail?id=${item.id}&orderId=${item.orderId}`,
})
}
// 格式化时间
const formatTime = (time: string) => {
if (!time) return ''
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// 售后类型文字
const getTypeText = (type: string) => {
const map: Record<string, string> = {
refund: '退款',
return: '退货退款',
exchange: '换货',
repair: '维修',
}
return map[type] || type
}
return (
<View className='min-h-screen bg-gray-50'>
<Tabs value={tabIndex} onChange={(val) => setTabIndex(val as number)} type='smile'>
{TAB_LIST.map((tab) => (
<Tabs.TabPane key={tab.title} title={tab.title}>
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{list.length > 0 ? (
list.map(item => {
const statusInfo = formatAfterSaleStatus(item.status)
return (
<View
key={item.id}
className='bg-white rounded-xl p-4 mb-3'
onClick={() => goToDetail(item)}
>
{/* 状态头部 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-sm text-gray-400'>: {item.orderNo}</Text>
<View className='flex items-center gap-1'>
<Text className='text-sm font-medium' style={{ color: statusInfo.color }}>
{statusInfo.text}
</Text>
<Text className='text-gray-400'></Text>
</View>
</View>
{/* 售后类型 */}
<View className='flex items-center gap-2 mb-2'>
<View className={`px-2 py-1 rounded text-xs ${
item.type === 'refund' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600'
}`}>
<Text>{getTypeText(item.type)}</Text>
</View>
<Text className='text-sm text-gray-600'>{item.reason}</Text>
</View>
{/* 金额 */}
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
<Text className='text-xs text-gray-400'>: {formatTime(item.applyTime)}</Text>
<View className='flex items-center gap-1'>
<Text className='text-xs text-gray-500'>退</Text>
<Text className='text-base font-bold text-red-500'>
{'\u00A5'}{item.amount.toFixed(2)}
</Text>
</View>
</View>
</View>
)
})
) : !loading ? (
<EmptyState text='暂无售后记录' />
) : null}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</Tabs.TabPane>
))}
</Tabs>
</View>
)
}
export default AfterSaleListPage

View File

@@ -0,0 +1,519 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { getShopOrder, prepayShopOrder, updateShopOrder, repairOrder, removeShopOrder } from '@/api/shop/shopOrder'
import { listShopOrderGoodsByOrderId } from '@/api/shop/shopOrderGoods'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
import { OrderStatus, OrderStatusText } from '@/types/order'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '订单详情',
})
// 支付方式映射
const PAY_TYPE_MAP: Record<number, string> = {
0: '货到付款',
1: '微信支付',
2: '会员卡支付',
3: '支付宝',
15: '积分支付',
}
// 发票状态映射
const INVOICE_STATUS_MAP: Record<number, string> = {
0: '未开票',
1: '已开票',
2: '不可开票',
}
// 发货状态映射
const DELIVERY_STATUS_MAP: Record<number, string> = {
10: '未发货',
20: '已发货',
30: '部分发货',
}
/** 根据订单状态计算展示用的状态标签 */
const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: string; subtitle: string } => {
const { payStatus, deliveryStatus, orderStatus } = order
// 退款/取消相关状态
if (orderStatus === OrderStatus.Cancelled) {
return { bgColor: '#999', title: '已取消', subtitle: '订单已取消' }
}
if (orderStatus === OrderStatus.Cancelling) {
return { bgColor: '#ff7d00', title: '取消中', subtitle: '退款处理中' }
}
if (orderStatus === OrderStatus.RefundSuccess) {
return { bgColor: '#999', title: '已退款', subtitle: '退款已原路返回' }
}
if (orderStatus === OrderStatus.RefundApply || orderStatus === OrderStatus.ClientRefundApply) {
return { bgColor: '#ee0a24', title: '退款申请中', subtitle: '商家正在处理退款' }
}
if (orderStatus === OrderStatus.RefundRejected) {
return { bgColor: '#ee0a24', title: '退款被拒绝', subtitle: '退款申请已被拒绝' }
}
// 正常订单流程
if (!payStatus) {
return { bgColor: '#ff7d00', title: '待付款', subtitle: '请尽快完成支付' }
}
if (deliveryStatus === 10) {
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
}
if (deliveryStatus === 20 || deliveryStatus === 30) {
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
}
if (orderStatus === OrderStatus.Completed) {
return { bgColor: '#0e932e', title: '已完成', subtitle: '交易已完成,感谢购买' }
}
if (orderStatus === OrderStatus.Unused) {
return { bgColor: '#0e932e', title: '已付款', subtitle: '订单已支付' }
}
// 默认
return { bgColor: '#999', title: OrderStatusText[orderStatus ?? 0] || '未知', subtitle: '' }
}
/** 根据订单状态判断当前操作阶段 */
type OrderPhase = 'unpaid' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
const getOrderPhase = (order: ShopOrder): OrderPhase => {
const { payStatus, deliveryStatus, orderStatus } = order
// 退款/取消
if ([OrderStatus.Cancelled, OrderStatus.Cancelling, OrderStatus.RefundSuccess, OrderStatus.RefundApply, OrderStatus.ClientRefundApply, OrderStatus.RefundRejected].includes(orderStatus as OrderStatus)) {
return orderStatus === OrderStatus.Cancelled || orderStatus === OrderStatus.RefundSuccess ? 'cancelled' : 'refund'
}
if (!payStatus) return 'unpaid'
if (deliveryStatus === 10) return 'unshipped'
if (deliveryStatus === 20 || deliveryStatus === 30) return 'shipped'
if (orderStatus === OrderStatus.Completed) return 'completed'
return 'unshipped'
}
const OrderDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [order, setOrder] = useState<ShopOrder | null>(null)
const [loading, setLoading] = useState(false)
const loadOrder = useCallback(async () => {
if (!id) return
try {
const orderData = await getShopOrder(Number(id))
// 如果订单中没有商品清单,则单独加载
if (!orderData.orderGoods || orderData.orderGoods.length === 0) {
try {
const goods = await listShopOrderGoodsByOrderId(Number(id))
orderData.orderGoods = goods
} catch {
// 忽略加载商品失败
}
}
setOrder(orderData)
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' })
}
}, [id])
useEffect(() => {
loadOrder()
}, [loadOrder])
// 格式化时间
const formatTime = (time: string | undefined) => {
if (!time) return '-'
return time.replace('T', ' ').slice(0, 19)
}
// 取消订单
const handleCancelOrder = () => {
Taro.showModal({
title: '确认取消',
content: '确定要取消该订单吗?',
confirmColor: '#ee0a24',
success: async (res) => {
if (res.confirm && order) {
try {
await updateShopOrder({ ...order, orderStatus: OrderStatus.Cancelled })
Taro.showToast({ title: '订单已取消', icon: 'success' })
loadOrder()
} catch {
Taro.showToast({ title: '取消失败', icon: 'none' })
}
}
}
})
}
// 立即支付
const handlePay = async () => {
if (!order || loading) return
setLoading(true)
try {
const payResult = await prepayShopOrder({ orderId: order.orderId!, payType: 1 })
if (payResult) {
await Taro.requestPayment({
timeStamp: payResult.timeStamp,
nonceStr: payResult.nonceStr,
package: payResult.package,
signType: payResult.signType,
paySign: payResult.paySign,
})
Taro.showToast({ title: '支付成功', icon: 'success' })
loadOrder()
}
} catch (err: any) {
if (err?.message !== 'requestPayment:fail cancel') {
Taro.showToast({ title: err?.message || '支付失败', icon: 'none' })
}
} finally {
setLoading(false)
}
}
// 确认收货
const handleConfirmReceive = () => {
Taro.showModal({
title: '确认收货',
content: '确认已收到商品?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm && order) {
try {
await updateShopOrder({ ...order, orderStatus: OrderStatus.Completed })
Taro.showToast({ title: '已确认收货', icon: 'success' })
loadOrder()
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
// 删除订单
const handleDeleteOrder = () => {
Taro.showModal({
title: '删除订单',
content: '确定要删除该订单吗?删除后不可恢复',
confirmColor: '#ee0a24',
success: async (res) => {
if (res.confirm && order) {
try {
await removeShopOrder(order.orderId)
Taro.showToast({ title: '订单已删除', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1000)
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
if (!order) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)
}
const displayStatus = getOrderDisplayStatus(order)
const phase = getOrderPhase(order)
const isExpress = order.deliveryType === 0 || order.deliveryType === undefined
const goodsCount = order.orderGoods?.reduce((sum, g) => sum + (g.totalNum || 1), 0) || 0
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 状态卡片 */}
<View
className='p-4'
style={{ backgroundColor: displayStatus.bgColor }}
>
<Text className='text-white text-lg font-medium block'>{displayStatus.title}</Text>
<Text className='text-white text-sm opacity-90 mt-1 block'>{displayStatus.subtitle}</Text>
{phase === 'shipped' && (
<View
className='mt-2 px-3 py-2 rounded-lg inline-flex items-center'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/logistics?orderId=${order.orderId}` })}
>
<Text className='text-white text-xs'></Text>
<Text className='text-white text-xs ml-1'></Text>
</View>
)}
</View>
{/* 收货信息 */}
{isExpress ? (
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<View className='flex items-start gap-2'>
<Text className='text-base'>📦</Text>
<View className='flex-1'>
<View className='flex gap-2 mb-1'>
<Text className='text-sm font-medium text-gray-800'>{order.realName || '未知'}</Text>
<Text className='text-sm text-gray-600'>{order.mobile || order.phone || '-'}</Text>
</View>
<Text className='text-xs text-gray-500'>{order.address || '未填写收货地址'}</Text>
{(order.sendStartTime || order.sendEndTime) && (
<Text className='text-xs text-gray-400 mt-1'>
: {order.sendStartTime?.slice(0, 10)} ~ {order.sendEndTime?.slice(0, 10)}
</Text>
)}
</View>
</View>
</View>
) : (
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<View className='flex items-start gap-2'>
<Text className='text-base'>🏪</Text>
<View className='flex-1'>
<Text className='text-sm font-medium text-gray-800'>{order.selfTakeMerchantName || '自提点'}</Text>
<Text className='text-xs text-gray-500 mt-1'>{order.address || '-'}</Text>
{order.selfTakeCode && (
<View className='mt-2 px-3 py-2 rounded-lg inline-flex items-center' style={{ backgroundColor: '#fff7e6' }}>
<Text className='text-xs text-orange-600'>: {order.selfTakeCode}</Text>
</View>
)}
</View>
</View>
</View>
)}
{/* 商品列表 */}
<View className='bg-white mx-3 mt-3 rounded-lg overflow-hidden'>
<View className='p-3 border-b border-gray-100'>
<Text className='text-sm font-medium text-gray-800'> ({goodsCount})</Text>
</View>
{order.orderGoods?.map((item, idx) => (
<View
key={idx}
className='flex gap-3 p-3 border-b border-gray-50'
style={idx === (order.orderGoods?.length ?? 0) - 1 ? { borderBottom: 'none' } : undefined}
onClick={() => item.goodsId && Taro.navigateTo({ url: `/pages/shop/product-detail?id=${item.goodsId}` })}
>
<View className='w-16 h-16 rounded-md bg-gray-100 flex-shrink-0 overflow-hidden'>
{item.image ? (
<Image className='w-full h-full' src={item.image} mode='aspectFill' />
) : (
<View className='w-full h-full flex items-center justify-center'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
<View className='flex-1 flex flex-col justify-between h-16'>
<Text className='text-sm text-gray-800 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 flex-col items-end justify-between h-16'>
<Price price={item.price || '0'} size='small' />
<Text className='text-xs text-gray-500'>x{item.totalNum || 1}</Text>
</View>
</View>
))}
</View>
{/* 金额明细 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='space-y-2'>
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-700'>¥{order.totalPrice || '0.00'}</Text>
</View>
{order.reducePrice && Number(order.reducePrice) > 0 && (
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-green-600'>-¥{order.reducePrice}</Text>
</View>
)}
<View className='flex justify-between pt-2 border-t border-gray-100'>
<Text className='text-sm font-medium text-gray-800'></Text>
<Price price={order.payPrice || '0'} size='normal' />
</View>
<View className='flex justify-between pt-2 border-t border-gray-100'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-500'>{PAY_TYPE_MAP[order.payType ?? 0] || '未知'}</Text>
</View>
</View>
</View>
{/* 订单信息 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg mb-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='space-y-2'>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<View className='flex items-center gap-1'>
<Text className='text-xs text-gray-600'>{order.orderNo}</Text>
<Text
className='text-xs text-blue-500'
onClick={() => {
Taro.setClipboardData({ data: order.orderNo || '' })
Taro.showToast({ title: '已复制', icon: 'none' })
}}
>
</Text>
</View>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{DELIVERY_STATUS_MAP[order.deliveryStatus ?? 10]}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.createTime)}</Text>
</View>
{order.payTime && (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.payTime)}</Text>
</View>
)}
{order.deliveryTime && (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.deliveryTime)}</Text>
</View>
)}
{order.expirationTime && (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.expirationTime)}</Text>
</View>
)}
{order.comments && (
<View className='flex justify-start'>
<Text className='text-xs text-gray-400 mr-2'></Text>
<Text className='text-xs text-gray-600 flex-1'>{order.comments}</Text>
</View>
)}
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{INVOICE_STATUS_MAP[order.isInvoice ?? 0]}</Text>
</View>
</View>
</View>
</ScrollView>
{/* 底部操作按钮(根据订单阶段动态显示) */}
<View
className='bg-white border-t border-gray-100 p-3 flex flex-row gap-2'
style={{ paddingBottom: '20px' }}
>
{/* 待付款: 取消 + 支付 */}
{phase === 'unpaid' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={handleCancelOrder}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#ee0a24' }}
loading={loading}
onClick={handlePay}
>
</Button>
</>
)}
{/* 待发货: 退款 + 联系客服 */}
{phase === 'unshipped' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/refund?orderId=${order.orderId}` })}
>
退
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#4b9cf5' }}
onClick={() => Taro.makePhoneCall({ phoneNumber: '400-000-0000' })}
>
</Button>
</>
)}
{/* 待收货: 物流 + 确认收货 */}
{phase === 'shipped' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#4b9cf5', color: '#4b9cf5' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/logistics?orderId=${order.orderId}` })}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#0e932e' }}
onClick={handleConfirmReceive}
>
</Button>
</>
)}
{/* 已完成: 售后 + 删除 */}
{phase === 'completed' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/after-sale-apply?orderId=${order.orderId}&type=refund` })}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ee0a24', color: '#ee0a24' }}
onClick={handleDeleteOrder}
>
</Button>
</>
)}
{/* 已取消/已退款: 删除 */}
{phase === 'cancelled' && (
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ee0a24', color: '#ee0a24' }}
onClick={handleDeleteOrder}
>
</Button>
)}
</View>
</View>
)
}
export default OrderDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '评价详情',
}

View File

@@ -0,0 +1,186 @@
import React, { useState } from 'react'
import { View, Text, ScrollView, Swiper, SwiperItem } from '@tarojs/components'
import Taro from '@tarojs/taro'
definePageConfig({
navigationBarTitleText: '评价详情',
})
const EvaluateDetailPage: React.FC = () => {
const [evaluation, setEvaluation] = useState({
id: 1,
goodsId: 1,
goodsName: '高品质保温杯',
goodsImage: '',
score: 5,
content: '质量很好,保温效果非常棒!物流也很快,包装严实,非常满意的一次购物体验。\n\n杯子的外观设计很时尚颜色也很正同事们都问我在哪里买的。保温效果真的很好早上装的开水到下午还是温热的。\n\n唯一的小缺点就是盖子有点紧不过用多了应该会好一些。总体来说非常满意推荐大家购买',
images: ['', '', ''],
isAnonymous: false,
userName: '张***',
createTime: '2026-05-10 14:30:00',
likes: 12,
isLiked: false,
replies: [
{
id: 1,
content: '感谢您的好评!我们会继续努力提供优质产品和服务。',
createTime: '2026-05-10 15:00:00',
isOfficial: true,
},
],
})
// 渲染星星
const renderStars = (score: number) => {
const stars = []
for (let i = 1; i <= 5; i++) {
stars.push(
<Text key={i} className={i <= score ? 'text-orange-400' : 'text-gray-300'}>
</Text>
)
}
return stars
}
// 处理点赞
const handleLike = () => {
setEvaluation(prev => ({
...prev,
isLiked: !prev.isLiked,
likes: prev.isLiked ? prev.likes - 1 : prev.likes + 1,
}))
Taro.showToast({ title: evaluation.isLiked ? '已取消点赞' : '点赞成功', icon: 'success' })
}
// 图片预览
const handleImagePreview = (index: number) => {
// 这里应该调用 Taro.previewImage
Taro.showToast({ title: `查看第${index + 1}张图片`, icon: 'none' })
}
return (
<View className='bg-gray-50 flex flex-col' style={{ minHeight: '100vh' }}>
<ScrollView scrollY className='flex-1'>
{/* 用户信息 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-3'>
<View className='w-10 h-10 rounded-full flex items-center justify-center' style={{ background: 'linear-gradient(to right, #60a5fa, #c084fc)' }}>
<Text className='text-white font-bold'>{evaluation.isAnonymous ? '匿' : evaluation.userName[0]}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700 font-medium block'>{evaluation.userName}</Text>
<View className='flex items-center gap-1'>
{renderStars(evaluation.score)}
</View>
</View>
<Text className='text-xs text-gray-400'>{evaluation.createTime.split(' ')[0]}</Text>
</View>
</View>
{/* 商品信息 */}
<View
className='bg-white p-4 mb-3'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${evaluation.goodsId}` })}
>
<View className='flex items-center gap-2'>
<View className='w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0'>
<Text className='text-2xl'>🛍</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700 block'>{evaluation.goodsName}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
<Text className='text-gray-400'></Text>
</View>
</View>
{/* 评价内容 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{evaluation.content}
</Text>
{/* 评价图片 */}
{evaluation.images && evaluation.images.length > 0 && (
<View className='mt-3'>
<Swiper
className='h-48 rounded-lg'
indicatorColor='#999'
indicatorActiveColor='#333'
circular
autoplay={false}
>
{evaluation.images.map((_, index) => (
<SwiperItem key={index}>
<View className='w-full h-48 bg-gray-100 flex items-center justify-center'>
<Text className='text-4xl'>🖼</Text>
<Text className='text-xs text-gray-400 mt-2'> {index + 1}</Text>
</View>
</SwiperItem>
))}
</Swiper>
<View className='flex justify-center gap-1 mt-2'>
{evaluation.images.map((_, index) => (
<View key={index} className='w-1 h-1 rounded-full bg-gray-300' />
))}
</View>
</View>
)}
</View>
{/* 商家回复 */}
{evaluation.replies && evaluation.replies.length > 0 && (
<View className='bg-white p-4 mb-3'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
{evaluation.replies.map(reply => (
<View key={reply.id} className='bg-orange-50 rounded-lg p-3'>
<View className='flex items-center gap-2 mb-2'>
{reply.isOfficial && (
<View className='bg-orange-500 px-2 py-1 rounded'>
<Text className='text-xs text-white'></Text>
</View>
)}
<Text className='text-xs text-gray-400'>{reply.createTime}</Text>
</View>
<Text className='text-sm text-gray-700 leading-6'>{reply.content}</Text>
</View>
))}
</View>
)}
</ScrollView>
{/* 底部操作栏 */}
<View className='bg-white border-t border-gray-100 px-4 py-2 flex items-center justify-around' style={{ paddingBottom: '20px' }}>
<View className='flex flex-col items-center' onClick={handleLike}>
<Text className={`text-2xl ${evaluation.isLiked ? 'text-red-500' : 'text-gray-400'}`}>
{evaluation.isLiked ? '❤️' : '🤍'}
</Text>
<Text className={`text-xs ${evaluation.isLiked ? 'text-red-500' : 'text-gray-400'}`}>
{evaluation.likes}
</Text>
</View>
<View className='flex flex-col items-center' onClick={() => Taro.showToast({ title: '收藏成功', icon: 'success' })}>
<Text className='text-2xl'></Text>
<Text className='text-xs text-gray-400'></Text>
</View>
<View className='flex flex-col items-center' onClick={() => Taro.showToast({ title: '举报已提交', icon: 'success' })}>
<Text className='text-2xl'></Text>
<Text className='text-xs text-gray-400'></Text>
</View>
<View
className='bg-orange-500 text-white px-6 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${evaluation.goodsId}` })}
>
<Text className='text-sm text-white'></Text>
</View>
</View>
</View>
)
}
export default EvaluateDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '评价详情',
}

View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getShopEvaluation, likeShopEvaluation } from '@/api/shop/shopEvaluation'
import type { ShopEvaluation } from '@/api/shop/shopEvaluation/model'
definePageConfig({
navigationBarTitleText: '评价详情',
})
const EvaluateDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [evaluation, setEvaluation] = useState<ShopEvaluation | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchDetail(Number(id))
}
}, [id])
const fetchDetail = async (evalId: number) => {
try {
const data = await getShopEvaluation(evalId)
setEvaluation(data)
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 渲染星星
const renderStars = (score: number) => {
const stars = []
for (let i = 1; i <= 5; i++) {
stars.push(
<Text key={i} className={i <= score ? 'text-orange-400' : 'text-gray-300'}></Text>
)
}
return stars
}
// 处理点赞
const handleLike = async () => {
if (!evaluation) return
try {
await likeShopEvaluation({ evaluationId: evaluation.id, isLiked: !evaluation.isLiked })
setEvaluation(prev => prev ? {
...prev,
isLiked: !prev.isLiked,
likes: prev.isLiked ? prev.likes - 1 : prev.likes + 1,
} : prev)
} catch (err: any) {
Taro.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
// 图片预览
const handleImagePreview = (index: number) => {
if (!evaluation?.images?.length) return
Taro.previewImage({
current: evaluation.images[index],
urls: evaluation.images,
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
if (!evaluation) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)
}
return (
<View className='bg-gray-50 flex flex-col' style={{ minHeight: '100vh' }}>
<ScrollView scrollY className='flex-1'>
{/* 用户信息 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-3'>
<View className='w-10 h-10 rounded-full flex items-center justify-center overflow-hidden' style={{ background: 'linear-gradient(to right, #60a5fa, #c084fc)' }}>
{evaluation.userAvatar ? (
<Image src={evaluation.userAvatar} className='w-10 h-10' mode='aspectFill' />
) : (
<Text className='text-white font-bold'>{evaluation.isAnonymous ? '匿' : evaluation.userName[0]}</Text>
)}
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700 font-medium block'>{evaluation.userName}</Text>
<View className='flex items-center gap-1'>{renderStars(evaluation.score)}</View>
</View>
<Text className='text-xs text-gray-400'>{evaluation.createTime.split(' ')[0]}</Text>
</View>
</View>
{/* 商品信息 */}
<View
className='bg-white p-4 mb-3'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${evaluation.goodsId}` })}
>
<View className='flex items-center gap-2'>
<View className='w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{evaluation.goodsImage ? (
<Image src={evaluation.goodsImage} className='w-12 h-12' mode='aspectFill' />
) : (
<Text className='text-2xl'>🛍</Text>
)}
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700 block'>{evaluation.goodsName}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
<Text className='text-gray-400'></Text>
</View>
</View>
{/* 评价内容 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{evaluation.content}
</Text>
{/* 评价图片 */}
{evaluation.images && evaluation.images.length > 0 && (
<View className='flex flex-wrap gap-2 mt-3'>
{evaluation.images.map((img, index) => (
<View
key={index}
className='w-24 h-24 bg-gray-100 rounded-lg overflow-hidden'
onClick={() => handleImagePreview(index)}
>
<Image src={img} className='w-24 h-24' mode='aspectFill' />
</View>
))}
</View>
)}
</View>
{/* 商家回复 */}
{evaluation.reply && (
<View className='bg-white p-4 mb-3'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='bg-orange-50 rounded-lg p-3'>
<View className='flex items-center gap-2 mb-2'>
<View className='bg-orange-500 px-2 py-1 rounded'>
<Text className='text-xs text-white'></Text>
</View>
{evaluation.replyTime && (
<Text className='text-xs text-gray-400'>{evaluation.replyTime}</Text>
)}
</View>
<Text className='text-sm text-gray-700 leading-6'>{evaluation.reply}</Text>
</View>
</View>
)}
</ScrollView>
{/* 底部操作栏 */}
<View className='bg-white border-t border-gray-100 px-4 py-2 flex items-center justify-around' style={{ paddingBottom: '20px' }}>
<View className='flex flex-col items-center' onClick={handleLike}>
<Text className={`text-2xl ${evaluation.isLiked ? 'text-red-500' : 'text-gray-400'}`}>
{evaluation.isLiked ? '❤️' : '🤍'}
</Text>
<Text className={`text-xs ${evaluation.isLiked ? 'text-red-500' : 'text-gray-400'}`}>
{evaluation.likes}
</Text>
</View>
<View
className='bg-orange-500 text-white px-6 py-2 rounded-full'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${evaluation.goodsId}` })}
>
<Text className='text-sm text-white'></Text>
</View>
</View>
</View>
)
}
export default EvaluateDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '商品评价',
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopEvaluation, likeShopEvaluation } from '@/api/shop/shopEvaluation'
import type { ShopEvaluation } from '@/api/shop/shopEvaluation'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '商品评价',
})
const EvaluateListPage: React.FC = () => {
const [evaluations, setEvaluations] = useState<ShopEvaluation[]>([])
const [sortBy, setSortBy] = useState('newest') // newest, mostLiked
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setEvaluations([])
setPage(1)
setFinished(false)
loadList(1)
}, [sortBy])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await listShopEvaluation({
page: p,
limit: 10,
sortBy: sortBy as 'newest' | 'mostLiked',
})
if (res?.list) {
if (p === 1) {
setEvaluations(res.list)
} else {
setEvaluations(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 handleLike = async (id: number) => {
const evaluation = evaluations.find(ev => ev.id === id)
if (!evaluation) return
try {
await likeShopEvaluation({
evaluationId: id,
isLiked: !evaluation.isLiked,
})
setEvaluations(prev =>
prev.map(ev =>
ev.id === id
? {
...ev,
isLiked: !ev.isLiked,
likes: ev.isLiked ? ev.likes - 1 : ev.likes + 1,
}
: ev
)
)
Taro.showToast({ title: '操作成功', icon: 'success' })
} catch (err) {
console.error('点赞操作失败', err)
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
// 查看评价详情
const handleDetail = (id: number) => {
Taro.navigateTo({ url: `/pages/order/evaluate-detail?id=${id}` })
}
// 渲染星星
const renderStars = (score: number) => {
const stars = []
for (let i = 1; i <= 5; i++) {
stars.push(
<Text key={i} className={i <= score ? 'text-orange-400' : 'text-gray-300'}>
</Text>
)
}
return stars
}
// 排序
const sortedEvaluations = [...evaluations].sort((a, b) => {
if (sortBy === 'newest') {
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
} else if (sortBy === 'mostLiked') {
return b.likes - a.likes
}
return 0
})
// 加载更多
const handleLoadMore = () => {
if (!finished && !loading) {
loadList(page + 1)
}
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 排序栏 */}
<View className='bg-white flex justify-between items-center px-3 py-2'>
<Text className='text-sm text-gray-500'>
{evaluations.length}
</Text>
<View className='flex gap-3'>
<View
className={`px-3 py-1 rounded-full text-xs ${
sortBy === 'newest' ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setSortBy('newest')}
>
<Text className={sortBy === 'newest' ? 'text-white' : 'text-gray-600'}></Text>
</View>
<View
className={`px-3 py-1 rounded-full text-xs ${
sortBy === 'mostLiked' ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setSortBy('mostLiked')}
>
<Text className={sortBy === 'mostLiked' ? 'text-white' : 'text-gray-600'}></Text>
</View>
</View>
</View>
{/* 评价列表 */}
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{evaluations.length === 0 && !loading ? (
<EmptyState text='暂无评价' />
) : (
sortedEvaluations.map(ev => (
<View key={ev.id} className='bg-white rounded-lg p-4 mb-3 shadow-sm'>
{/* 用户信息 */}
<View className='flex items-center gap-2 mb-2'>
<View className='w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center'>
<Text className='text-sm text-gray-500'>{ev.isAnonymous ? '匿' : ev.userName[0]}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700'>{ev.userName}</Text>
<View className='flex items-center gap-1'>
{renderStars(ev.score)}
</View>
</View>
<Text className='text-xs text-gray-400'>
{ev.createTime.split(' ')[0]}
</Text>
</View>
{/* 商品信息 */}
<View
className='flex items-center gap-2 bg-gray-50 rounded-lg p-2 mb-2'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${ev.goodsId}` })}
>
<View className='w-10 h-10 bg-gray-200 rounded flex items-center justify-center flex-shrink-0'>
<Text className='text-lg'>🛍</Text>
</View>
<Text className='text-xs text-gray-600 flex-1'>{ev.goodsName}</Text>
<Text className='text-gray-400 text-xs'></Text>
</View>
{/* 评价内容 */}
<Text className='text-sm text-gray-700 mb-2 block leading-6'>
{ev.content}
</Text>
{/* 评价图片 */}
{ev.images && ev.images.length > 0 && (
<View className='flex gap-2 mb-2'>
{ev.images.map((img, idx) => (
<View key={idx} className='w-16 h-16 bg-gray-100 rounded'>
<Text className='text-xs text-gray-400'>{idx + 1}</Text>
</View>
))}
</View>
)}
{/* 操作栏 */}
<View className='flex items-center justify-between pt-2 border-t border-gray-50'>
<View
className={`flex items-center gap-1 ${
ev.isLiked ? 'opacity-100' : 'opacity-50'
}`}
onClick={() => handleLike(ev.id)}
>
<Text className={ev.isLiked ? 'text-red-500' : 'text-gray-400'}>
{ev.isLiked ? '❤️' : '🤍'}
</Text>
<Text className={`text-xs ${ev.isLiked ? 'text-red-500' : 'text-gray-400'}`}>
{ev.likes}
</Text>
</View>
<View
className='flex items-center gap-1'
onClick={() => handleDetail(ev.id)}
>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-gray-400 text-xs'></Text>
</View>
</View>
</View>
))
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default EvaluateListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '商品评价',
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopEvaluation, likeShopEvaluation } from '@/api/shop/shopEvaluation'
import type { ShopEvaluation } from '@/api/shop/shopEvaluation'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '商品评价',
})
const EvaluateListPage: React.FC = () => {
const [evaluations, setEvaluations] = useState<ShopEvaluation[]>([])
const [sortBy, setSortBy] = useState('newest') // newest, mostLiked
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setEvaluations([])
setPage(1)
setFinished(false)
loadList(1)
}, [sortBy])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await listShopEvaluation({
page: p,
limit: 10,
sortBy: sortBy as 'newest' | 'mostLiked',
})
if (res?.list) {
if (p === 1) {
setEvaluations(res.list)
} else {
setEvaluations(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 handleLike = async (id: number) => {
const evaluation = evaluations.find(ev => ev.id === id)
if (!evaluation) return
try {
await likeShopEvaluation({
evaluationId: id,
isLiked: !evaluation.isLiked,
})
setEvaluations(prev =>
prev.map(ev =>
ev.id === id
? {
...ev,
isLiked: !ev.isLiked,
likes: ev.isLiked ? ev.likes - 1 : ev.likes + 1,
}
: ev
)
)
Taro.showToast({ title: '操作成功', icon: 'success' })
} catch (err) {
console.error('点赞操作失败', err)
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
// 查看评价详情
const handleDetail = (id: number) => {
Taro.navigateTo({ url: `/pages/order/evaluate-detail/index?id=${id}` })
}
// 渲染星星
const renderStars = (score: number) => {
const stars = []
for (let i = 1; i <= 5; i++) {
stars.push(
<Text key={i} className={i <= score ? 'text-orange-400' : 'text-gray-300'}>
</Text>
)
}
return stars
}
// 排序
const sortedEvaluations = [...evaluations].sort((a, b) => {
if (sortBy === 'newest') {
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
} else if (sortBy === 'mostLiked') {
return b.likes - a.likes
}
return 0
})
// 加载更多
const handleLoadMore = () => {
if (!finished && !loading) {
loadList(page + 1)
}
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 排序栏 */}
<View className='bg-white flex justify-between items-center px-3 py-2'>
<Text className='text-sm text-gray-500'>
{evaluations.length}
</Text>
<View className='flex gap-3'>
<View
className={`px-3 py-1 rounded-full text-xs ${
sortBy === 'newest' ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setSortBy('newest')}
>
<Text className={sortBy === 'newest' ? 'text-white' : 'text-gray-600'}></Text>
</View>
<View
className={`px-3 py-1 rounded-full text-xs ${
sortBy === 'mostLiked' ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600'
}`}
onClick={() => setSortBy('mostLiked')}
>
<Text className={sortBy === 'mostLiked' ? 'text-white' : 'text-gray-600'}></Text>
</View>
</View>
</View>
{/* 评价列表 */}
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{evaluations.length === 0 && !loading ? (
<EmptyState text='暂无评价' />
) : (
sortedEvaluations.map(ev => (
<View key={ev.id} className='bg-white rounded-lg p-4 mb-3 shadow-sm'>
{/* 用户信息 */}
<View className='flex items-center gap-2 mb-2'>
<View className='w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center'>
<Text className='text-sm text-gray-500'>{ev.isAnonymous ? '匿' : ev.userName[0]}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-700'>{ev.userName}</Text>
<View className='flex items-center gap-1'>
{renderStars(ev.score)}
</View>
</View>
<Text className='text-xs text-gray-400'>
{ev.createTime.split(' ')[0]}
</Text>
</View>
{/* 商品信息 */}
<View
className='flex items-center gap-2 bg-gray-50 rounded-lg p-2 mb-2'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${ev.goodsId}` })}
>
<View className='w-10 h-10 bg-gray-200 rounded flex items-center justify-center flex-shrink-0'>
<Text className='text-lg'>🛍</Text>
</View>
<Text className='text-xs text-gray-600 flex-1'>{ev.goodsName}</Text>
<Text className='text-gray-400 text-xs'></Text>
</View>
{/* 评价内容 */}
<Text className='text-sm text-gray-700 mb-2 block leading-6'>
{ev.content}
</Text>
{/* 评价图片 */}
{ev.images && ev.images.length > 0 && (
<View className='flex gap-2 mb-2'>
{ev.images.map((img, idx) => (
<View key={idx} className='w-16 h-16 bg-gray-100 rounded'>
<Text className='text-xs text-gray-400'>{idx + 1}</Text>
</View>
))}
</View>
)}
{/* 操作栏 */}
<View className='flex items-center justify-between pt-2 border-t border-gray-50'>
<View
className={`flex items-center gap-1 ${
ev.isLiked ? 'opacity-100' : 'opacity-50'
}`}
onClick={() => handleLike(ev.id)}
>
<Text className={ev.isLiked ? 'text-red-500' : 'text-gray-400'}>
{ev.isLiked ? '❤️' : '🤍'}
</Text>
<Text className={`text-xs ${ev.isLiked ? 'text-red-500' : 'text-gray-400'}`}>
{ev.likes}
</Text>
</View>
<View
className='flex items-center gap-1'
onClick={() => handleDetail(ev.id)}
>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-gray-400 text-xs'></Text>
</View>
</View>
</View>
))
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default EvaluateListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '商品评价',
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { useUser } from '@/hooks/useUser'
import { submitGoodsComment } from '@/api/shop/shopGoodsComment'
definePageConfig({
navigationBarTitleText: '商品评价',
})
const EvaluatePage: React.FC = () => {
const { isLoggedIn } = useUser()
const { orderId, orderGoodsId, goodsId } = Taro.getCurrentInstance().router?.params || {}
const [rating, setRating] = useState(5)
const [content, setContent] = useState('')
const [images, setImages] = useState<string[]>([])
const [isAnonymous, setIsAnonymous] = useState(false)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
}
}, [isLoggedIn])
const handleSubmit = async () => {
if (!content.trim()) {
Taro.showToast({ title: '请输入评价内容', icon: 'none' })
return
}
if (!orderId || !orderGoodsId || !goodsId) {
Taro.showToast({ title: '参数错误', icon: 'none' })
return
}
Taro.showModal({
title: '提交评价',
content: '确定要提交评价吗?',
success: async (res) => {
if (res.confirm) {
try {
const submitRes = await submitGoodsComment({
oid: Number(orderId),
goodsId: Number(goodsId),
goodsScore: rating,
serviceScore: rating,
comment: content,
pics: images.join(','),
})
if (submitRes.code === 0) {
Taro.showToast({ title: '评价成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} else {
Taro.showToast({ title: submitRes.message || '提交失败', icon: 'none' })
}
} catch (err: any) {
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
}
}
},
})
}
const handleChooseImage = () => {
Taro.chooseImage({
count: 9 - images.length,
success: (res) => {
setImages([...images, ...res.tempFilePaths])
},
})
}
const handleRemoveImage = (index: number) => {
const newImages = [...images]
newImages.splice(index, 1)
setImages(newImages)
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50 pb-20 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 评分 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex gap-2'>
{[1, 2, 3, 4, 5].map((star) => (
<View
key={star}
onClick={() => setRating(star)}
>
<Text className='text-2xl'>
{star <= rating ? '⭐' : '☆'}
</Text>
</View>
))}
</View>
</View>
{/* 评价内容 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='bg-gray-50 rounded-lg p-3'>
<Input
value={content}
onInput={(e) => setContent(e.detail.value)}
placeholder='请输入评价内容'
className='w-full text-sm'
style={{ minHeight: '120px' }}
/>
</View>
</View>
{/* 上传图片 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='flex flex-wrap gap-2'>
{images.map((img, idx) => (
<View key={idx} className='relative'>
<Image
src={img}
className='w-20 h-20 rounded-lg'
mode='aspectFill'
/>
<View
className='absolute top-0 right-0 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => handleRemoveImage(idx)}
>
<Text className='text-white text-xs'>×</Text>
</View>
</View>
))}
{images.length < 9 && (
<View
className='w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center'
onClick={handleChooseImage}
>
<Text className='text-2xl text-gray-400'>+</Text>
</View>
)}
</View>
</View>
{/* 匿名评价 */}
<View
className='bg-white mx-3 mt-3 p-4 rounded-lg flex items-center justify-between'
onClick={() => setIsAnonymous(!isAnonymous)}
>
<Text className='text-sm text-gray-700'></Text>
<View
className={`w-12 h-6 rounded-full relative ${
isAnonymous ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<View
className={`absolute top-1 w-4 h-4 bg-white rounded-full ${
isAnonymous ? 'right-1' : 'left-1'
}`}
/>
</View>
</View>
</ScrollView>
{/* 提交按钮 */}
<View className='bg-white border-t border-gray-100 p-3' style={{ paddingBottom: '20px' }}>
<Button
type='primary'
className='w-full rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={handleSubmit}
>
</Button>
</View>
</View>
)
}
export default EvaluatePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '商品评价',
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { useUser } from '@/hooks/useUser'
import { submitGoodsComment } from '@/api/shop/shopGoodsComment'
definePageConfig({
navigationBarTitleText: '商品评价',
})
const EvaluatePage: React.FC = () => {
const { isLoggedIn } = useUser()
const { orderId, orderGoodsId, goodsId } = Taro.getCurrentInstance().router?.params || {}
const [rating, setRating] = useState(5)
const [content, setContent] = useState('')
const [images, setImages] = useState<string[]>([])
const [isAnonymous, setIsAnonymous] = useState(false)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
}
}, [isLoggedIn])
const handleSubmit = async () => {
if (!content.trim()) {
Taro.showToast({ title: '请输入评价内容', icon: 'none' })
return
}
if (!orderId || !orderGoodsId || !goodsId) {
Taro.showToast({ title: '参数错误', icon: 'none' })
return
}
Taro.showModal({
title: '提交评价',
content: '确定要提交评价吗?',
success: async (res) => {
if (res.confirm) {
try {
const submitRes = await submitGoodsComment({
oid: Number(orderId),
goodsId: Number(goodsId),
goodsScore: rating,
serviceScore: rating,
comment: content,
pics: images.join(','),
})
if (submitRes.code === 0) {
Taro.showToast({ title: '评价成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} else {
Taro.showToast({ title: submitRes.message || '提交失败', icon: 'none' })
}
} catch (err: any) {
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
}
}
},
})
}
const handleChooseImage = () => {
Taro.chooseImage({
count: 9 - images.length,
success: (res) => {
setImages([...images, ...res.tempFilePaths])
},
})
}
const handleRemoveImage = (index: number) => {
const newImages = [...images]
newImages.splice(index, 1)
setImages(newImages)
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50 pb-20 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 评分 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex gap-2'>
{[1, 2, 3, 4, 5].map((star) => (
<View
key={star}
onClick={() => setRating(star)}
>
<Text className='text-2xl'>
{star <= rating ? '⭐' : '☆'}
</Text>
</View>
))}
</View>
</View>
{/* 评价内容 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='bg-gray-50 rounded-lg p-3'>
<Input
value={content}
onInput={(e) => setContent(e.detail.value)}
placeholder='请输入评价内容'
className='w-full text-sm'
style={{ minHeight: '120px' }}
/>
</View>
</View>
{/* 上传图片 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='flex flex-wrap gap-2'>
{images.map((img, idx) => (
<View key={idx} className='relative'>
<Image
src={img}
className='w-20 h-20 rounded-lg'
mode='aspectFill'
/>
<View
className='absolute top-0 right-0 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => handleRemoveImage(idx)}
>
<Text className='text-white text-xs'>×</Text>
</View>
</View>
))}
{images.length < 9 && (
<View
className='w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center'
onClick={handleChooseImage}
>
<Text className='text-2xl text-gray-400'>+</Text>
</View>
)}
</View>
</View>
{/* 匿名评价 */}
<View
className='bg-white mx-3 mt-3 p-4 rounded-lg flex items-center justify-between'
onClick={() => setIsAnonymous(!isAnonymous)}
>
<Text className='text-sm text-gray-700'></Text>
<View
className={`w-12 h-6 rounded-full relative ${
isAnonymous ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<View
className={`absolute top-1 w-4 h-4 bg-white rounded-full ${
isAnonymous ? 'right-1' : 'left-1'
}`}
/>
</View>
</View>
</ScrollView>
{/* 提交按钮 */}
<View className='bg-white border-t border-gray-100 p-3' style={{ paddingBottom: '20px' }}>
<Button
type='primary'
className='w-full rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={handleSubmit}
>
</Button>
</View>
</View>
)
}
export default EvaluatePage

View File

@@ -0,0 +1,187 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder, ShopOrderParam } from '@/api/shop/shopOrder/model'
import { OrderListStatus, OrderListTabText } from '@/types/order'
import OrderCard from '@/components/common/OrderCard'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '我的订单',
})
// Tab 配置(使用后端 statusFilter 字段)
const tabList = [
{ title: OrderListTabText[OrderListStatus.All], status: OrderListStatus.All },
{ title: OrderListTabText[OrderListStatus.Unpaid], status: OrderListStatus.Unpaid },
{ title: OrderListTabText[OrderListStatus.Unshipped], status: OrderListStatus.Unshipped },
{ title: OrderListTabText[OrderListStatus.Shipped], status: OrderListStatus.Shipped },
{ title: OrderListTabText[OrderListStatus.Completed], status: OrderListStatus.Completed },
]
interface TabState {
orders: ShopOrder[]
page: number
finished: boolean
loading: boolean
initialized: boolean
}
const OrderListPage: React.FC = () => {
const { tab: initTab } = Taro.getCurrentInstance().router?.params || {}
const [tabIndex, setTabIndex] = useState(Number(initTab) || 0)
// 每个 tab 独立维护状态
const [tabStates, setTabStates] = useState<TabState[]>(
tabList.map(() => ({
orders: [],
page: 1,
finished: false,
loading: false,
initialized: false,
}))
)
const currentState = tabStates[tabIndex]
// 用 ref 避免 loadOrders 闭包中捕获过期状态
const tabStatesRef = useRef(tabStates)
tabStatesRef.current = tabStates
const updateTabState = useCallback(
(index: number, partial: Partial<TabState>) => {
setTabStates(prev => {
const next = [...prev]
next[index] = { ...next[index], ...partial }
return next
})
},
[]
)
const loadOrders = useCallback(
async (targetIndex: number, p: number) => {
const state = tabStatesRef.current[targetIndex]
if (state.loading) return
updateTabState(targetIndex, { loading: true })
try {
const params: ShopOrderParam = { page: p, limit: 10 }
const status = tabList[targetIndex]?.status
// 使用后端 statusFilter 字段进行筛选
// -1=全部, 0=待支付, 1=待发货, 3=待收货, 5=已完成
if (status !== undefined && status !== OrderListStatus.All) {
params.statusFilter = status
}
const res = await pageShopOrder(params)
const list = res?.list || []
updateTabState(targetIndex, {
orders: p === 1 ? list : [...state.orders, ...list],
page: p,
finished: list.length < 10,
loading: false,
initialized: true,
})
} catch {
updateTabState(targetIndex, { loading: false, initialized: true })
}
},
[updateTabState]
)
// 切换 tab 时加载数据
useEffect(() => {
if (!tabStates[tabIndex].initialized) {
loadOrders(tabIndex, 1)
}
}, [tabIndex])
const handleLoadMore = () => {
if (!currentState.finished && !currentState.loading) {
loadOrders(tabIndex, currentState.page + 1)
}
}
const handleTabChange = (index: number) => {
if (index !== tabIndex) {
setTabIndex(index)
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
{/* 自定义 Tabs 标题栏 */}
<View className='bg-white flex flex-row items-center border-b border-gray-100'>
{tabList.map((tab, index) => (
<View
key={tab.title}
className='flex-1 flex flex-col items-center justify-center py-3'
onClick={() => handleTabChange(index)}
>
<Text
className='text-sm font-medium'
style={{
color: index === tabIndex ? '#ee0a24' : '#666',
}}
>
{tab.title}
</Text>
{index === tabIndex && (
<View
className='mt-1 rounded-full'
style={{
width: '20px',
height: '3px',
backgroundColor: '#ee0a24',
}}
/>
)}
</View>
))}
</View>
{/* 内容区域 - 每个 tab 独立渲染 */}
<View className='flex-1 relative'>
{tabList.map((tab, idx) => (
<View
key={tab.title}
className='absolute inset-0'
style={{
display: idx === tabIndex ? 'flex' : 'none',
flexDirection: 'column',
}}
>
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{tabStates[idx].orders.length > 0 ? (
tabStates[idx].orders.map(order => (
<OrderCard key={order.orderId} order={order} />
))
) : tabStates[idx].initialized && !tabStates[idx].loading ? (
<EmptyState text='暂无订单' />
) : null}
<LoadMore
loading={tabStates[idx].loading}
finished={tabStates[idx].finished}
/>
</View>
</ScrollView>
</View>
))}
</View>
</View>
)
}
export default OrderListPage

View File

@@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { queryLogistics, formatLogisticsStatus, EXPRESS_COMPANIES } from '@/api/shop/shopLogistics'
import type { LogisticsInfo, LogisticsTrack } from '@/api/shop/shopLogistics'
import Loading from '@/components/common/Loading'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '物流详情',
})
const LogisticsPage: React.FC = () => {
const { orderId, expressNo, expressCompany } = Taro.getCurrentInstance().router?.params || {}
const [loading, setLoading] = useState(true)
const [logisticsInfo, setLogisticsInfo] = useState<LogisticsInfo | null>(null)
const [trackList, setTrackList] = useState<LogisticsTrack[]>([])
useEffect(() => {
loadLogistics()
}, [])
const loadLogistics = async () => {
setLoading(true)
try {
// 必须传入参数
if (!expressNo || !expressCompany) {
Taro.showToast({ title: '缺少物流参数', icon: 'none' })
setLoading(false)
return
}
const res = await queryLogistics({
orderId,
expressNo,
expressCompany,
})
if (res?.data) {
setLogisticsInfo(res.data.logisticsInfo)
setTrackList(res.data.trackList)
}
} catch (err) {
console.error('加载物流信息失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 复制单号
const copyExpressNo = () => {
if (logisticsInfo?.expressNo) {
Taro.setClipboardData({
data: logisticsInfo.expressNo,
success: () => {
Taro.showToast({ title: '单号已复制', icon: 'success' })
},
})
}
}
// 联系快递员(模拟)
const contactRider = () => {
Taro.makePhoneCall({
phoneNumber: '400-811-1111',
fail: () => {
Taro.showToast({ title: '拨打失败', icon: 'none' })
},
})
}
// 格式化时间
const formatTime = (time: string) => {
if (!time) return ''
const date = new Date(time)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// 格式化日期
const formatDate = (time: string) => {
if (!time) return ''
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
if (loading) {
return <Loading fullscreen />
}
if (!logisticsInfo) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<EmptyState text='暂无物流信息' />
</View>
)
}
const statusInfo = formatLogisticsStatus(logisticsInfo.status)
return (
<View className='min-h-screen bg-gray-50 pb-4 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 快递信息卡片 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-center gap-3'>
{/* 快递logo */}
<View className='w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center'>
<Text className='text-xl'>📦</Text>
</View>
<View className='flex-1'>
<View className='flex items-center gap-2'>
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.expressCompanyName}</Text>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-sm text-gray-500'>{statusInfo.text}</Text>
</View>
<Text className='text-xs text-gray-400 mt-1'>
: {logisticsInfo.expressNo}
</Text>
</View>
<View
className='px-3 py-1 rounded-full bg-gray-50'
onClick={copyExpressNo}
>
<Text className='text-xs text-gray-500'></Text>
</View>
</View>
{/* 预计送达 */}
{logisticsInfo.estimatedTime && (
<View className='mt-3 pt-3 border-t border-gray-100'>
<Text className='text-xs text-gray-400'>
: {formatDate(logisticsInfo.estimatedTime)} 24:00
</Text>
</View>
)}
</View>
{/* 当前状态 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-start gap-3'>
<View className={`w-10 h-10 rounded-full flex items-center justify-center ${statusInfo.icon === '🚚' ? 'bg-green-50' : 'bg-gray-50'}`}>
<Text>{statusInfo.icon}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.status}</Text>
{logisticsInfo.currentLocation && (
<Text className='text-xs text-gray-400 mt-1'>
: {logisticsInfo.currentLocation}
</Text>
)}
<Text className='text-xs text-gray-400 mt-1'>
: {formatTime(logisticsInfo.updateTime)}
</Text>
</View>
</View>
</View>
{/* 收货人信息 */}
{logisticsInfo.receiverInfo && (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-600'>{logisticsInfo.receiverInfo.name}</Text>
<Text className='text-sm text-gray-500'>{logisticsInfo.receiverInfo.phone}</Text>
</View>
<Text className='text-xs text-gray-400 mt-1'>{logisticsInfo.receiverInfo.address}</Text>
</View>
)}
{/* 物流轨迹 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='relative'>
{/* 竖线 */}
<View className='absolute left-4 top-0 bottom-0 w-px bg-gray-200' />
{trackList.map((item, idx) => (
<View key={idx} className={`relative flex gap-3 pb-4 ${idx === trackList.length - 1 ? 'pb-0' : ''}`}>
{/* 圆点 */}
<View
className={`w-8 h-8 rounded-full flex items-center justify-center z-10 ${
item.isCompleted ? 'bg-green-500' : 'bg-white border-2 border-green-500'
}`}
>
{item.isCompleted && idx !== 0 ? (
<Text className='text-white text-xs'></Text>
) : !item.isCompleted ? (
<View className='w-2 h-2 rounded-full bg-green-500' />
) : null}
</View>
{/* 内容 */}
<View className='flex-1 pt-1'>
<View className='flex justify-between items-start'>
<Text className={`text-sm ${item.isCompleted ? 'text-gray-600' : 'text-gray-800 font-medium'}`}>
{item.status}
</Text>
<Text className='text-xs text-gray-400'>{formatTime(item.time)}</Text>
</View>
<Text className={`text-xs mt-1 ${item.isCompleted ? 'text-gray-400' : 'text-gray-500'}`}>
{item.description}
</Text>
<Text className='text-xs text-gray-400 mt-1'>{item.location}</Text>
</View>
</View>
))}
</View>
</View>
{/* 温馨提示 */}
<View className='bg-orange-50 mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm text-orange-600 font-medium'></Text>
<Text className='text-xs text-orange-500 mt-1 leading-5'>
1. {'\n'}
2. {'\n'}
3.
</Text>
</View>
</ScrollView>
{/* 底部操作 */}
<View className='bg-white border-t border-gray-100 p-3' style={{ paddingBottom: '20px' }}>
<View className='flex gap-3'>
<Button
size='small'
className='flex-1 rounded-full border-gray-300 text-gray-600'
onClick={contactRider}
>
</Button>
<Button
size='small'
className='flex-1 rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={() => Taro.showToast({ title: '功能开发中', icon: 'none' })}
>
</Button>
</View>
</View>
</View>
)
}
export default LogisticsPage

View File

@@ -0,0 +1,6 @@
export default {
navigationBarTitleText: '我的',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f8f8f8'
}

View File

@@ -0,0 +1,9 @@
.home-page {
min-height: 100vh;
padding: 32px;
box-sizing: border-box;
.nut-avatar {
margin: 0 auto;
}
}

View File

@@ -0,0 +1,65 @@
import { View, Text, Image } from '@tarojs/components'
import { Cell, CellGroup, Avatar, Tag } from '@nutui/nutui-react-taro'
import { useAppContext } from '@/hooks/useAppContext'
import './order.scss'
export default function Order() {
const { theme, toggleTheme } = useAppContext()
const techStack = [
{ name: 'Taro', version: '4.0.8', color: 'primary' },
{ name: 'React', version: '18.3.1', color: 'success' },
{ name: 'TypeScript', version: '5.7.2', color: 'warning' },
{ name: 'NutUI', version: '2.7.4', color: 'danger' },
{ name: 'TailwindCSS', version: '3.4.17', color: 'primary' }
]
return (
<View className='home-page p-4'>
<View className='flex-center mb-6'>
<Avatar
size='large'
src='https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/7362/5fc1f425E224AFA46/a13f0a3e12a比6b4.png'
/>
</View>
<CellGroup className='mb-4' title='项目信息'>
<Cell title='项目名称' description='Paopao Taro' />
<Cell title='当前主题' description={theme === 'light' ? '浅色模式 🌞' : '深色模式 🌙'} />
<Cell
title='切换主题'
extra={
<Tag type={theme === 'light' ? 'primary' : 'dark'}>
</Tag>
}
onClick={toggleTheme}
/>
</CellGroup>
<CellGroup className='mb-4' title='技术栈'>
{techStack.map((tech, index) => (
<Cell
key={index}
title={tech.name}
description={`v${tech.version}`}
extra={
<Tag type={tech.color as any}>{tech.name}</Tag>
}
/>
))}
</CellGroup>
<View className='mt-4 p-4 bg-gray-50 rounded-lg'>
<Text className='text-sm text-gray-500'>
📦
</Text>
<View className='mt-2 flex flex-wrap gap-2'>
<Tag type='primary' className='m-1'>Day.js</Tag>
<Tag type='success' className='m-1'>Crypto-js</Tag>
<Tag type='warning' className='m-1'>React Router</Tag>
</View>
</View>
</View>
)
}

Some files were not shown because too many files have changed in this diff Show More