feat(order): 添加订单退款功能并优化退款流程

- 在 shopOrder API 中新增 refundShopOrder 接口导入
- 实现订单详情页的退款申请功能,添加确认弹窗和加载提示
- 将退款逻辑从 afterSale 接口迁移到统一的 refundShopOrder 接口
- 优化退款页面数据获取,支持从订单详情或商品明细接口获取商品信息
- 添加退款金额验证和订单ID格式校验
- 实现乐观更新本地状态以提升用户体验
- 统一各页面的退款流程和状态码处理
- 添加退款按钮状态控制,避免重复提交
This commit is contained in:
2026-02-24 12:30:11 +08:00
parent 694b5e1e7a
commit d6eb6d5e05
3 changed files with 117 additions and 97 deletions

View File

@@ -3,7 +3,7 @@ import {Cell, CellGroup, Image, Space, Button, Dialog} from '@nutui/nutui-react-
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import {View} from '@tarojs/components' import {View} from '@tarojs/components'
import {ShopOrder} from "@/api/shop/shopOrder/model"; import {ShopOrder} from "@/api/shop/shopOrder/model";
import {getShopOrder, updateShopOrder} from "@/api/shop/shopOrder"; import {getShopOrder, updateShopOrder, refundShopOrder} from "@/api/shop/shopOrder";
import {listShopOrderGoods} from "@/api/shop/shopOrderGoods"; import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model"; import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -58,25 +58,39 @@ const OrderDetail = () => {
const handleApplyRefund = async () => { const handleApplyRefund = async () => {
if (order) { if (order) {
try { try {
// 更新订单状态为"退款申请中" const confirm = await Taro.showModal({
await updateShopOrder({ title: '申请退款',
content: '确认要申请退款吗?',
confirmText: '确认',
cancelText: '取消'
})
if (!confirm?.confirm) return
Taro.showLoading({ title: '提交中...' })
// 退款相关操作使用退款接口PUT /api/shop/shop-order/refund
await refundShopOrder({
orderId: order.orderId, orderId: order.orderId,
orderStatus: 4 // 退款申请中 refundMoney: order.payPrice || order.totalPrice,
}); orderStatus: 7
})
// 更新本地状态 // 乐观更新本地状态
setOrder(prev => prev ? {...prev, orderStatus: 4} : null); setOrder(prev => prev ? { ...prev, orderStatus: 7 } : null)
// 跳转到退款申请页面 Taro.showToast({ title: '退款申请已提交', icon: 'success' })
Taro.navigateTo({
url: `/user/order/refund/index?orderId=${order.orderId}&orderNo=${order.orderNo}`
});
} catch (error) { } catch (error) {
console.error('更新订单状态失败:', error); console.error('申请退款失败:', error);
Taro.showToast({ Taro.showToast({
title: '操作失败,请重试', title: '操作失败,请重试',
icon: 'none' icon: 'none'
}); });
} finally {
try {
Taro.hideLoading()
} catch (_e) {
// ignore
}
} }
} }
}; };

View File

@@ -8,7 +8,8 @@ import {
updateShopOrder, updateShopOrder,
createOrder, createOrder,
getShopOrder, getShopOrder,
prepayShopOrder prepayShopOrder,
refundShopOrder
} from "@/api/shop/shopOrder"; } from "@/api/shop/shopOrder";
import {OrderCreateRequest, ShopOrder, ShopOrderParam} from "@/api/shop/shopOrder/model"; import {OrderCreateRequest, ShopOrder, ShopOrderParam} from "@/api/shop/shopOrder/model";
import {listShopOrderGoods} from "@/api/shop/shopOrderGoods"; import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
@@ -330,28 +331,53 @@ function OrderList(props: OrderListProps) {
// 申请退款 (待发货状态) // 申请退款 (待发货状态)
const applyRefund = async (order: ShopOrder) => { const applyRefund = async (order: ShopOrder) => {
if (!order?.orderId) {
Taro.showToast({ title: '订单信息错误', icon: 'none' })
return
}
try { try {
// 更新订单状态为"退款申请中" const confirm = await Taro.showModal({
await updateShopOrder({ title: '申请退款',
content: '确认要申请退款吗?',
confirmText: '确认',
cancelText: '取消'
})
if (!confirm?.confirm) return
Taro.showLoading({ title: '提交中...' })
// 退款相关操作使用退款接口PUT /api/shop/shop-order/refund
await refundShopOrder({
orderId: order.orderId, orderId: order.orderId,
orderStatus: 4 // 退款申请中 // Best-effort: pass refund amount when available; backend may ignore and calculate by orderId.
refundMoney: order.payPrice || order.totalPrice,
// Backend requires orderStatus to determine refund flow (client applying).
orderStatus: 7
}); });
// 更新本地状态 Taro.showToast({ title: '退款申请已提交', icon: 'success' })
// 乐观更新本地状态,提升反馈速度
setList(prev => prev.map(item => setList(prev => prev.map(item =>
item.orderId === order.orderId ? {...item, orderStatus: 4} : item item.orderId === order.orderId ? { ...item, orderStatus: 7 } : item
)); ))
// 刷新列表以服务端为准
void reload(true)
props.onReload?.()
// 跳转到退款申请页面
Taro.navigateTo({
url: `/user/order/refund/index?orderId=${order.orderId}&orderNo=${order.orderNo}`
});
} catch (error) { } catch (error) {
console.error('更新订单状态失败:', error); console.error('申请退款失败:', error);
Taro.showToast({ Taro.showToast({
title: '操作失败,请重试', title: '操作失败,请重试',
icon: 'none' icon: 'none'
}); });
} finally {
try {
Taro.hideLoading()
} catch (_e) {
// ignore
}
} }
}; };
@@ -839,7 +865,7 @@ function OrderList(props: OrderListProps) {
)} )}
{/* 待发货状态:显示申请退款 */} {/* 待发货状态:显示申请退款 */}
{item.payStatus && isWithinRefundWindow(item.payTime, 60) && item.deliveryStatus === 10 && item.orderStatus !== 2 && item.orderStatus !== 4 && !isOrderCompleted(item) && ( {item.payStatus && isWithinRefundWindow(item.payTime, 60) && item.deliveryStatus === 10 && item.orderStatus !== 2 && item.orderStatus !== 4 && item.orderStatus !== 7 && !isOrderCompleted(item) && (
<Button size={'small'} onClick={(e) => { <Button size={'small'} onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
applyRefund(item); applyRefund(item);

View File

@@ -13,8 +13,8 @@ import {
Empty, Empty,
InputNumber InputNumber
} from '@nutui/nutui-react-taro' } from '@nutui/nutui-react-taro'
import { applyAfterSale } from '@/api/afterSale' import { getShopOrder, refundShopOrder } from '@/api/shop/shopOrder'
import { updateShopOrder } from '@/api/shop/shopOrder' import { listShopOrderGoods } from '@/api/shop/shopOrderGoods'
import './index.scss' import './index.scss'
// 订单商品信息 // 订单商品信息
@@ -84,42 +84,48 @@ const RefundPage: React.FC = () => {
try { try {
setLoading(true) setLoading(true)
// 模拟API调用 const numericOrderId = Number(orderId)
const mockOrderGoods: OrderGoods[] = [ if (!Number.isFinite(numericOrderId) || numericOrderId <= 0) {
{ setOrderGoods([])
goodsId: '1', setOrderAmount(0)
goodsName: 'iPhone 15 Pro Max 256GB 深空黑色', return
goodsImage: 'https://via.placeholder.com/100x100', }
goodsPrice: 9999,
goodsNum: 1, const order = await getShopOrder(numericOrderId)
canRefundNum: 1,
skuInfo: '颜色深空黑色容量256GB' // 优先使用订单详情自带的 orderGoods缺失时补拉一次商品明细
}, const rawGoods = (order?.orderGoods && order.orderGoods.length)
{ ? order.orderGoods
goodsId: '2', : (await listShopOrderGoods({ orderId: numericOrderId })) || []
goodsName: 'AirPods Pro 第三代',
goodsImage: 'https://via.placeholder.com/100x100', const goods: OrderGoods[] = (rawGoods || []).map((g: any) => {
goodsPrice: 1999, const qty = Number(g?.totalNum ?? g?.quantity ?? 1)
goodsNum: 2, const price = Number(g?.price ?? 0)
canRefundNum: 2, return {
skuInfo: '颜色:白色' goodsId: String(g?.goodsId ?? g?.itemId ?? ''),
goodsName: String(g?.goodsName || g?.goodsTitle || g?.title || ''),
goodsImage: String(g?.image || ''),
goodsPrice: Number.isFinite(price) ? price : 0,
goodsNum: Number.isFinite(qty) && qty > 0 ? qty : 1,
canRefundNum: Number.isFinite(qty) && qty > 0 ? qty : 1,
skuInfo: g?.spec ? String(g.spec) : (g?.specInfo ? String(g.specInfo) : undefined)
} }
] }).filter(g => !!g.goodsId)
const totalAmount = mockOrderGoods.reduce((sum, goods) => // 优先使用订单的实付金额;兜底按商品合计
sum + goods.goodsPrice * goods.goodsNum, 0 const amountFromOrder = Number(order?.payPrice ?? order?.totalPrice ?? 0)
) const totalAmount = (Number.isFinite(amountFromOrder) && amountFromOrder > 0)
? amountFromOrder
: goods.reduce((sum, g) => sum + g.goodsPrice * g.goodsNum, 0)
await new Promise(resolve => setTimeout(resolve, 1000)) setOrderGoods(goods)
setOrderGoods(mockOrderGoods)
setOrderAmount(totalAmount) setOrderAmount(totalAmount)
// 初始化退款申请信息 // 初始化退款申请信息
setRefundApp(prev => ({ setRefundApp(prev => ({
...prev, ...prev,
refundAmount: totalAmount, refundAmount: totalAmount,
refundGoods: mockOrderGoods.map(goods => ({ refundGoods: goods.map(goods => ({
goodsId: goods.goodsId, goodsId: goods.goodsId,
refundNum: goods.goodsNum refundNum: goods.goodsNum
})) }))
@@ -234,50 +240,20 @@ const RefundPage: React.FC = () => {
setSubmitting(true) setSubmitting(true)
// 构造请求参数 const numericOrderId = Number(orderId)
const params = { if (!Number.isFinite(numericOrderId) || numericOrderId <= 0) {
orderId: orderId || '', throw new Error('订单信息错误')
type: 'refund' as const,
reason: refundApp.refundReason,
description: refundApp.refundDescription,
amount: refundApp.refundAmount,
contactPhone: refundApp.contactPhone,
evidenceImages: refundApp.evidenceImages,
goodsItems: refundApp.refundGoods.filter(item => item.refundNum > 0).map(item => ({
goodsId: item.goodsId,
quantity: item.refundNum
}))
} }
// 调用API提交退款申请 // 退款相关操作使用退款接口PUT /api/shop/shop-order/refund
const result = await applyAfterSale(params) await refundShopOrder({
orderId: numericOrderId,
refundMoney: String(refundApp.refundAmount),
orderStatus: 7
})
if (result.success) { Taro.showToast({ title: '退款申请提交成功', icon: 'success' })
// 更新订单状态为"退款申请中" setTimeout(() => Taro.navigateBack(), 1200)
if (orderId) {
try {
await updateShopOrder({
orderId: parseInt(orderId),
orderStatus: 4 // 退款申请中
})
} catch (updateError) {
console.error('更新订单状态失败:', updateError)
// 即使更新订单状态失败,也继续执行后续操作
}
}
Taro.showToast({
title: '退款申请提交成功',
icon: 'success'
})
// 延迟返回上一页
setTimeout(() => {
Taro.navigateBack()
}, 1500)
} else {
throw new Error(result.message || '提交失败')
}
} catch (error) { } catch (error) {
console.error('提交退款申请失败:', error) console.error('提交退款申请失败:', error)
@@ -316,6 +292,9 @@ const RefundPage: React.FC = () => {
) )
} }
// @ts-ignore
// @ts-ignore
// @ts-ignore
return ( return (
<View className="refund-page"> <View className="refund-page">
{/* 订单信息 */} {/* 订单信息 */}
@@ -369,7 +348,7 @@ const RefundPage: React.FC = () => {
value={refundNum} value={refundNum}
min={0} min={0}
max={goods.canRefundNum} max={goods.canRefundNum}
onChange={(value) => updateGoodsRefundNum(goods.goodsId, value)} onChange={(value) => updateGoodsRefundNum(goods.goodsId, Number(value))}
/> />
<Text className="max-num">{goods.canRefundNum}</Text> <Text className="max-num">{goods.canRefundNum}</Text>
</View> </View>
@@ -418,6 +397,7 @@ const RefundPage: React.FC = () => {
<View className="evidence-section"> <View className="evidence-section">
<View className="section-title"></View> <View className="section-title"></View>
<Uploader <Uploader
// @ts-ignore
value={refundApp.evidenceImages.map(url => ({ url }))} value={refundApp.evidenceImages.map(url => ({ url }))}
onChange={handleImageUpload} onChange={handleImageUpload}
multiple multiple