| | |
| | | <script setup> |
| | | import { computed, onMounted, ref } from 'vue' |
| | | import { computed, onMounted, reactive, ref } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { ElMessage } from 'element-plus' |
| | | import { ArrowLeft } from '@element-plus/icons-vue' |
| | | import { getOrderDetail, refundOrder } from '@/api/orders' |
| | | import { orderStatusRank, orderTypeMap, refundableOrderStatuses } from '@/config/orderOptions' |
| | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const loading = ref(false) |
| | | const refunding = ref(false) |
| | | const refundDialogVisible = ref(false) |
| | | const refundSubmitting = ref(false) |
| | | const refundForm = reactive({ amount: '' }) |
| | | const order = ref(null) |
| | | |
| | | const paymentMethodMap = { WECHAT: '微信支付', ALIPAY: '支付宝' } |
| | |
| | | const displayValue = (value) => value === null || value === undefined || value === '' ? '-' : value |
| | | const formatDateTime = (value) => value ? value.replace('T', ' ').slice(0, 19) : '-' |
| | | const formatMoney = (value) => Number(value || 0).toFixed(2) |
| | | const getRefundableAmount = (value) => Number( |
| | | value?.refundableAmount |
| | | ?? (Number(value?.paymentAmount ?? value?.amount ?? 0) - Number(value?.refundedAmount ?? 0)), |
| | | ) |
| | | const canRefund = computed(() => order.value |
| | | && refundableOrderStatuses.includes(order.value.status) |
| | | && order.value.refundStatus !== 'REFUNDED') |
| | | const isRefunded = computed(() => order.value?.refundStatus === 'REFUNDED' || order.value?.status === 'REFUNDED') |
| | | && (refundableOrderStatuses.includes(order.value.status) |
| | | || (order.value.status === 'CANCELLED' && ['PARTIAL', 'REFUND_FAILED'].includes(order.value.refundStatus))) |
| | | && !['REFUNDING', 'REFUNDED'].includes(order.value.refundStatus) |
| | | && getRefundableAmount(order.value) > 0) |
| | | const refundStatusLabel = computed(() => ({ |
| | | REFUNDING: '\u9000\u6b3e\u5904\u7406\u4e2d', |
| | | PARTIAL: '\u90e8\u5206\u9000\u6b3e', |
| | | REFUNDED: '\u5df2\u9000\u6b3e', |
| | | REFUND_FAILED: '\u9000\u6b3e\u5931\u8d25', |
| | | }[order.value?.refundStatus] || (order.value?.status === 'REFUNDED' ? '\u5df2\u9000\u6b3e' : ''))) |
| | | const hasRefund = computed(() => order.value?.status === 'REFUNDED' |
| | | || (order.value?.refundStatus && order.value.refundStatus !== 'NONE') |
| | | || (Array.isArray(order.value?.refundRecords) && order.value.refundRecords.length > 0)) |
| | | const refundRecords = computed(() => { |
| | | const records = order.value?.refundRecords |
| | | if (Array.isArray(records) && records.length) return records |
| | | if (!hasRefund.value) return [] |
| | | return [{ |
| | | id: order.value.id, |
| | | refundAmount: order.value.refundAmount, |
| | | refundStatus: order.value.refundStatus || (order.value.status === 'REFUNDED' ? 'REFUNDED' : ''), |
| | | refundTime: order.value.refundTime, |
| | | createdAt: order.value.refundTime, |
| | | refundOrderNo: order.value.refundOrderNo, |
| | | refundMethod: order.value.refundMethod, |
| | | }] |
| | | }) |
| | | const refundRecordStatusLabel = (record) => ({ |
| | | REFUNDING: '退款处理中', |
| | | PARTIAL: '部分退款', |
| | | REFUNDED: '已退款', |
| | | REFUND_FAILED: '退款失败', |
| | | }[record?.refundStatus] || '-') |
| | | const packageName = computed(() => orderTypeMap[order.value?.orderType] || order.value?.packageName || '-') |
| | | const originalPrice = computed(() => order.value?.originalPrice ?? order.value?.unitPrice) |
| | | const discountPrice = computed(() => order.value?.discountPrice ?? order.value?.unitPrice) |
| | | const subtotalAmount = computed(() => order.value?.subtotalAmount ?? order.value?.amount) |
| | | const paymentAmount = computed(() => order.value?.paymentAmount ?? order.value?.amount) |
| | | const refundableAmount = computed(() => getRefundableAmount(order.value)) |
| | | const paymentAmount = computed(() => Number(order.value?.paymentAmount ?? order.value?.amount ?? 0)) |
| | | const arrivedAt = computed(() => order.value?.arrivedAt || order.value?.serviceStartedAt) |
| | | const statusSteps = computed(() => { |
| | | if (!order.value) return [] |
| | | const currentRank = orderStatusRank[order.value.status] ?? 0 |
| | |
| | | { title: '待支付', label: '下单时间', time: order.value.placedAt, active: true }, |
| | | { title: '待接单', label: '支付时间', time: order.value.paymentTime, active: currentRank >= 1 }, |
| | | { title: '前往汇合点', label: '接单时间', time: order.value.acceptedAt, active: currentRank >= 2 }, |
| | | { title: '到达汇合点', label: '到达时间', time: order.value.arrivedAt, active: currentRank >= 3 || Boolean(order.value.arrivedAt) }, |
| | | { title: '到达汇合点', label: '到达时间', time: arrivedAt.value, active: currentRank >= 3 || Boolean(arrivedAt.value) }, |
| | | { title: '讲解中', label: '讲解开始时间', time: order.value.serviceStartedAt, active: currentRank >= 4 }, |
| | | { title: '已完成-待评价', label: '讲解结束时间', time: order.value.completedAt, active: currentRank >= 5 }, |
| | | { title: '已完成-已评价', label: '评价时间', time: order.value.reviewedAt, active: Boolean(order.value.reviewId) }, |
| | |
| | | finally { loading.value = false } |
| | | } |
| | | |
| | | const refund = async () => { |
| | | const openRefundDialog = () => { |
| | | refundForm.amount = '' |
| | | refundDialogVisible.value = true |
| | | } |
| | | |
| | | const resetRefundDialog = () => { |
| | | refundForm.amount = '' |
| | | } |
| | | |
| | | const submitRefund = async () => { |
| | | const amountText = String(refundForm.amount || '').trim() |
| | | if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(amountText)) { |
| | | ElMessage.warning('请输入正确的退款金额,最多保留两位小数') |
| | | return |
| | | } |
| | | const amount = Number(amountText) |
| | | if (amount <= 0) { |
| | | ElMessage.warning('退款金额必须大于0') |
| | | return |
| | | } |
| | | if (amount > refundableAmount.value) { |
| | | ElMessage.warning('退款金额不能超过当前可退金额') |
| | | return |
| | | } |
| | | refundSubmitting.value = true |
| | | try { |
| | | await ElMessageBox.confirm(`确认对订单“${order.value.orderNo}”执行退款吗?`, '操作退款', { |
| | | type: 'warning', confirmButtonText: '确认退款', cancelButtonText: '取消', |
| | | }) |
| | | refunding.value = true |
| | | await refundOrder(order.value.id, paymentAmount.value.toFixed(2)) |
| | | ElMessage.success('退款成功') |
| | | const result = await refundOrder(order.value.id, amount.toFixed(2)) |
| | | if (result?.refundStatus === 'REFUNDED') { |
| | | ElMessage.success('退款成功') |
| | | } else { |
| | | ElMessage.info('退款申请已提交,等待支付渠道确认') |
| | | } |
| | | refundDialogVisible.value = false |
| | | await loadDetail() |
| | | } catch (error) { |
| | | if (error !== 'cancel' && error !== 'close') throw error |
| | | } finally { refunding.value = false } |
| | | } finally { |
| | | refundSubmitting.value = false |
| | | } |
| | | } |
| | | |
| | | const viewReview = () => router.push({ path: '/operations/reviews', query: { keyword: order.value.orderNo } }) |
| | |
| | | </el-timeline> |
| | | </section> |
| | | |
| | | <section v-if="isRefunded" class="detail-section"> |
| | | <section v-if="hasRefund" class="detail-section"> |
| | | <h3>退款信息</h3> |
| | | <div class="field-grid"> |
| | | <div class="field-item"><span>退款金额</span><div>{{ formatMoney(order.refundAmount) }}</div></div> |
| | | <div class="field-item"><span>退款时间</span><div>{{ formatDateTime(order.refundTime) }}</div></div> |
| | | <div class="field-item"><span>退款流水号</span><div>{{ displayValue(order.refundOrderNo) }}</div></div> |
| | | <div class="field-item"><span>退款方式</span><div>{{ paymentMethodMap[order.refundMethod] || order.refundMethod || '-' }}</div></div> |
| | | <div class="refund-summary"> |
| | | <div class="field-item"><span>当前退款状态</span><div>{{ refundStatusLabel }}</div></div> |
| | | <div class="field-item"><span>累计退款金额</span><div>{{ formatMoney(order.refundedAmount) }}</div></div> |
| | | </div> |
| | | <el-table :data="refundRecords" border class="refund-records-table" row-key="id"> |
| | | <el-table-column label="退款状态" min-width="110" align="center"> |
| | | <template #default="{ row }">{{ refundRecordStatusLabel(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="本次退款金额" min-width="120" align="center"> |
| | | <template #default="{ row }">{{ formatMoney(row.refundAmount) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="退款时间" min-width="170" align="center"> |
| | | <template #default="{ row }">{{ formatDateTime(row.refundTime || row.createdAt) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="退款流水号" min-width="220" align="center"> |
| | | <template #default="{ row }">{{ displayValue(row.refundOrderNo) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="退款方式" min-width="110" align="center"> |
| | | <template #default="{ row }">{{ paymentMethodMap[row.refundMethod] || row.refundMethod || '-' }}</template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </section> |
| | | |
| | | <footer class="detail-actions"> |
| | | <el-button v-if="canRefund" type="primary" :loading="refunding" @click="refund">操作退款</el-button> |
| | | <el-button v-if="canRefund" type="primary" :loading="refundSubmitting" @click="openRefundDialog">操作退款</el-button> |
| | | <el-button type="primary" :disabled="!order.reviewId" @click="viewReview">查看评价</el-button> |
| | | </footer> |
| | | </el-card> |
| | | |
| | | <el-dialog |
| | | v-model="refundDialogVisible" |
| | | class="refund-dialog" |
| | | title="退款" |
| | | width="520px" |
| | | :close-on-click-modal="false" |
| | | :close-on-press-escape="!refundSubmitting" |
| | | :show-close="!refundSubmitting" |
| | | @closed="resetRefundDialog" |
| | | > |
| | | <div v-if="order" class="refund-content"> |
| | | <div class="refund-info-grid"> |
| | | <span class="refund-label">订单编号</span> |
| | | <span>{{ order.orderNo || '-' }}</span> |
| | | <span class="refund-label">游客手机号</span> |
| | | <span>{{ order.visitorMobile || '-' }}</span> |
| | | |
| | | <span class="refund-label">订单金额</span> |
| | | <strong>¥{{ formatMoney(order.amount) }}</strong> |
| | | <span class="refund-label">实际支付</span> |
| | | <strong>¥{{ formatMoney(paymentAmount) }}</strong> |
| | | |
| | | <span class="refund-label">支付流水号</span> |
| | | <span class="refund-flow-no">{{ order.paymentOrderNo || '-' }}</span> |
| | | |
| | | <span class="refund-label">当前可退金额</span> |
| | | <strong>¥{{ formatMoney(refundableAmount) }}</strong> |
| | | </div> |
| | | |
| | | <div class="refund-amount-row"> |
| | | <span class="refund-label">退款金额</span> |
| | | <el-input |
| | | v-model="refundForm.amount" |
| | | inputmode="decimal" |
| | | maxlength="14" |
| | | placeholder="请输入" |
| | | @keyup.enter="submitRefund" |
| | | > |
| | | <template #append>元</template> |
| | | </el-input> |
| | | </div> |
| | | </div> |
| | | <template #footer> |
| | | <el-button type="primary" :loading="refundSubmitting" @click="submitRefund">确认退款</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </section> |
| | | </template> |
| | | |
| | |
| | | .status-timeline :deep(.el-timeline-item:last-child .status-row strong) { color: #1f2937; font-weight: 700; } |
| | | .detail-actions { display: flex; gap: 46px; padding: 30px 34px; border-top: 10px solid #f4f7fb; } |
| | | .detail-actions .el-button { width: 138px; } |
| | | .refund-content { padding: 4px 20px 10px; } |
| | | .refund-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 42px; margin-bottom: 22px; } |
| | | .refund-records-table :deep(th.el-table__cell) { color: #53657d; background: #f4f6fa; } |
| | | .refund-records-table :deep(.el-table__cell) { padding: 9px 0; } |
| | | .refund-info-grid { display: grid; grid-template-columns: 92px minmax(120px, 1fr) 92px minmax(120px, 1fr); gap: 22px 12px; align-items: center; } |
| | | .refund-label { color: #303133; font-weight: 600; } |
| | | .refund-flow-no { grid-column: span 3; overflow-wrap: anywhere; } |
| | | .refund-amount-row { display: grid; grid-template-columns: 92px 1fr; gap: 12px; align-items: center; margin-top: 28px; } |
| | | :global(.refund-dialog .el-dialog__header) { margin-right: 0; padding-bottom: 18px; text-align: center; border-bottom: 1px solid #ebeef5; } |
| | | :global(.refund-dialog .el-dialog__title) { color: #1f2937; font-size: 18px; font-weight: 700; } |
| | | :global(.refund-dialog .el-dialog__footer) { padding-top: 10px; text-align: center; } |
| | | :global(.refund-dialog .el-dialog__footer .el-button) { min-width: 160px; } |
| | | @media (max-width: 1320px) { |
| | | .field-grid { column-gap: 24px; } |
| | | .field-item { grid-template-columns: 82px minmax(0, 1fr); } |
| | | } |
| | | @media (max-width: 680px) { |
| | | .refund-summary { grid-template-columns: 1fr; row-gap: 12px; } |
| | | .refund-info-grid { grid-template-columns: 92px 1fr; } |
| | | .refund-flow-no { grid-column: auto; } |
| | | } |
| | | </style> |