- 新增地址类型定义,增强前端地址数据结构 - 新增地址编辑页面,支持地址智能识别和定位选点功能 - 地址编辑支持省市区选择及默认地址设置 - 新增地址列表页面,支持地址展示、删除、编辑和选择功能 - 实现售后申请页面,支持选择售后类型和退款原因 - 售后申请支持商品选择、退款金额计算和凭证上传 - 新增售后详情页面,支持售后状态展示及申请取消 - 优化页面加载和用户交互体验,增加错误提示和权限处理
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
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
|