退款审核功能:待交接取消不再自动退款,改为客服审核后原路退回

- 租客在待交接状态取消订单不再自动退款,改为 refund_status=pending_review
- 新增退款审核页面 /admin/orders/refund-review,客服可逐个审核
- 新增 POST /refund/approve 通过退款、POST /refund/reject 驳回退款
- 新增 GET /orders/refund-pending 查询待审核退款订单列表
- 优化状态标签表述:已取消→租客已取消、已关闭→客服已关闭、异常→客服介入
- 新增 refund_status: pending_review 及其前端标签「待客服审核退款」
- AdminOrderDetailView 退款状态改用统一 refundStatusLabel
This commit is contained in:
yml2213
2026-06-28 21:14:55 +08:00
parent c6c6a92645
commit c92cd03b8c
16 changed files with 492 additions and 35 deletions
@@ -28,6 +28,7 @@ import {
disputeStatusLabel,
handoffStatusLabel,
orderStatusLabel,
refundStatusLabel,
settlementStatusLabel,
} from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
@@ -293,16 +294,6 @@ async function confirmRefund() {
await handleRefund()
}
function refundStatusLabel(status: string) {
const map: Record<string, string> = {
pending: '待退款',
refunding: '退款中',
refunded: '已退款',
failed: '退款失败',
}
return map[status] || status || '未退款'
}
function paymentStatusLabel(status: string) {
const map: Record<string, string> = {
created: '已创建',
@@ -35,10 +35,10 @@ const orderStatusOptions = [
{ label: '待租客确认修正', value: 'pending_checkout_accept' },
{ label: '结账争议中', value: 'checkout_disputing' },
{ label: '申诉中', value: 'disputing' },
{ label: '异常', value: 'abnormal' },
{ label: '客服介入', value: 'abnormal' },
{ label: '已完成', value: 'completed' },
{ label: '已关闭', value: 'closed' },
{ label: '已取消', value: 'cancelled' },
{ label: '客服已关闭', value: 'closed' },
{ label: '租客已取消', value: 'cancelled' },
] as const
const handoffStatusOptions = [
@@ -60,8 +60,8 @@ const settlementStatusOptions = [
{ label: '冻结中', value: 'frozen' },
{ label: '已结算', value: 'settled' },
{ label: '已退款', value: 'refunded' },
{ label: '已取消', value: 'cancelled' },
{ label: '已关闭', value: 'closed' },
{ label: '结算已取消', value: 'cancelled' },
{ label: '结算已关闭', value: 'closed' },
{ label: '争议中', value: 'disputed' },
{ label: '已仲裁', value: 'arbitrated' },
] as const
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Close, Refresh, View } from '@element-plus/icons-vue'
import {
adminApproveRefund,
adminRejectRefund,
fetchPendingRefundOrders,
type Order,
} from '@/features/orders/api/orders'
import { formatDateTime } from '@/shared/utils/time'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { orderStatusLabel, handoffStatusLabel } from '@/shared/utils/statusLabels'
import { readError } from '@/shared/utils/error'
import { adminPath } from '@/shared/utils/adminPath'
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const orders = ref<Order[]>([])
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchPendingRefundOrders()
} catch (error) {
ElMessage.error(readError(error, '加载退款审核列表失败'))
} finally {
loading.value = false
}
}
async function handleApprove(row: Order) {
try {
await ElMessageBox.confirm(
`确认通过「${row.order_no}」的退款申请?通过后将原路退回 ${formatCentWithSymbol(row.refund_amount_cent || 0)}`,
'退款审核通过确认',
{ confirmButtonText: '通过退款', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await adminApproveRefund(row.id)
ElMessage.success(`退款审核通过,${row.order_no} 已发起退款`)
await loadOrders()
} catch (error) {
ElMessage.error(readError(error, '退款审核失败'))
} finally {
submitting.value = false
}
}
async function handleReject(row: Order) {
try {
await ElMessageBox.confirm(
`确认驳回「${row.order_no}」的退款申请?驳回后不再退款。`,
'退款审核驳回确认',
{ confirmButtonText: '驳回退款', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await adminRejectRefund(row.id)
ElMessage.success(`退款审核已驳回,${row.order_no} 不再退款`)
await loadOrders()
} catch (error) {
ElMessage.error(readError(error, '驳回退款失败'))
} finally {
submitting.value = false
}
}
function openDetail(order: Order) {
router.push(adminPath(`orders/${order.id}`))
}
onMounted(loadOrders)
</script>
<template>
<div class="refund-review-page">
<div class="page-toolbar">
<h2>退款审核</h2>
<el-button :icon="Refresh" :loading="loading" @click="loadOrders">刷新</el-button>
</div>
<div v-loading="loading" class="review-list">
<div v-if="!loading && orders.length === 0" class="empty-state">
<el-empty description="暂无待审核退款" />
</div>
<div class="review-cards">
<div
v-for="order in orders"
:key="order.id"
class="review-card"
>
<div class="card-header">
<div class="order-info">
<span class="order-no" @click="openDetail(order)">{{ order.order_no }}</span>
<el-tag size="small" type="warning">待客服审核退款</el-tag>
</div>
<div class="order-time">{{ formatDateTime(order.updated_at) }}</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">商品</span>
<span class="info-value">{{ order.title || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">订单状态</span>
<span class="info-value">{{ orderStatusLabel(order.status) }}</span>
</div>
<div class="info-row">
<span class="info-label">交接状态</span>
<span class="info-value">{{ handoffStatusLabel(order.handoff_status) }}</span>
</div>
<div class="info-row">
<span class="info-label">号主</span>
<span class="info-value">{{ order.owner_phone || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">租客</span>
<span class="info-value">{{ order.renter_phone || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">订单总额</span>
<span class="info-value"
>{{ formatCentWithSymbol((order.rent_amount_cent || 0) + order.deposit_amount_cent) }}租金{{
formatCentWithSymbol(order.rent_amount_cent || 0)
}}
/ 押金{{ formatCentWithSymbol(order.deposit_amount_cent) }}</span
>
</div>
<div class="info-row">
<span class="info-label">退款金额</span>
<span class="info-value refund-amount">{{ formatCentWithSymbol(order.refund_amount_cent || 0) }}</span>
</div>
</div>
<div class="card-actions">
<el-button
:icon="View"
size="small"
@click="openDetail(order)"
>
查看详情
</el-button>
<el-button
:icon="Check"
type="primary"
size="small"
:loading="submitting"
@click="handleApprove(order)"
>
通过退款
</el-button>
<el-button
:icon="Close"
type="danger"
size="small"
:loading="submitting"
@click="handleReject(order)"
>
驳回退款
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.refund-review-page {
max-width: 900px;
margin: 0 auto;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-toolbar h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.empty-state {
padding: 60px 0;
}
.review-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.review-card {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 16px;
background: var(--el-bg-color);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.order-info {
display: flex;
align-items: center;
gap: 8px;
}
.order-no {
font-weight: 600;
color: var(--el-color-primary);
cursor: pointer;
}
.order-no:hover {
text-decoration: underline;
}
.order-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.card-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 24px;
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.info-label {
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.info-value {
color: var(--el-text-color-primary);
}
.refund-amount {
font-weight: 600;
color: var(--el-color-danger);
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -34,6 +34,8 @@ export interface Order {
status: OrderStatus
handoff_status: HandoffStatus
settlement_status: SettlementStatus
refund_status?: string
refund_amount_cent?: number
active_dispute?: ActiveDispute
checkout?: Checkout
admin_actions?: AdminActions
@@ -364,3 +366,24 @@ export async function adminRefundOrder(id: number) {
const { data } = await apiClient.post<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund`)
return data.data
}
export async function adminApproveRefund(id: number) {
const { data } = await apiClient.post<ApiResponse<{ approved: boolean }>>(
`/admin/orders/${id}/refund/approve`
)
return data.data
}
export async function adminRejectRefund(id: number) {
const { data } = await apiClient.post<ApiResponse<{ rejected: boolean }>>(
`/admin/orders/${id}/refund/reject`
)
return data.data
}
export async function fetchPendingRefundOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>(
'/admin/orders/refund-pending'
)
return data.data.items
}