feat: 实现订单退货全链路功能(申请、审核、物流、退款)

This commit is contained in:
2026-03-16 23:13:58 +08:00
parent 13b2e9f093
commit 32c1113d4a
21 changed files with 1870 additions and 12 deletions

View File

@@ -0,0 +1,52 @@
import { request } from '../request'
import type { ApiResponse, OrderReturn } from '@/types/api'
import { normalizeOrderReturn } from '@/utils/normalizers'
export const returnApi = {
create(data: { orderId: number; reason: string; description?: string; images?: string }): Promise<ApiResponse<OrderReturn>> {
return request.post('/api/return/create', data).then(normalizeResponse)
},
getByOrderId(orderId: number): Promise<ApiResponse<OrderReturn | null>> {
return request.get(`/api/return/order/${orderId}`)
.then((res: any) => ({
...res,
data: res.data ? normalizeOrderReturn(res.data) : null,
}))
},
getMyReturns(params?: { status?: number; page?: number; size?: number }): Promise<ApiResponse<any>> {
return request.get('/api/return/my', params)
},
ship(id: number, returnTracking: string): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/ship`, { returnTracking }).then(normalizeResponse)
},
cancel(id: number): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/cancel`).then(normalizeResponse)
},
adminReview(id: number, data: { status: number; rejectReason?: string; adminRemark?: string }): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/review`, data).then(normalizeResponse)
},
adminComplete(id: number, remark?: string): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/complete`, { remark }).then(normalizeResponse)
},
getAll(params?: { status?: number; page?: number; size?: number }): Promise<ApiResponse<any>> {
return request.get('/api/return/all', params)
},
getStatistics(): Promise<ApiResponse<any>> {
return request.get('/api/return/statistics')
},
}
function normalizeResponse(res: any): any {
return {
...res,
data: res.data ? normalizeOrderReturn(res.data) : res.data,
}
}

View File

@@ -0,0 +1,105 @@
<template>
<el-dialog
:model-value="visible"
title="申请退货"
width="520px"
@update:model-value="$emit('update:visible', $event)"
>
<el-form :model="form" label-width="80px">
<el-form-item label="退款金额">
<span class="text-lg font-bold text-red-500">&yen;{{ refundAmount }}</span>
</el-form-item>
<el-form-item label="退货原因" required>
<el-select v-model="form.reason" placeholder="请选择退货原因" style="width: 100%">
<el-option label="质量问题" value="质量问题" />
<el-option label="商品与描述不符" value="商品与描述不符" />
<el-option label="发错商品" value="发错商品" />
<el-option label="不想要了" value="不想要了" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="详细描述">
<el-input
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请描述退货原因(选填)"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="!form.reason"
@click="handleSubmit"
>
提交申请
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { returnApi } from '@/api/modules/return'
const props = defineProps<{
visible: boolean
orderId: number
refundAmount: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const submitting = ref(false)
const form = reactive({
reason: '',
description: '',
})
const handleSubmit = async () => {
if (!form.reason) {
ElMessage.warning('请选择退货原因')
return
}
submitting.value = true
try {
const res = await returnApi.create({
orderId: props.orderId,
reason: form.reason,
description: form.description || undefined,
})
if (res.success) {
ElMessage.success('退货申请已提交')
emit('success')
emit('update:visible', false)
} else {
ElMessage.error(res.message || '申请失败')
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || '申请失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
watch(() => props.visible, (val) => {
if (val) {
form.reason = ''
form.description = ''
}
})
</script>

View File

@@ -0,0 +1,82 @@
<template>
<el-dialog
:model-value="visible"
title="填写退货物流"
width="460px"
@update:model-value="$emit('update:visible', $event)"
>
<el-form :model="form" label-width="90px">
<el-form-item label="物流单号" required>
<el-input
v-model="form.returnTracking"
placeholder="请输入退货物流单号"
maxlength="100"
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="!form.returnTracking.trim()"
@click="handleSubmit"
>
提交
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { returnApi } from '@/api/modules/return'
const props = defineProps<{
visible: boolean
returnId: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const submitting = ref(false)
const form = reactive({
returnTracking: '',
})
const handleSubmit = async () => {
if (!form.returnTracking.trim()) {
ElMessage.warning('请输入物流单号')
return
}
submitting.value = true
try {
const res = await returnApi.ship(props.returnId, form.returnTracking.trim())
if (res.success) {
ElMessage.success('物流信息已提交')
emit('success')
emit('update:visible', false)
} else {
ElMessage.error(res.message || '提交失败')
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || '提交失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
watch(() => props.visible, (val) => {
if (val) {
form.returnTracking = ''
}
})
</script>

View File

@@ -48,6 +48,11 @@
<template #title>用户管理</template>
</el-menu-item>
<el-menu-item index="/admin/returns">
<el-icon><RefreshLeft /></el-icon>
<template #title>退货管理</template>
</el-menu-item>
<el-menu-item index="/admin/reviews">
<el-icon><ChatDotRound /></el-icon>
<template #title>评价管理</template>
@@ -161,6 +166,7 @@ const currentPageTitle = computed(() => {
'/admin/groupbuying': '拼团管理',
'/admin/orders': '订单管理',
'/admin/users': '用户管理',
'/admin/returns': '退货管理',
'/admin/reviews': '评价管理',
'/admin/favorites': '收藏管理',
'/admin/monitor': '系统监控',

View File

@@ -0,0 +1,220 @@
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h2 class="page-title">退货管理</h2>
<p class="page-subtitle">处理用户退货申请审核和退款</p>
</div>
<el-button @click="reloadData"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<div class="stats-grid">
<div class="mini-stat orange"><div class="mini-stat__value">{{ stats.pendingCount }}</div><div class="mini-stat__label">待审核</div></div>
<div class="mini-stat blue"><div class="mini-stat__value">{{ stats.approvedCount }}</div><div class="mini-stat__label">已同意</div></div>
<div class="mini-stat purple"><div class="mini-stat__value">{{ stats.returningCount }}</div><div class="mini-stat__label">退货中</div></div>
<div class="mini-stat green"><div class="mini-stat__value">{{ stats.completedCount }}</div><div class="mini-stat__label">已完成</div></div>
</div>
<div class="panel-card filter-card">
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width: 150px" @change="loadReturns">
<el-option label="全部" :value="undefined" />
<el-option label="待审核" :value="1" />
<el-option label="已同意" :value="2" />
<el-option label="退货中" :value="3" />
<el-option label="已完成" :value="4" />
<el-option label="已拒绝" :value="5" />
<el-option label="已取消" :value="6" />
</el-select>
<el-button type="primary" @click="loadReturns">查询</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="returns" stripe>
<el-table-column prop="returnNo" label="退货单号" min-width="140" />
<el-table-column prop="orderNo" label="订单号" min-width="140" />
<el-table-column prop="username" label="用户" width="100" />
<el-table-column prop="productName" label="商品" min-width="160" show-overflow-tooltip />
<el-table-column prop="refundAmount" label="退款金额" width="100">
<template #default="{ row }">&yen;{{ row.refundAmount }}</template>
</el-table-column>
<el-table-column prop="reason" label="退货原因" min-width="120" show-overflow-tooltip />
<el-table-column prop="statusText" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="getReturnStatusType(row.status)" size="small">{{ row.statusText }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="申请时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'PENDING'" text type="primary" @click="openReviewDialog(row)">审核</el-button>
<el-button v-if="row.status === 'RETURNING'" text type="success" @click="handleComplete(row)">确认退款</el-button>
<el-button text @click="openDetailDialog(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="loadReturns" @size-change="loadReturns" />
</div>
</div>
<!-- 审核弹窗 -->
<el-dialog v-model="reviewVisible" title="退货审核" width="500px">
<div v-if="currentReturn" class="space-y-4">
<div class="text-sm"><span class="text-gray-500">退货单号</span>{{ currentReturn.returnNo }}</div>
<div class="text-sm"><span class="text-gray-500">用户</span>{{ currentReturn.username }}</div>
<div class="text-sm"><span class="text-gray-500">退款金额</span><span class="text-red-500 font-semibold">&yen;{{ currentReturn.refundAmount }}</span></div>
<div class="text-sm"><span class="text-gray-500">退货原因</span>{{ currentReturn.reason }}</div>
<div v-if="currentReturn.description" class="text-sm"><span class="text-gray-500">详细描述</span>{{ currentReturn.description }}</div>
<el-divider />
<el-radio-group v-model="reviewForm.status">
<el-radio :label="2">同意退货</el-radio>
<el-radio :label="5">拒绝退货</el-radio>
</el-radio-group>
<el-input v-if="reviewForm.status === 5" v-model="reviewForm.rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
<el-input v-model="reviewForm.adminRemark" type="textarea" :rows="2" placeholder="管理员备注(选填)" />
</div>
<template #footer>
<el-button @click="reviewVisible = false">取消</el-button>
<el-button type="primary" :disabled="!reviewForm.status" @click="submitReview">确认</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="退货详情" width="600px">
<div v-if="currentReturn" class="space-y-3 text-sm">
<div><span class="text-gray-500">退货单号</span>{{ currentReturn.returnNo }}</div>
<div><span class="text-gray-500">订单号</span>{{ currentReturn.orderNo }}</div>
<div><span class="text-gray-500">用户</span>{{ currentReturn.username }}</div>
<div><span class="text-gray-500">商品</span>{{ currentReturn.productName }}</div>
<div><span class="text-gray-500">退款金额</span><span class="text-red-500 font-semibold">&yen;{{ currentReturn.refundAmount }}</span></div>
<div><span class="text-gray-500">退货原因</span>{{ currentReturn.reason }}</div>
<div v-if="currentReturn.description"><span class="text-gray-500">详细描述</span>{{ currentReturn.description }}</div>
<div><span class="text-gray-500">状态</span><el-tag :type="getReturnStatusType(currentReturn.status)" size="small">{{ currentReturn.statusText }}</el-tag></div>
<div v-if="currentReturn.rejectReason"><span class="text-gray-500">拒绝原因:</span><span class="text-red-500">{{ currentReturn.rejectReason }}</span></div>
<div v-if="currentReturn.returnTracking"><span class="text-gray-500">物流单号</span>{{ currentReturn.returnTracking }}</div>
<div v-if="currentReturn.adminRemark"><span class="text-gray-500">管理员备注</span>{{ currentReturn.adminRemark }}</div>
<div><span class="text-gray-500">申请时间</span>{{ formatTime(currentReturn.createdAt) }}</div>
<div v-if="currentReturn.reviewedAt"><span class="text-gray-500">审核时间</span>{{ formatTime(currentReturn.reviewedAt) }}</div>
<div v-if="currentReturn.shippedAt"><span class="text-gray-500">寄出时间</span>{{ formatTime(currentReturn.shippedAt) }}</div>
<div v-if="currentReturn.completedAt"><span class="text-gray-500">完成时间</span>{{ formatTime(currentReturn.completedAt) }}</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { returnApi } from '@/api/modules/return'
import { normalizeOrderReturn } from '@/utils/normalizers'
import type { OrderReturn } from '@/types/api'
import dayjs from 'dayjs'
const loading = ref(false)
const page = ref(1)
const size = ref(10)
const total = ref(0)
const returns = ref<OrderReturn[]>([])
const filters = reactive<{ status: number | undefined }>({ status: undefined })
const stats = reactive({ pendingCount: 0, approvedCount: 0, returningCount: 0, completedCount: 0, rejectedCount: 0, cancelledCount: 0, totalCount: 0 })
const reviewVisible = ref(false)
const detailVisible = ref(false)
const currentReturn = ref<OrderReturn | null>(null)
const reviewForm = reactive({ status: 0 as number, rejectReason: '', adminRemark: '' })
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getReturnStatusType = (status: string) => ({ PENDING: 'warning', APPROVED: 'primary', RETURNING: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info' }[status] || 'info')
const loadStats = async () => {
try {
const res = await returnApi.getStatistics()
if (res.success) Object.assign(stats, res.data)
} catch (error) {
console.error('加载统计失败:', error)
}
}
const loadReturns = async () => {
loading.value = true
try {
const res = await returnApi.getAll({
status: filters.status,
page: page.value - 1,
size: size.value,
})
if (res.success) {
returns.value = (res.data.content || []).map((item: any) => normalizeOrderReturn(item))
total.value = res.data.totalElements || 0
}
} finally {
loading.value = false
}
}
const openReviewDialog = (row: OrderReturn) => {
currentReturn.value = row
reviewForm.status = 0
reviewForm.rejectReason = ''
reviewForm.adminRemark = ''
reviewVisible.value = true
}
const openDetailDialog = (row: OrderReturn) => {
currentReturn.value = row
detailVisible.value = true
}
const submitReview = async () => {
if (!currentReturn.value || !reviewForm.status) return
if (reviewForm.status === 5 && !reviewForm.rejectReason.trim()) {
ElMessage.warning('请输入拒绝原因')
return
}
try {
await returnApi.adminReview(currentReturn.value.id, {
status: reviewForm.status,
rejectReason: reviewForm.status === 5 ? reviewForm.rejectReason : undefined,
adminRemark: reviewForm.adminRemark || undefined,
})
ElMessage.success('审核完成')
reviewVisible.value = false
reloadData()
} catch (error) {
console.error('审核失败:', error)
}
}
const handleComplete = async (row: OrderReturn) => {
await ElMessageBox.confirm(`确认退款 ¥${row.refundAmount} 给用户 ${row.username}`, '确认退款', { confirmButtonText: '确认退款', cancelButtonText: '取消', type: 'warning' })
try {
await returnApi.adminComplete(row.id)
ElMessage.success('退款已完成')
reloadData()
} catch (error) {
console.error('确认退款失败:', error)
}
}
const reloadData = async () => { await Promise.all([loadStats(), loadReturns()]) }
onMounted(() => { reloadData() })
</script>
<style scoped lang="scss">
.page-shell { display:flex; flex-direction:column; gap:20px; }
.page-header { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
.page-title { @apply text-2xl font-bold text-slate-900; }
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
.mini-stat { @apply rounded-xl p-5 shadow-sm; background:#fffaf2; color:#171715; border:1px solid #d8cebf; box-shadow:0 10px 24px rgba(23,22,20,0.04); }
.mini-stat__value { @apply text-3xl font-bold; }
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
.panel-card { @apply bg-white rounded-xl p-5; border:1px solid #d8cebf; box-shadow:0 10px 24px rgba(23,22,20,0.04); }
.filter-card { display:flex; gap:12px; align-items:center; }
.table-footer { @apply flex justify-end mt-4; }
</style>

View File

@@ -42,9 +42,17 @@
<template v-else-if="order.status === 'COMPLETED'">
<el-button v-if="allReviewed" @click="reviewDialogVisible = true">查看评价</el-button>
<el-button v-else type="primary" @click="reviewDialogVisible = true">评价</el-button>
<el-button type="warning" @click="returnDialogVisible = true">申请退货</el-button>
<el-button @click="handleRebuy">再次购买</el-button>
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
</template>
<template v-else-if="order.status === 'REFUNDING'">
<el-button v-if="orderReturn && orderReturn.status === 'APPROVED'" type="primary" @click="trackingDialogVisible = true">填写物流单号</el-button>
<el-button v-if="orderReturn && (orderReturn.status === 'PENDING' || orderReturn.status === 'APPROVED')" @click="handleCancelReturn">取消退货</el-button>
</template>
<template v-else-if="order.status === 'REFUNDED'">
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
</template>
<template v-else-if="order.status === 'CANCELLED'">
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
</template>
@@ -95,6 +103,23 @@
</div>
</div>
<!-- 退货信息 -->
<div v-if="orderReturn && (order.status === 'REFUNDING' || order.status === 'REFUNDED')" class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">退货信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-500">退货单号</span><span>{{ orderReturn.returnNo }}</span></div>
<div><span class="text-gray-500">退货状态</span><el-tag :type="getReturnStatusType(orderReturn.status)" size="small">{{ orderReturn.statusText }}</el-tag></div>
<div><span class="text-gray-500">退款金额</span><span class="text-red-500 font-semibold">&yen;{{ orderReturn.refundAmount }}</span></div>
<div><span class="text-gray-500">退货原因</span><span>{{ orderReturn.reason }}</span></div>
<div v-if="orderReturn.description" class="md:col-span-2"><span class="text-gray-500">详细描述</span><span>{{ orderReturn.description }}</span></div>
<div v-if="orderReturn.rejectReason" class="md:col-span-2"><span class="text-gray-500">拒绝原因:</span><span class="text-red-500">{{ orderReturn.rejectReason }}</span></div>
<div v-if="orderReturn.returnTracking"><span class="text-gray-500">物流单号</span><span>{{ orderReturn.returnTracking }}</span></div>
<div v-if="orderReturn.adminRemark" class="md:col-span-2"><span class="text-gray-500">管理员备注</span><span>{{ orderReturn.adminRemark }}</span></div>
<div><span class="text-gray-500">申请时间</span><span>{{ formatTime(orderReturn.createdAt) }}</span></div>
<div v-if="orderReturn.completedAt"><span class="text-gray-500">完成时间</span><span>{{ formatTime(orderReturn.completedAt) }}</span></div>
</div>
</div>
<ReviewDialog
v-if="order"
v-model:visible="reviewDialogVisible"
@@ -102,6 +127,21 @@
:order-items="order.items"
@success="checkAllReviewed"
/>
<ReturnDialog
v-if="order"
v-model:visible="returnDialogVisible"
:order-id="order.id"
:refund-amount="order.paymentAmount"
@success="onReturnSuccess"
/>
<ReturnTrackingDialog
v-if="orderReturn"
v-model:visible="trackingDialogVisible"
:return-id="orderReturn.id"
@success="onReturnSuccess"
/>
</div>
</div>
</template>
@@ -117,6 +157,10 @@ import type { Order } from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import ReviewDialog from '@/components/business/ReviewDialog.vue'
import ReturnDialog from '@/components/business/ReturnDialog.vue'
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
import { returnApi } from '@/api/modules/return'
import type { OrderReturn } from '@/types/api'
const route = useRoute()
const router = useRouter()
@@ -126,10 +170,13 @@ const loading = ref(false)
const order = ref<Order | null>(null)
const reviewDialogVisible = ref(false)
const allReviewed = ref(false)
const returnDialogVisible = ref(false)
const trackingDialogVisible = ref(false)
const orderReturn = ref<OrderReturn | null>(null)
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDED: 'danger' }[status] || 'info')
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDED: '已退' }[status] || status)
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDING: 'warning', REFUNDED: 'danger' }[status] || 'info')
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退货中', REFUNDED: '已退' }[status] || status)
const getPaymentMethodText = (method: string) => ({ ONLINE: '在线支付', ALIPAY: '支付宝', WECHAT: '微信支付', CASH: '货到付款', default: '默认支付' }[method] || method)
const getActiveStep = () => {
@@ -192,6 +239,39 @@ const checkAllReviewed = async () => {
}
}
const getReturnStatusType = (status: string) => ({ PENDING: 'warning', APPROVED: 'primary', RETURNING: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info' }[status] || 'info')
const loadReturnInfo = async () => {
if (!order.value) return
if (order.value.status !== 'REFUNDING' && order.value.status !== 'REFUNDED') return
try {
const res = await returnApi.getByOrderId(order.value.id)
if (res.success && res.data) {
orderReturn.value = res.data
}
} catch (error) {
console.error('加载退货信息失败:', error)
}
}
const onReturnSuccess = () => {
loadOrderDetail()
loadReturnInfo()
}
const handleCancelReturn = async () => {
if (!orderReturn.value) return
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
try {
await returnApi.cancel(orderReturn.value.id)
ElMessage.success('退货申请已取消')
loadOrderDetail()
loadReturnInfo()
} catch (error) {
console.error('取消退货失败:', error)
}
}
const handleRebuy = async () => {
if (!order.value) return
const firstItem = order.value.items[0]
@@ -208,7 +288,7 @@ const handleDelete = async () => {
onMounted(async () => {
await loadOrderDetail()
await checkAllReviewed()
await Promise.all([checkAllReviewed(), loadReturnInfo()])
})
</script>

View File

@@ -25,6 +25,8 @@
<el-radio-button label="SHIPPED">待收货</el-radio-button>
<el-radio-button label="COMPLETED">已完成</el-radio-button>
<el-radio-button label="CANCELLED">已取消</el-radio-button>
<el-radio-button label="REFUNDING">退货中</el-radio-button>
<el-radio-button label="REFUNDED">已退货</el-radio-button>
</el-radio-group>
<el-input v-model="filters.keyword" placeholder="搜索订单号或商品名称" style="width: 250px" clearable @keyup.enter="loadOrders">
@@ -87,10 +89,19 @@
<template v-else-if="order.status === 'COMPLETED'">
<el-button v-if="orderReviewStatus[order.id]" size="small" @click="openReviewDialog(order)">查看评价</el-button>
<el-button v-else type="primary" size="small" @click="openReviewDialog(order)">评价</el-button>
<el-button type="warning" size="small" @click="openReturnDialog(order)">申请退货</el-button>
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
</template>
<template v-else-if="order.status === 'REFUNDING'">
<el-button type="primary" size="small" @click="handleViewDetail(order.id)">查看退货进度</el-button>
</template>
<template v-else-if="order.status === 'REFUNDED'">
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
</template>
<template v-else-if="order.status === 'CANCELLED'">
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
</template>
@@ -110,6 +121,14 @@
:order-items="currentReviewOrder.items"
@success="onReviewSuccess"
/>
<ReturnDialog
v-if="currentReturnOrder"
v-model:visible="returnDialogVisible"
:order-id="currentReturnOrder.id"
:refund-amount="currentReturnOrder.paymentAmount || currentReturnOrder.totalAmount"
@success="onReturnSuccess"
/>
</div>
</div>
</template>
@@ -125,6 +144,7 @@ import type { Order } from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import ReviewDialog from '@/components/business/ReviewDialog.vue'
import ReturnDialog from '@/components/business/ReturnDialog.vue'
const router = useRouter()
const cartStore = useCartStore()
@@ -137,6 +157,8 @@ const pagination = reactive({ page: 1, size: 10, total: 0 })
const reviewDialogVisible = ref(false)
const currentReviewOrder = ref<Order | null>(null)
const orderReviewStatus = ref<Record<number, boolean>>({})
const returnDialogVisible = ref(false)
const currentReturnOrder = ref<Order | null>(null)
const orderStats = ref([
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
@@ -149,8 +171,8 @@ const orderStats = ref([
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDED: 'danger' }[status] || 'info')
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDED: '已退' }[status] || status)
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDING: 'warning', REFUNDED: 'danger' }[status] || 'info')
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退货中', REFUNDED: '已退' }[status] || status)
const loadOrders = async () => {
loading.value = true
@@ -231,6 +253,16 @@ const onReviewSuccess = () => {
}
}
const openReturnDialog = (order: Order) => {
currentReturnOrder.value = order
returnDialogVisible.value = true
}
const onReturnSuccess = () => {
loadOrders()
loadStatistics()
}
const handleRebuy = async (order: Order) => {
const firstItem = order.items[0]
if (!firstItem) return

View File

@@ -0,0 +1,146 @@
<template>
<div class="user-returns-page">
<div class="container mx-auto px-4 py-8">
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>我的退货</el-breadcrumb-item>
</el-breadcrumb>
<h1 class="text-3xl font-bold mb-6">我的退货</h1>
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<el-radio-group v-model="filters.status" @change="loadReturns">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="1">待审核</el-radio-button>
<el-radio-button label="2">已同意</el-radio-button>
<el-radio-button label="3">退货中</el-radio-button>
<el-radio-button label="4">已完成</el-radio-button>
<el-radio-button label="5">已拒绝</el-radio-button>
</el-radio-group>
</div>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="returns.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无退货记录" />
</div>
<div v-else class="space-y-4">
<div v-for="item in returns" :key="item.id" class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="bg-gray-50 px-6 py-3 flex justify-between items-center">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>退货单号{{ item.returnNo }}</span>
<span>{{ formatTime(item.createdAt) }}</span>
</div>
<el-tag :type="getReturnStatusType(item.status)">{{ item.statusText }}</el-tag>
</div>
<div class="p-6">
<div class="flex gap-4">
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-20 h-20 rounded" img-class="w-20 h-20 object-cover rounded" />
<div class="flex-1">
<h4 class="font-semibold">{{ item.productName || '商品' }}</h4>
<div class="text-sm text-gray-500 mt-1">订单号{{ item.orderNo }}</div>
<div class="text-sm text-gray-500 mt-1">退货原因{{ item.reason }}</div>
<div v-if="item.rejectReason" class="text-sm text-red-500 mt-1">拒绝原因{{ item.rejectReason }}</div>
<div v-if="item.returnTracking" class="text-sm text-gray-500 mt-1">物流单号{{ item.returnTracking }}</div>
</div>
<div class="text-right">
<div class="text-sm text-gray-500">退款金额</div>
<div class="text-lg font-bold text-red-500">&yen;{{ item.refundAmount }}</div>
</div>
</div>
</div>
<div class="border-t px-6 py-4 flex justify-end gap-2">
<el-button text type="primary" @click="router.push(`/order/${item.orderId}`)">查看订单</el-button>
<el-button v-if="item.status === 'APPROVED'" type="primary" size="small" @click="openTrackingDialog(item)">填写物流</el-button>
<el-button v-if="item.status === 'PENDING' || item.status === 'APPROVED'" size="small" @click="handleCancel(item)">取消退货</el-button>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="mt-8 flex justify-center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadReturns" @current-change="loadReturns" />
</div>
<ReturnTrackingDialog
v-if="currentReturn"
v-model:visible="trackingDialogVisible"
:return-id="currentReturn.id"
@success="loadReturns"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { returnApi } from '@/api/modules/return'
import { normalizeOrderReturn } from '@/utils/normalizers'
import type { OrderReturn } from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
const router = useRouter()
const loading = ref(false)
const returns = ref<OrderReturn[]>([])
const filters = reactive({ status: '' })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const trackingDialogVisible = ref(false)
const currentReturn = ref<OrderReturn | null>(null)
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getReturnStatusType = (status: string) => ({ PENDING: 'warning', APPROVED: 'primary', RETURNING: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info' }[status] || 'info')
const loadReturns = async () => {
loading.value = true
try {
const res = await returnApi.getMyReturns({
status: filters.status ? Number(filters.status) : undefined,
page: pagination.page - 1,
size: pagination.size,
})
if (res.success) {
returns.value = (res.data.content || []).map((item: any) => normalizeOrderReturn(item))
pagination.total = res.data.totalElements || 0
}
} catch (error) {
console.error('加载退货列表失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const openTrackingDialog = (item: OrderReturn) => {
currentReturn.value = item
trackingDialogVisible.value = true
}
const handleCancel = async (item: OrderReturn) => {
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
try {
await returnApi.cancel(item.id)
ElMessage.success('退货申请已取消')
loadReturns()
} catch (error) {
console.error('取消退货失败:', error)
}
}
onMounted(() => { loadReturns() })
</script>
<style scoped lang="scss">
.user-returns-page {
min-height: calc(100vh - 60px);
background: transparent;
}
</style>

View File

@@ -86,6 +86,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/user/reviews.vue'),
meta: { title: '我的评价', requiresAuth: true }
},
{
path: 'returns',
name: 'MyReturns',
component: () => import('@/pages/user/returns.vue'),
meta: { title: '我的退货', requiresAuth: true }
},
{
path: 'notifications',
name: 'Notifications',
@@ -177,6 +183,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/admin/reviews.vue'),
meta: { title: '评价管理' }
},
{
path: 'returns',
name: 'AdminReturns',
component: () => import('@/pages/admin/returns.vue'),
meta: { title: '退货管理' }
},
{
path: 'favorites',
name: 'AdminFavorites',

View File

@@ -112,7 +112,7 @@ export interface Order {
totalAmount: number
paymentAmount: number
paymentMethod?: string
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDED'
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED'
items: OrderItem[]
address?: OrderAddress
remark?: string
@@ -157,6 +157,33 @@ export interface Statistics {
onlineUsers: number
}
// 退货类型
export interface OrderReturn {
id: number
returnNo: string
orderId: number
orderNo: string
userId: number
username: string
refundAmount: number
reason: string
description?: string
images?: string
status: 'PENDING' | 'APPROVED' | 'RETURNING' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
statusText: string
rejectReason?: string
adminRemark?: string
returnTracking?: string
productName?: string
productImage?: string
reviewedAt?: string
shippedAt?: string
completedAt?: string
cancelledAt?: string
createdAt: string
updatedAt: string
}
// 拼团活动类型
export interface GroupBuying {
id: number

View File

@@ -5,6 +5,7 @@ import type {
GroupBuyingGroup,
Order,
OrderAddress,
OrderReturn,
PageResponse,
Product,
User,
@@ -51,6 +52,8 @@ export const mapOrderStatus = (status: number | string): Order['status'] => {
if (value === 'SHIPPED' || value === 3) return 'SHIPPED'
if (value === 'COMPLETED' || value === 4) return 'COMPLETED'
if (value === 'CANCELLED' || value === 5) return 'CANCELLED'
if (value === 'REFUNDING' || value === 6) return 'REFUNDING'
if (value === 'REFUNDED' || value === 7) return 'REFUNDED'
return 'PENDING'
}
@@ -328,6 +331,43 @@ export const normalizeGroupBuying = (gb: Record<string, any>): GroupBuying => ({
discount: toNumber(gb.discount),
})
export const mapReturnStatus = (status: number | string): OrderReturn['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'PENDING' || value === 1) return 'PENDING'
if (value === 'APPROVED' || value === 2) return 'APPROVED'
if (value === 'RETURNING' || value === 3) return 'RETURNING'
if (value === 'COMPLETED' || value === 4) return 'COMPLETED'
if (value === 'REJECTED' || value === 5) return 'REJECTED'
if (value === 'CANCELLED' || value === 6) return 'CANCELLED'
return 'PENDING'
}
export const normalizeOrderReturn = (ret: Record<string, any>): OrderReturn => ({
id: toNumber(ret.id),
returnNo: toString(ret.returnNo),
orderId: toNumber(ret.orderId),
orderNo: toString(ret.orderNo),
userId: toNumber(ret.userId),
username: toString(ret.username),
refundAmount: toNumber(ret.refundAmount),
reason: toString(ret.reason),
description: toString(ret.description),
images: toString(ret.images),
status: mapReturnStatus(ret.status),
statusText: toString(ret.statusText),
rejectReason: toString(ret.rejectReason),
adminRemark: toString(ret.adminRemark),
returnTracking: toString(ret.returnTracking),
productName: toString(ret.productName),
productImage: resolveImageUrl(toString(ret.productImage, '')),
reviewedAt: ret.reviewedAt ? toIsoLikeString(ret.reviewedAt) : undefined,
shippedAt: ret.shippedAt ? toIsoLikeString(ret.shippedAt) : undefined,
completedAt: ret.completedAt ? toIsoLikeString(ret.completedAt) : undefined,
cancelledAt: ret.cancelledAt ? toIsoLikeString(ret.cancelledAt) : undefined,
createdAt: toIsoLikeString(ret.createdAt),
updatedAt: toIsoLikeString(ret.updatedAt || ret.createdAt),
})
export const normalizeGroupBuyingGroup = (group: Record<string, any>): GroupBuyingGroup => ({
id: toNumber(group.id),
groupNo: toString(group.groupNo),