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

361 lines
12 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 { readError } from '@/shared/utils/error'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
adminCloseOrder,
adminMarkOrderAbnormal,
adminRefundOrder,
adminRefundStatus,
adminResetHandoff,
fetchAdminHandoffRecords,
fetchAdminOrder,
type HandoffRecord,
type Order,
type RefundStatus,
} from '@/features/orders'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { adminPath } from '@/shared/utils/adminPath'
import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const paymentRecords = ref<AdminPayment[]>([])
const actionType = ref<'close' | 'abnormal' | 'reset' | ''>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null)
const listingCode = computed(() =>
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
)
const returnTarget = computed(() => {
const from = firstQueryValue(route.query.from)
const chatID = firstQueryValue(route.query.chat_id)
const chatFilter = firstQueryValue(route.query.chat_filter)
if (from === 'chat' && chatID) {
return {
path: adminPath('chats'),
query: {
chat_id: chatID,
...(chatFilter ? { chat_filter: chatFilter } : {}),
},
}
}
return adminPath('orders')
})
const returnLabel = computed(() =>
firstQueryValue(route.query.from) === 'chat' && firstQueryValue(route.query.chat_id)
? '返回会话'
: '返回列表'
)
const refunding = ref(false)
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
const actionTitle = computed(() => {
if (actionType.value === 'close') return '客服关闭订单'
if (actionType.value === 'reset') return '重置交接(给号主再次机会)'
return '标记订单异常'
})
const canOperate = computed(
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
)
const canResetHandoff = computed(
() =>
!!order.value &&
order.value.status === 'pending_handoff' &&
order.value.handoff_status === 'owner_timeout'
)
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items
await loadRefundStatus()
} finally {
loading.value = false
}
}
async function loadRefundStatus() {
try {
refundStatus.value = await adminRefundStatus(Number(route.params.id))
} catch {
refundStatus.value = null
}
}
function openAction(type: 'close' | 'abnormal' | 'reset') {
actionType.value = type
reason.value = ''
}
async function submitAction() {
if (!order.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭')
} else if (actionType.value === 'reset') {
await adminResetHandoff(order.value.id, reason.value)
ElMessage.success('交接已重置,等待号主重新提交')
} else {
await adminMarkOrderAbnormal(order.value.id, reason.value)
ElMessage.success('订单已标记异常')
}
actionType.value = ''
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function orderRentedAt() {
return order.value?.rented_at
}
function orderEstimatedEndAt() {
if (!order.value) return undefined
if (order.value.estimated_end_at) return order.value.estimated_end_at
const rentedAt = orderRentedAt()
const durationHours = Number(order.value.estimated_duration_hours || 0)
if (!rentedAt || durationHours <= 0) return undefined
return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString()
}
function money(value: unknown) {
return formatMoney(Number(value || 0))
}
function amountYuan(cent: unknown) {
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
return 0
}
async function handleRefund() {
if (!order.value) return
refunding.value = true
try {
refundStatus.value = await adminRefundOrder(order.value.id)
ElMessage.success('退款已发起')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '退款失败'))
} finally {
refunding.value = false
}
}
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: '已创建',
paying: '支付中',
paid: '已支付',
refunding: '退款中',
refunded: '已退款',
failed: '失败',
}
return map[status] || status
}
function paymentStatusType(status: string) {
if (['paid', 'refunded'].includes(status)) return 'success'
if (status === 'failed') return 'danger'
if (['paying', 'refunding'].includes(status)) return 'warning'
return 'info'
}
function paymentBizTypeLabel(type: string) {
const map: Record<string, string> = {
order_pay: '订单支付',
checkout_refund: '结账退款',
arbitration_refund: '仲裁退款',
admin_refund: '人工退款',
cancel_refund: '取消退款',
admin_close_refund: '客服关闭退款',
}
return map[type] || type
}
function moneyCent(value: number) {
return formatCentWithSymbol(value)
}
function firstQueryValue(value: unknown) {
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
return typeof value === 'string' ? value : ''
}
function formatHandoffRecordType(type: string) {
const typeMap: Record<string, string> = {
owner_handoff: '卖家交接',
renter_checkout: '买家结账',
owner_counter_checkout: '卖家反驳结账',
renter_confirm_checkout: '买家确认结账',
owner_accept_checkout: '卖家接受结账',
admin_arbitration: '客服仲裁',
}
return typeMap[type] || type
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="order" class="page-header-row">
<div class="page-header">
<p class="eyebrow">{{ order.order_no }}</p>
<h1>订单详情</h1>
<p>
商品编号 {{ listingCode }} · {{ order.title }} · {{ order.server_region }} /
{{ order.login_platform }}
</p>
</div>
<div class="toolbar-actions">
<RouterLink :to="returnTarget">
<el-button>{{ returnLabel }}</el-button>
</RouterLink>
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')"
>重置交接</el-button
>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')"
>客服关闭</el-button
>
<el-button
type="primary"
:loading="refunding"
:disabled="refundStatus?.refund_status === 'refunded'"
@click="handleRefund"
>
{{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
</el-button>
</div>
</div>
<div v-if="order" class="metric-grid dashboard-metrics">
<div class="metric-card">
<span>订单状态</span>
<strong>{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span>交接状态</span>
<strong>{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card">
<span>订单金额</span>
<strong>¥{{ money(amountYuan(order.rent_amount_cent)) }}</strong>
</div>
<div class="metric-card">
<span>平台费用</span>
<strong>¥{{ money(amountYuan(order.platform_fee_cent)) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>¥{{ money(amountYuan(order.deposit_amount_cent)) }}</strong>
</div>
<div v-if="refundStatus" class="metric-card">
<span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
<small v-if="refundStatus.refund_amount_cent > 0">{{
moneyCent(refundStatus.refund_amount_cent)
}}</small>
</div>
</div>
<div v-if="order" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>用户信息</h2>
<p>商品编号{{ listingCode }}</p>
<p>租客{{ order.renter_phone || order.renter_id }}</p>
<p>号主{{ order.owner_phone || order.owner_id }}</p>
<p>开始{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
<p>预计截止{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
</div>
<div class="order-panel dashboard-panel">
<h2>支付与退款</h2>
<el-empty v-if="paymentRecords.length === 0" description="暂无支付流水" />
<div v-for="record in paymentRecords" :key="record.id" class="timeline-item">
<strong>
{{ paymentBizTypeLabel(record.biz_type) }} · {{ moneyCent(record.amount_cent) }}
</strong>
<p>
{{ record.provider || '-' }} · {{ record.third_order_id }}
<span v-if="record.provider_order_id"> / {{ record.provider_order_id }}</span>
</p>
<p v-if="record.error_message" class="danger-text">
{{ record.error_code }} {{ record.error_message }}
</p>
<el-tag :type="paymentStatusType(record.status)">
{{ paymentStatusLabel(record.status) }}
</el-tag>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
<div class="order-panel dashboard-panel">
<h2>交接记录</h2>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-for="record in handoffRecords" :key="record.id" class="timeline-item">
<strong>{{ formatHandoffRecordType(record.type) }}</strong>
<p>{{ record.content }}</p>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
</div>
<div v-if="order" class="order-panel dashboard-panel code-panel">
<h2>账号快照</h2>
<pre>{{ snapshotText }}</pre>
</div>
<el-dialog
:model-value="!!actionType"
:title="actionTitle"
width="560px"
@update:model-value="actionType = ''"
>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写客服操作原因,会写入审计日志并通知双方"
/>
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
</section>
</template>