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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getRefundDetail, type RefundDetail } from '@/api/shop/shopOrderRefund'
import Loading from '@/components/common/Loading'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '退款详情',
})
const RefundDetailPage: React.FC = () => {
const { orderId } = Taro.getCurrentInstance().router?.params || {}
const [loading, setLoading] = useState(true)
const [refundDetail, setRefundDetail] = useState<RefundDetail | null>(null)
useEffect(() => {
loadRefundDetail()
}, [orderId])
const loadRefundDetail = async () => {
try {
setLoading(true)
const data = await getRefundDetail(orderId || '')
setRefundDetail(data)
} catch (err) {
Taro.showToast({ title: '加载失败', icon: 'none' })
console.error('加载退款详情失败', err)
} finally {
setLoading(false)
}
}
if (loading) {
return <Loading fullscreen />
}
if (!refundDetail) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<EmptyState text='未找到退款信息' />
</View>
)
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 退款状态 */}
<View className='p-4 text-center bg-white'>
<Text className='text-lg font-bold text-orange-500 block'>{refundDetail.statusText}</Text>
<Text className='text-sm text-gray-400 mt-1 block'>
退: {'\u00A5'}{refundDetail.refundMoney}
</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>
{refundDetail.steps.map((step, idx) => (
<View key={idx} className='flex gap-3 pb-4 border-b border-gray-50' style={idx === refundDetail.steps.length - 1 ? { borderBottom: 'none', paddingBottom: '0' } : undefined}>
<View className='flex flex-col items-center'>
<View className={`w-3 h-3 rounded-full ${step.done ? 'bg-green-500' : 'bg-gray-300'}`} />
{idx < refundDetail.steps.length - 1 && (
<View className={`w-1 flex-1 min-h-4 ${step.done ? 'bg-green-500' : 'bg-gray-200'}`} />
)}
</View>
<View className='flex-1'>
<Text className={`text-sm ${step.done ? 'text-gray-800' : 'text-gray-400'}`}>{step.title}</Text>
<Text className='text-xs text-gray-400 mt-0 block'>{step.desc}</Text>
{step.time && (
<Text className='text-xs text-gray-400 mt-0 block'>{step.time}</Text>
)}
</View>
</View>
))}
</View>
{/* 退款信息 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl mb-4'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'>退</Text>
<View className='text-xs text-gray-500 leading-6'>
<Text>: {refundDetail.orderNo}</Text>
<Text>退: {refundDetail.reason}</Text>
<Text>: {refundDetail.time}</Text>
</View>
</View>
</ScrollView>
</View>
)
}
export default RefundDetailPage

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Input, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { applyAfterSale } from '@/api/shop/shopAfterSale'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '申请退款',
})
const REASON_OPTIONS = ['不想要了', '商品描述不符', '商品质量问题', '发货太慢', '其他原因']
const RefundPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [reason, setReason] = useState('')
const [description, setDescription] = useState('')
const [selectedReason, setSelectedReason] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
if (!selectedReason) {
Taro.showToast({ title: '请选择退款原因', icon: 'none' })
return
}
Taro.showModal({
title: '确认',
content: '确定要提交退款申请吗?',
success: async (res) => {
if (res.confirm) {
setLoading(true)
try {
Taro.showToast({ title: '提交中...', icon: 'loading' })
const result = await applyAfterSale({
orderId: id || '',
type: 'refund',
reason: selectedReason,
description: description,
amount: 0 // 金额由后端计算
})
if (result.success) {
Taro.showToast({ title: '申请已提交', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} else {
Taro.showToast({ title: result.message || '提交失败', icon: 'none' })
}
} catch (err) {
console.error('申请退款失败:', err)
Taro.showToast({ title: '提交失败', icon: 'none' })
} finally {
setLoading(false)
}
}
},
})
}
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 flex-wrap gap-2'>
{REASON_OPTIONS.map(r => (
<View
key={r}
className={`px-3 py-1 rounded-full text-sm ${
selectedReason === r
? 'bg-green-50 text-green-600 border border-green-500'
: 'bg-gray-50 text-gray-600 border border-gray-200'
}`}
onClick={() => setSelectedReason(r)}
>
<Text>{r}</Text>
</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-2 block'>退</Text>
<View className='w-full bg-gray-50 rounded-lg p-3 text-sm'>
<Input
className='w-full'
placeholder='选填,请详细描述退款原因'
value={description}
onInput={e => setDescription(e.detail.value)}
style={{ minHeight: '120px' }}
/>
</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: '#ee0a24' }}
onClick={handleSubmit}
>
</Button>
</View>
</View>
)
}
export default RefundPage