fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top
- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api - 更新图片上传接口地址为新的 guilixu-api 域名 - 修改用户推广页面中邀请码链接和二维码接口的域名 - 更改注册页微信登录接口请求的域名为 guilixu-api
This commit is contained in:
3
src/pages/activity/detail/index.config.ts
Normal file
3
src/pages/activity/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '活动详情',
|
||||
}
|
||||
245
src/pages/activity/detail/index.tsx
Normal file
245
src/pages/activity/detail/index.tsx
Normal 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
|
||||
3
src/pages/activity/list/index.config.ts
Normal file
3
src/pages/activity/list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '活动列表',
|
||||
}
|
||||
220
src/pages/activity/list/index.tsx
Normal file
220
src/pages/activity/list/index.tsx
Normal 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
|
||||
3
src/pages/after-sale/apply/index.config.ts
Normal file
3
src/pages/after-sale/apply/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '申请售后',
|
||||
}
|
||||
251
src/pages/after-sale/apply/index.tsx
Normal file
251
src/pages/after-sale/apply/index.tsx
Normal 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
|
||||
3
src/pages/after-sale/list/index.config.ts
Normal file
3
src/pages/after-sale/list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '售后记录',
|
||||
}
|
||||
180
src/pages/after-sale/list/index.tsx
Normal file
180
src/pages/after-sale/list/index.tsx
Normal 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
|
||||
3
src/pages/after-sale/progress/index.config.ts
Normal file
3
src/pages/after-sale/progress/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '售后进度',
|
||||
}
|
||||
175
src/pages/after-sale/progress/index.tsx
Normal file
175
src/pages/after-sale/progress/index.tsx
Normal 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
|
||||
3
src/pages/apply.config.ts
Normal file
3
src/pages/apply.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '申请售后',
|
||||
}
|
||||
151
src/pages/apply.tsx
Normal file
151
src/pages/apply.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/pages/balance-log.tsx
Normal file
169
src/pages/balance-log.tsx
Normal 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
|
||||
3
src/pages/balance.config.ts
Normal file
3
src/pages/balance.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '礼品卡余额',
|
||||
}
|
||||
187
src/pages/balance.tsx
Normal file
187
src/pages/balance.tsx
Normal 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
|
||||
3
src/pages/booking.config.ts
Normal file
3
src/pages/booking.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '穿线预约',
|
||||
}
|
||||
167
src/pages/booking.tsx
Normal file
167
src/pages/booking.tsx
Normal 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
|
||||
3
src/pages/booking/detail/index.config.ts
Normal file
3
src/pages/booking/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '预约详情',
|
||||
}
|
||||
242
src/pages/booking/detail/index.tsx
Normal file
242
src/pages/booking/detail/index.tsx
Normal 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
|
||||
3
src/pages/booking/list/index.config.ts
Normal file
3
src/pages/booking/list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '预约订单',
|
||||
}
|
||||
226
src/pages/booking/list/index.tsx
Normal file
226
src/pages/booking/list/index.tsx
Normal 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
|
||||
3
src/pages/booking/reschedule/index.config.ts
Normal file
3
src/pages/booking/reschedule/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '改签预约',
|
||||
}
|
||||
229
src/pages/booking/reschedule/index.tsx
Normal file
229
src/pages/booking/reschedule/index.tsx
Normal 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
|
||||
3
src/pages/commission.config.ts
Normal file
3
src/pages/commission.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '佣金明细',
|
||||
}
|
||||
92
src/pages/commission.tsx
Normal file
92
src/pages/commission.tsx
Normal 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
|
||||
3
src/pages/customer-service.config.ts
Normal file
3
src/pages/customer-service.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '在线客服',
|
||||
}
|
||||
406
src/pages/customer-service.tsx
Normal file
406
src/pages/customer-service.tsx
Normal 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/pages/dashboard.tsx
Normal file
198
src/pages/dashboard.tsx
Normal 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
|
||||
3
src/pages/detail.config.ts
Normal file
3
src/pages/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '消息详情',
|
||||
}
|
||||
147
src/pages/detail.tsx
Normal file
147
src/pages/detail.tsx
Normal 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
|
||||
3
src/pages/event/detail/index.config.ts
Normal file
3
src/pages/event/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '立即报名',
|
||||
}
|
||||
195
src/pages/event/detail/index.tsx
Normal file
195
src/pages/event/detail/index.tsx
Normal 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
|
||||
3
src/pages/event/list/index.config.ts
Normal file
3
src/pages/event/list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '报名活动',
|
||||
}
|
||||
146
src/pages/event/list/index.tsx
Normal file
146
src/pages/event/list/index.tsx
Normal 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
|
||||
3
src/pages/event/my/index.config.ts
Normal file
3
src/pages/event/my/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '我的报名',
|
||||
}
|
||||
170
src/pages/event/my/index.tsx
Normal file
170
src/pages/event/my/index.tsx
Normal 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
|
||||
3
src/pages/event/register/index.config.ts
Normal file
3
src/pages/event/register/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '立即报名',
|
||||
}
|
||||
317
src/pages/event/register/index.tsx
Normal file
317
src/pages/event/register/index.tsx
Normal 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
|
||||
4
src/pages/exchange.config.ts
Normal file
4
src/pages/exchange.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
navigationBarTitleText: '积分兑换',
|
||||
enablePullDownRefresh: true,
|
||||
}
|
||||
226
src/pages/exchange.tsx
Normal file
226
src/pages/exchange.tsx
Normal 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
|
||||
96
src/pages/favorite-list.tsx
Normal file
96
src/pages/favorite-list.tsx
Normal 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
|
||||
3
src/pages/gift-card/balance/index.config.ts
Normal file
3
src/pages/gift-card/balance/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '礼品卡余额',
|
||||
}
|
||||
187
src/pages/gift-card/balance/index.tsx
Normal file
187
src/pages/gift-card/balance/index.tsx
Normal 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
|
||||
3
src/pages/gift-card/exchange/index.config.ts
Normal file
3
src/pages/gift-card/exchange/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '兑换礼品卡',
|
||||
}
|
||||
205
src/pages/gift-card/exchange/index.tsx
Normal file
205
src/pages/gift-card/exchange/index.tsx
Normal 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
|
||||
3
src/pages/gift-card/purchase/index.config.ts
Normal file
3
src/pages/gift-card/purchase/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '购买礼品卡',
|
||||
}
|
||||
143
src/pages/gift-card/purchase/index.tsx
Normal file
143
src/pages/gift-card/purchase/index.tsx
Normal 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
|
||||
3
src/pages/group-buy-detail.config.ts
Normal file
3
src/pages/group-buy-detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '拼团详情',
|
||||
}
|
||||
323
src/pages/group-buy-detail.tsx
Normal file
323
src/pages/group-buy-detail.tsx
Normal 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
|
||||
119
src/pages/group-buy-list.tsx
Normal file
119
src/pages/group-buy-list.tsx
Normal 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
|
||||
146
src/pages/help-center.tsx
Normal file
146
src/pages/help-center.tsx
Normal 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
|
||||
50
src/pages/help-detail.tsx
Normal file
50
src/pages/help-detail.tsx
Normal 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
|
||||
113
src/pages/history-list.tsx
Normal file
113
src/pages/history-list.tsx
Normal 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
|
||||
28
src/pages/index/article-detail.tsx
Normal file
28
src/pages/index/article-detail.tsx
Normal 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
|
||||
3
src/pages/index/article-list.config.ts
Normal file
3
src/pages/index/article-list.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '活动列表',
|
||||
}
|
||||
125
src/pages/index/article-list.tsx
Normal file
125
src/pages/index/article-list.tsx
Normal 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
|
||||
3
src/pages/index/coupon-center.config.ts
Normal file
3
src/pages/index/coupon-center.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '领券中心',
|
||||
}
|
||||
125
src/pages/index/coupon-center.tsx
Normal file
125
src/pages/index/coupon-center.tsx
Normal 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
|
||||
3
src/pages/index/coupon-center/index.config.ts
Normal file
3
src/pages/index/coupon-center/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '领券中心',
|
||||
}
|
||||
164
src/pages/index/coupon-center/index.tsx
Normal file
164
src/pages/index/coupon-center/index.tsx
Normal 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
|
||||
6
src/pages/index/index.config.ts
Normal file
6
src/pages/index/index.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
navigationBarTitleText: '鑫龙家电',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#f8f8f8'
|
||||
}
|
||||
9
src/pages/index/index.scss
Normal file
9
src/pages/index/index.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.index-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.nut-button {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
267
src/pages/index/index.tsx
Normal file
267
src/pages/index/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
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'
|
||||
|
||||
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 = ['全部', '球拍', '球鞋', '服装', '配件']
|
||||
|
||||
// 功能入口
|
||||
const featureEntries = [
|
||||
{ icon: '🎁', label: '全部商品', url: '/pages/shop/index' },
|
||||
{ icon: '🎯', label: '限时秒杀', url: '/pages/index/seckill' },
|
||||
{ icon: '👥', label: '拼团活动', url: '/pages/index/group-buy' },
|
||||
{ icon: '🎫', label: '领券中心', url: '/pages/index/coupon-center' },
|
||||
{ icon: '⭐', label: '积分商城', url: '/pages/points/index' },
|
||||
{ icon: '👑', label: '会员中心', url: '/pages/user/member/index' },
|
||||
{ icon: '📞', label: '联系客服', url: '/pages/customer-service' },
|
||||
{ icon: '🏪', label: '门店地址', url: '/pages/index/store-list' },
|
||||
]
|
||||
|
||||
// 获取轮播图
|
||||
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 })
|
||||
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/points/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='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<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'>
|
||||
<Text className='text-sm font-bold text-red-500 mr-1'>¥{item.price || '0'}</Text>
|
||||
{item.salePrice && item.salePrice !== item.price && (
|
||||
<Text className='text-xs text-gray-400 line-through'>¥{item.salePrice}</Text>
|
||||
)}
|
||||
</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
|
||||
87
src/pages/index/notification.tsx
Normal file
87
src/pages/index/notification.tsx
Normal 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
|
||||
115
src/pages/index/search.tsx
Normal file
115
src/pages/index/search.tsx
Normal 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, isShow: 1, 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
|
||||
3
src/pages/invite-record.config.ts
Normal file
3
src/pages/invite-record.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '邀请记录',
|
||||
}
|
||||
89
src/pages/invite-record.tsx
Normal file
89
src/pages/invite-record.tsx
Normal 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
|
||||
153
src/pages/invoice/apply/index.tsx
Normal file
153
src/pages/invoice/apply/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
src/pages/invoice/records/index.tsx
Normal file
224
src/pages/invoice/records/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
src/pages/invoice/title/index.tsx
Normal file
297
src/pages/invoice/title/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/pages/list.config.ts
Normal file
3
src/pages/list.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '门店列表',
|
||||
}
|
||||
131
src/pages/list.tsx
Normal file
131
src/pages/list.tsx
Normal 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
|
||||
3
src/pages/member.config.ts
Normal file
3
src/pages/member.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '会员中心',
|
||||
}
|
||||
137
src/pages/member.tsx
Normal file
137
src/pages/member.tsx
Normal 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
|
||||
3
src/pages/message/detail/index.config.ts
Normal file
3
src/pages/message/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '消息详情',
|
||||
}
|
||||
169
src/pages/message/detail/index.tsx
Normal file
169
src/pages/message/detail/index.tsx
Normal 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
|
||||
3
src/pages/message/list/index.config.ts
Normal file
3
src/pages/message/list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '消息通知',
|
||||
}
|
||||
262
src/pages/message/list/index.tsx
Normal file
262
src/pages/message/list/index.tsx
Normal 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
|
||||
220
src/pages/order-list.tsx
Normal file
220
src/pages/order-list.tsx
Normal 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
|
||||
340
src/pages/order/after-sale-apply.tsx
Normal file
340
src/pages/order/after-sale-apply.tsx
Normal 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
|
||||
257
src/pages/order/after-sale-detail.tsx
Normal file
257
src/pages/order/after-sale-detail.tsx
Normal 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
|
||||
174
src/pages/order/after-sale-list.tsx
Normal file
174
src/pages/order/after-sale-list.tsx
Normal 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
|
||||
519
src/pages/order/detail.tsx
Normal file
519
src/pages/order/detail.tsx
Normal 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
|
||||
3
src/pages/order/evaluate-detail.config.ts
Normal file
3
src/pages/order/evaluate-detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '评价详情',
|
||||
}
|
||||
186
src/pages/order/evaluate-detail.tsx
Normal file
186
src/pages/order/evaluate-detail.tsx
Normal 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
|
||||
3
src/pages/order/evaluate-detail/index.config.ts
Normal file
3
src/pages/order/evaluate-detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '评价详情',
|
||||
}
|
||||
189
src/pages/order/evaluate-detail/index.tsx
Normal file
189
src/pages/order/evaluate-detail/index.tsx
Normal 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
|
||||
3
src/pages/order/evaluate-list.config.ts
Normal file
3
src/pages/order/evaluate-list.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '商品评价',
|
||||
}
|
||||
238
src/pages/order/evaluate-list.tsx
Normal file
238
src/pages/order/evaluate-list.tsx
Normal 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
|
||||
3
src/pages/order/evaluate-list/index.config.ts
Normal file
3
src/pages/order/evaluate-list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '商品评价',
|
||||
}
|
||||
238
src/pages/order/evaluate-list/index.tsx
Normal file
238
src/pages/order/evaluate-list/index.tsx
Normal 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
|
||||
3
src/pages/order/evaluate.config.ts
Normal file
3
src/pages/order/evaluate.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '商品评价',
|
||||
}
|
||||
185
src/pages/order/evaluate.tsx
Normal file
185
src/pages/order/evaluate.tsx
Normal 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
|
||||
3
src/pages/order/evaluate/index.config.ts
Normal file
3
src/pages/order/evaluate/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '商品评价',
|
||||
}
|
||||
185
src/pages/order/evaluate/index.tsx
Normal file
185
src/pages/order/evaluate/index.tsx
Normal 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
|
||||
187
src/pages/order/list.tsx
Normal file
187
src/pages/order/list.tsx
Normal 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
|
||||
250
src/pages/order/logistics.tsx
Normal file
250
src/pages/order/logistics.tsx
Normal 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
|
||||
6
src/pages/order/order.config.ts
Normal file
6
src/pages/order/order.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
navigationBarTitleText: '我的',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#f8f8f8'
|
||||
}
|
||||
9
src/pages/order/order.scss
Normal file
9
src/pages/order/order.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.nut-avatar {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
65
src/pages/order/order.tsx
Normal file
65
src/pages/order/order.tsx
Normal 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
Reference in New Issue
Block a user