361 lines
12 KiB
Vue
361 lines
12 KiB
Vue
<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>
|