退款审核功能:待交接取消不再自动退款,改为客服审核后原路退回
- 租客在待交接状态取消订单不再自动退款,改为 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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ const allNavGroups: NavGroup[] = [
|
||||
children: [
|
||||
{ label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' },
|
||||
{ label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' },
|
||||
{
|
||||
label: '退款审核',
|
||||
to: adminPath('orders/refund-review'),
|
||||
icon: Money,
|
||||
permission: 'order:close',
|
||||
},
|
||||
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
|
||||
{
|
||||
label: '商品审核',
|
||||
|
||||
@@ -30,6 +30,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/admin/views/AdminOrdersView.vue'),
|
||||
meta: adminMeta,
|
||||
},
|
||||
{
|
||||
path: adminPath('orders/refund-review'),
|
||||
name: 'admin-refund-review',
|
||||
component: () => import('@/features/admin/views/AdminRefundReviewView.vue'),
|
||||
meta: adminMeta,
|
||||
},
|
||||
{
|
||||
path: adminPath('orders/:id'),
|
||||
name: 'admin-order-detail',
|
||||
|
||||
@@ -40,29 +40,29 @@ const orderStatusMap: Record<OrderStatus, string> = {
|
||||
pending_checkout_accept: '待租客确认修正',
|
||||
checkout_disputing: '结账争议中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
closed: '已关闭',
|
||||
cancelled: '租客已取消',
|
||||
closed: '客服已关闭',
|
||||
disputing: '申诉中',
|
||||
abnormal: '异常',
|
||||
abnormal: '客服介入',
|
||||
}
|
||||
|
||||
const handoffStatusMap: Record<HandoffStatus, string> = {
|
||||
pending_owner: '待号主交接',
|
||||
pending_renter_confirm: '待租客确认',
|
||||
received: '已确认收号',
|
||||
received: '租客已收号',
|
||||
pending_owner_return_confirm: '待号主确认结账',
|
||||
pending_owner_checkout: '待号主确认结账',
|
||||
pending_renter_checkout: '待租客确认修正',
|
||||
checkout_disputed: '结账争议中',
|
||||
returned: '已归还',
|
||||
cancelled: '已取消',
|
||||
cancelled: '租客已取消',
|
||||
owner_timeout: '号主交接超时',
|
||||
renter_confirm_timeout: '租客确认超时',
|
||||
return_overdue: '归还逾期',
|
||||
owner_return_confirm_timeout: '号主确认结账超时',
|
||||
owner_checkout_confirm_timeout: '号主确认结账超时',
|
||||
admin_closed: '客服关闭',
|
||||
admin_abnormal: '客服标记异常',
|
||||
admin_closed: '客服已关闭',
|
||||
admin_abnormal: '客服介入',
|
||||
arbitrated: '已仲裁',
|
||||
}
|
||||
|
||||
@@ -72,12 +72,21 @@ const settlementStatusMap: Record<SettlementStatus, string> = {
|
||||
frozen: '冻结中',
|
||||
settled: '已结算',
|
||||
refunded: '已退款',
|
||||
cancelled: '已取消',
|
||||
closed: '已关闭',
|
||||
cancelled: '结算已取消',
|
||||
closed: '结算已关闭',
|
||||
disputed: '争议中',
|
||||
arbitrated: '已仲裁',
|
||||
}
|
||||
|
||||
const refundStatusMap: Record<string, string> = {
|
||||
none: '未退款',
|
||||
pending_review: '待客服审核退款',
|
||||
pending: '待退款',
|
||||
refunding: '退款中',
|
||||
refunded: '已退款',
|
||||
failed: '退款失败',
|
||||
}
|
||||
|
||||
const realnameStatusMap: Partial<Record<RealnameStatusValue, string>> = {
|
||||
unverified: '未认证',
|
||||
pending: '认证中',
|
||||
@@ -148,6 +157,10 @@ export function settlementStatusLabel(status: string) {
|
||||
return readLabel(settlementStatusMap, status)
|
||||
}
|
||||
|
||||
export function refundStatusLabel(status: string) {
|
||||
return readLabel(refundStatusMap, status)
|
||||
}
|
||||
|
||||
export function realnameStatusLabel(status: string) {
|
||||
return readLabel(realnameStatusMap, status)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user