Files
hfb_sys/frontend/src/features/admin/views/AdminRefundReviewView.vue
T

762 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Close, Money, Refresh, ShoppingBag, View, Warning } 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 rejectVisible = ref(false)
const rejectOrder = ref<Order | null>(null)
const orders = ref<Order[]>([])
const totalRefundAmountCent = computed(() =>
orders.value.reduce((sum, item) => sum + Number(item.refund_amount_cent || 0), 0)
)
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
}
}
function openReject(row: Order) {
rejectOrder.value = row
rejectVisible.value = true
}
async function handleRejectRestore() {
if (!rejectOrder.value) return
const row = rejectOrder.value
try {
await ElMessageBox.confirm(
`确认仅驳回「${row.order_no}」的退款申请?驳回后订单将恢复至待交接状态,可继续流转。`,
'驳回退款确认',
{ confirmButtonText: '驳回退款(恢复订单)', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await adminRejectRefund(row.id, 'restore')
ElMessage.success(`退款已驳回,${row.order_no} 已恢复至待交接状态`)
rejectVisible.value = false
await loadOrders()
} catch (error) {
ElMessage.error(readError(error, '驳回退款失败'))
} finally {
submitting.value = false
}
}
async function handleRejectClose() {
if (!rejectOrder.value) return
const row = rejectOrder.value
try {
await ElMessageBox.confirm(
`确认驳回「${row.order_no}」退款并关闭订单?关闭后商品将下架归档,订单不可再操作。`,
'驳回退款并关闭订单确认',
{ confirmButtonText: '驳回退款并关闭订单', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await adminRejectRefund(row.id, 'close')
ElMessage.success(`退款已驳回,${row.order_no} 已关闭`)
rejectVisible.value = false
await loadOrders()
} catch (error) {
ElMessage.error(readError(error, '驳回退款并关闭失败'))
} finally {
submitting.value = false
}
}
function openDetail(order: Order) {
void router.push(adminPath(`orders/${order.id}`))
}
onMounted(loadOrders)
</script>
<template>
<div class="finance-page-container">
<!-- 头部卡片 -->
<header class="page-header-card">
<div class="header-main">
<div class="title-group">
<div class="eyebrow-tag">
<el-icon><Money /></el-icon>
<span>REFUND AUDIT</span>
</div>
<h1 class="page-title">退款审核</h1>
<p class="page-subtitle">
审核租客取消订单发起的退款申请核验订单流转状态并执行原路退回或驳回处理
</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadOrders">刷新数据</el-button>
</div>
</div>
</header>
<!-- 汇总指标卡片 -->
<section class="summary-metric-grid">
<div class="metric-card metric-card--pending">
<div class="metric-icon-wrap amber">
<el-icon><Warning /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">待审核退款申请</span>
<strong class="metric-val amber-val">{{ orders.length }} </strong>
<span class="metric-sub-pill warning">等待人工审核</span>
</div>
</div>
<div class="metric-card metric-card--amount">
<div class="metric-icon-wrap rose">
<el-icon><Money /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">待退款总金额</span>
<strong class="metric-val rose-val">{{
formatCentWithSymbol(totalRefundAmountCent)
}}</strong>
<span class="metric-sub-pill danger">待原路退回资金</span>
</div>
</div>
</section>
<!-- 退款列表卡片 -->
<section class="section-card review-list-card">
<div class="table-card-header">
<div class="table-header-title">
<el-icon class="header-icon"><ShoppingBag /></el-icon>
<span>待审核退款列表</span>
</div>
<span class="count-badge"> {{ orders.length }} 笔待办</span>
</div>
<div v-loading="loading" class="review-items-wrap">
<div v-if="!loading && orders.length === 0" class="empty-box">
<el-empty description="暂无待审核的退款申请,运营秩序良好" />
</div>
<div v-else class="review-cards-grid">
<div v-for="order in orders" :key="order.id" class="review-card">
<div class="rc-header">
<div class="rc-order-info">
<span class="order-no" @click="openDetail(order)">{{ order.order_no }}</span>
<el-tag size="small" type="warning" class="status-tag">待客服审核退款</el-tag>
</div>
<span class="time-text">{{ formatDateTime(order.updated_at) }}</span>
</div>
<div class="rc-body">
<div class="rc-info-row">
<span class="label">商品标题</span>
<span class="val title-val">{{ order.title || '-' }}</span>
</div>
<div class="rc-info-grid">
<div class="rc-info-row">
<span class="label">租客信息</span>
<span class="val font-mono">{{ order.renter_phone || '-' }}</span>
</div>
<div class="rc-info-row">
<span class="label">号主信息</span>
<span class="val font-mono">{{ order.owner_phone || '-' }}</span>
</div>
<div class="rc-info-row">
<span class="label">订单状态</span>
<span class="val">{{ orderStatusLabel(order.status) }}</span>
</div>
<div class="rc-info-row">
<span class="label">交接状态</span>
<span class="val">{{ handoffStatusLabel(order.handoff_status) }}</span>
</div>
</div>
<div class="rc-amount-row">
<div class="amount-detail">
<span class="label">订单总付</span>
<span class="val font-mono">
{{
formatCentWithSymbol(
(order.rent_amount_cent || 0) + order.deposit_amount_cent
)
}}
<small
>租金 {{ formatCentWithSymbol(order.rent_amount_cent || 0) }} + 押金
{{ formatCentWithSymbol(order.deposit_amount_cent) }}</small
>
</span>
</div>
<div class="refund-badge">
<span class="refund-label">申请退款</span>
<strong class="refund-val font-mono">{{
formatCentWithSymbol(order.refund_amount_cent || 0)
}}</strong>
</div>
</div>
</div>
<div class="rc-footer">
<el-button :icon="View" size="small" @click="openDetail(order)"> 查看订单 </el-button>
<div class="action-btn-group">
<el-button
:icon="Close"
type="danger"
size="small"
plain
:loading="submitting"
@click="openReject(order)"
>
驳回退款
</el-button>
<el-button
:icon="Check"
type="primary"
size="small"
:loading="submitting"
@click="handleApprove(order)"
>
通过退款
</el-button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 驳回退款弹窗 -->
<el-dialog v-model="rejectVisible" title="驳回退款申请" width="480px" destroy-on-close>
<div v-if="rejectOrder" class="reject-dialog-body">
<div class="reject-order-badge">
<span>待处理订单</span>
<strong class="font-mono">{{ rejectOrder.order_no }}</strong>
</div>
<p class="reject-tip-text">请根据争议核实情况选择驳回模式</p>
<div class="reject-mode-list">
<div class="reject-mode-card" @click="handleRejectRestore">
<div class="mode-header">
<el-button type="primary" size="small" :loading="submitting">
仅驳回退款恢复订单
</el-button>
</div>
<p class="mode-desc">
仅驳回退款请求订单恢复至待交接状态号主与租客可继续完成交接履约
</p>
</div>
<div class="reject-mode-card" @click="handleRejectClose">
<div class="mode-header">
<el-button type="danger" size="small" :loading="submitting">
驳回退款并关闭订单
</el-button>
</div>
<p class="mode-desc">驳回退款请求并强制关闭订单商品将下架归档后续不可再流转</p>
</div>
</div>
</div>
<template #footer>
<div class="modal-footer">
<el-button @click="rejectVisible = false">取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.finance-page-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
}
/* ================= 头部卡片 ================= */
.page-header-card {
background: #ffffff;
border-radius: 16px;
padding: 24px 28px;
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
border: 1px solid rgba(226, 232, 240, 0.8);
}
.header-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.eyebrow-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: #ea580c;
background: #fff7ed;
padding: 3px 10px;
border-radius: 20px;
width: fit-content;
}
.page-title {
margin: 0;
font-size: 26px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 0;
font-size: 13.5px;
color: #64748b;
line-height: 1.5;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* ================= 核心指标卡片 ================= */
.summary-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.metric-card {
background: #ffffff;
border-radius: 16px;
padding: 18px 20px;
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
display: flex;
align-items: center;
gap: 16px;
position: relative;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
}
.metric-card--pending::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.metric-card--amount::before {
background: linear-gradient(90deg, #f43f5e, #fb7185);
}
.metric-icon-wrap {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.metric-icon-wrap.amber {
background: #fffbeb;
color: #f59e0b;
}
.metric-icon-wrap.rose {
background: #fff1f2;
color: #f43f5e;
}
.metric-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label {
font-size: 12.5px;
font-weight: 500;
color: #64748b;
}
.metric-val {
font-size: 24px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.3px;
line-height: 1.2;
}
.amber-val {
color: #d97706;
}
.rose-val {
color: #e11d48;
}
.metric-sub-pill {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
width: fit-content;
}
.metric-sub-pill.warning {
background: #fffbeb;
color: #d97706;
}
.metric-sub-pill.danger {
background: #fff1f2;
color: #e11d48;
}
/* ================= 列表卡片 ================= */
.section-card {
background: #ffffff;
border-radius: 16px;
padding: 22px 24px;
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
}
.table-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.table-header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.header-icon {
color: #ea580c;
font-size: 18px;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #ea580c;
background: #fff7ed;
padding: 3px 10px;
border-radius: 20px;
}
.empty-box {
padding: 40px 0;
}
.review-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}
.review-card {
background: #f8fafc;
border-radius: 14px;
border: 1px solid #e2e8f0;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
transition: all 0.2s ease;
}
.review-card:hover {
background: #ffffff;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
border-color: #cbd5e1;
}
.rc-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.rc-order-info {
display: flex;
align-items: center;
gap: 8px;
}
.order-no {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
font-weight: 700;
color: #2563eb;
cursor: pointer;
}
.order-no:hover {
text-decoration: underline;
}
.status-tag {
font-weight: 600;
}
.time-text {
font-size: 11.5px;
color: #94a3b8;
}
.rc-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.rc-info-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12.5px;
}
.rc-info-row .label {
color: #64748b;
min-width: 60px;
flex-shrink: 0;
}
.rc-info-row .val {
color: #1e293b;
font-weight: 500;
}
.title-val {
font-weight: 600 !important;
color: #0f172a !important;
}
.rc-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
background: rgba(255, 255, 255, 0.8);
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #edf2f7;
}
.rc-amount-row {
margin-top: 4px;
padding: 10px 12px;
background: #fff1f2;
border-radius: 8px;
border: 1px solid #fecdd3;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.amount-detail .label {
font-size: 11.5px;
color: #64748b;
}
.amount-detail .val {
font-size: 12px;
color: #334155;
}
.amount-detail small {
color: #94a3b8;
}
.refund-badge {
display: flex;
align-items: baseline;
gap: 6px;
}
.refund-label {
font-size: 12px;
font-weight: 600;
color: #e11d48;
}
.refund-val {
font-size: 18px;
font-weight: 800;
color: #e11d48;
}
.rc-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
}
.action-btn-group {
display: flex;
align-items: center;
gap: 8px;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* ================= 弹窗样式 ================= */
.reject-dialog-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.reject-order-badge {
background: #f8fafc;
border-radius: 8px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
font-size: 13px;
color: #334155;
}
.reject-tip-text {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.reject-mode-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.reject-mode-card {
padding: 12px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #f8fafc;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.reject-mode-card:hover {
background: #ffffff;
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
}
.mode-desc {
margin: 0;
font-size: 11.5px;
color: #64748b;
line-height: 1.4;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>