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

1116 lines
34 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, ElMessageBox } 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 {
getSnapshotHafCoinM,
readSnapshot,
readSnapshotResources,
} from '@/features/orders/composables/useOrderSnapshot'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import {
disputeStatusLabel,
handoffStatusLabel,
orderStatusLabel,
settlementStatusLabel,
} 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)
type FundSplitRow = {
label: string
amountCent: number | null | undefined
}
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)
const chatStage = firstQueryValue(route.query.chat_stage)
const chatKeyword = firstQueryValue(route.query.chat_keyword)
if (from === 'chat' && chatID) {
return {
path: adminPath('chats'),
query: {
chat_id: chatID,
...(chatFilter ? { chat_filter: chatFilter } : {}),
...(chatStage ? { chat_stage: chatStage } : {}),
...(chatKeyword ? { chat_keyword: chatKeyword } : {}),
},
}
}
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 canOperate = computed(
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
)
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
const resetActionLabel = computed(() => {
return resetAction.value?.label || '重置'
})
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
const actionTitle = computed(() => {
if (actionType.value === 'close') return '客服关闭订单'
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
return '标记订单异常'
})
const closeActionTip = '关闭订单并归档商品/账号;已支付订单会按租金+实付押金全量原路退款。'
const refundActionTip = '仅发起后台人工全量原路退款,不关闭订单或调整商品/账号状态。'
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
const orderTotalCent = computed(
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
)
const latestPayment = computed(() => {
return [...paymentRecords.value].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)[0]
})
const paidOrderPayment = computed(() =>
paymentRecords.value.find(item => item.biz_type === 'order_pay' && item.status === 'paid')
)
const currentSnapshot = computed(() => readSnapshot(order.value))
const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(order.value))
const snapshotResources = computed(() => readSnapshotResources(order.value).slice(0, 6))
const assetSummary = computed(() => {
const snapshot = order.value?.account_snapshot
if (typeof snapshot !== 'object' || snapshot === null) return null
const summary = (snapshot as Record<string, unknown>).asset_summary
if (typeof summary !== 'object' || summary === null) return null
return summary as Record<string, unknown>
})
const snapshotPriceBreakdown = computed(() => {
const breakdown = assetSummary.value?.price_breakdown
if (typeof breakdown !== 'object' || breakdown === null) return null
return breakdown as Record<string, unknown>
})
const snapshotRatio = computed(() => {
const row = snapshotPriceBreakdown.value
if (!row) return null
const sellerRatio = Number(row.seller_ratio || 0)
const buyerRatio = Number(row.buyer_ratio || 0)
const publishRatio = Number(assetSummary.value?.publish_ratio || 0)
return {
recycleRatio: sellerRatio > 0 ? sellerRatio : publishRatio > 0 ? publishRatio : 0,
sellRatio: buyerRatio > 0 ? buyerRatio : 0,
}
})
const ownerCoinBasePriceCent = computed(() => readPriceBreakdownCent('seller_coin_base_price'))
const ownerLossPriceCent = computed(() => {
const consumableCent = readPriceBreakdownCent('consumable_price')
if (consumableCent !== null) return consumableCent
if (ownerCoinBasePriceCent.value === null || !order.value?.owner_rent_amount_cent) return null
return Math.max(Number(order.value.owner_rent_amount_cent) - ownerCoinBasePriceCent.value, 0)
})
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
const fundSplitRows = computed(() => {
if (!order.value) return []
const rows: FundSplitRow[] = [
{ label: '租客租金', amountCent: order.value.rent_amount_cent },
]
if (hasOwnerRentBreakdown.value) {
rows.push(
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
{ label: '号主损耗', amountCent: ownerLossPriceCent.value ?? 0 },
{ label: '号主租金合计', amountCent: order.value.owner_rent_amount_cent }
)
} else {
rows.push({ label: '号主租金', amountCent: order.value.owner_rent_amount_cent })
}
rows.push(
{ label: '平台费用', amountCent: order.value.platform_fee_cent },
{ label: '实付押金', amountCent: order.value.deposit_amount_cent },
{ label: '原始押金', amountCent: order.value.deposit_original_amount_cent },
{ label: '免押额度', amountCent: order.value.deposit_waived_amount_cent }
)
return rows
})
const snapshotSeasonTags = computed(() => {
const tags = currentSnapshot.value?.season_tags
return Array.isArray(tags) ? tags.map(item => String(item)).filter(Boolean) : []
})
const accountSnapshotItems = computed(() => {
const snapshot = currentSnapshot.value
if (!snapshot) return []
return [
{ label: '账号ID', value: displayValue(snapshot.account_id) },
{ label: '账号标题', value: displayValue(snapshot.title) },
{ label: '游戏名称', value: displayValue(snapshot.game_name) },
{ label: '所在区服', value: displayValue(snapshot.server_region) },
{ label: '登录平台', value: displayValue(snapshot.login_platform) },
{ label: '段位等级', value: displayValue(snapshot.rank_level) },
].filter(item => item.value !== '-')
})
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 confirmCloseAction() {
if (!order.value || !canOperate.value) return
try {
await ElMessageBox.confirm(
'客服关闭会终止订单,归档关联商品和账号;若订单已支付,将按租金加实付押金发起全量原路退款。确认继续?',
'确认客服关闭',
{
confirmButtonText: '继续关闭',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
openAction('close')
}
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(`${resetActionLabel.value}成功`)
} 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()
}
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
}
}
async function confirmRefund() {
if (!order.value || refundButtonDisabled.value) return
const hasRefundInProgress = ['pending', 'refunding'].includes(
refundStatus.value?.refund_status || ''
)
const message = hasRefundInProgress
? '当前订单已有退款处理中,继续人工退款可能导致重复全量退款。确认仍要发起?'
: '人工退款只会发起全量原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发全额退款。'
try {
await ElMessageBox.confirm(message, '确认人工退款', {
confirmButtonText: '确认退款',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
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: '已创建',
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 | undefined | null) {
return formatCentWithSymbol(Number(value || 0))
}
function readPriceBreakdownCent(key: string) {
const raw = snapshotPriceBreakdown.value?.[key]
if (raw === undefined || raw === null || raw === '') return null
const value = Number(raw)
if (!Number.isFinite(value)) return null
return Math.round(value * 100)
}
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
}
function orderStatusType(status: string) {
if (['completed', 'renting'].includes(status)) return 'success'
if (['overdue', 'abnormal', 'checkout_disputing'].includes(status)) return 'danger'
if (['pending_payment', 'pending_handoff', 'pending_checkout_confirm'].includes(status)) {
return 'warning'
}
if (['cancelled', 'closed'].includes(status)) return 'info'
return 'primary'
}
function settlementStatusType(status: string) {
if (['settled', 'refunded', 'arbitrated'].includes(status)) return 'success'
if (['disputed', 'frozen'].includes(status)) return 'danger'
if (['pending', 'unsettled'].includes(status)) return 'warning'
return 'info'
}
function refundStatusType(status: string) {
if (status === 'refunded') return 'success'
if (status === 'failed') return 'danger'
if (['pending', 'refunding'].includes(status)) return 'warning'
return 'info'
}
function checkoutStatusLabel(status: string) {
const map: Record<string, string> = {
submitted: '待确认',
countered: '号主已修正',
accepted: '已接受',
disputed: '争议中',
}
return map[status] || settlementStatusLabel(status)
}
function priceRoleLabel(role?: string) {
const map: Record<string, string> = {
renter: '租客视角',
owner: '号主视角',
admin: '客服处理',
}
return map[role || ''] || role || '-'
}
function disputeTypeLabel(type: string) {
const map: Record<string, string> = {
handoff: '交接申诉',
checkout: '结账争议',
order: '订单争议',
}
return map[type] || type || '-'
}
function userDisplay(phone: string | undefined, id: number) {
return phone ? `${phone} / ID ${id}` : `ID ${id}`
}
function displayValue(value: unknown) {
if (value === undefined || value === null || value === '') return '-'
if (Array.isArray(value))
return (
value
.map(item => String(item))
.filter(Boolean)
.join('、') || '-'
)
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function quantity(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN', {
maximumFractionDigits: 2,
})
}
function formatRatioNumber(value: number) {
const rounded = Math.round(value * 100) / 100
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2)
}
function paymentPaidAt(record: AdminPayment) {
return record.paid_at || record.updated_at || record.created_at
}
</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')">
{{ resetActionLabel }}
</el-button>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
<el-tooltip :content="closeActionTip" placement="top">
<span>
<el-button type="danger" :disabled="!canOperate" @click="confirmCloseAction"
>客服关闭</el-button
>
</span>
</el-tooltip>
<el-tooltip :content="refundActionTip" placement="top">
<span>
<el-button
type="primary"
:loading="refunding"
:disabled="refundButtonDisabled"
@click="confirmRefund"
>
{{ refundButtonDisabled ? '已退款' : '人工退款' }}
</el-button>
</span>
</el-tooltip>
</div>
</div>
<div v-if="order" class="metric-grid dashboard-metrics order-metrics">
<div class="metric-card">
<span>订单状态</span>
<strong>{{ orderStatusLabel(order.status) }}</strong>
<small>{{ handoffStatusLabel(order.handoff_status) }}</small>
</div>
<div class="metric-card">
<span>结算状态</span>
<strong>{{ settlementStatusLabel(order.settlement_status) }}</strong>
<small>更新 {{ formatDateTime(order.updated_at) }}</small>
</div>
<div class="metric-card">
<span>订单总额</span>
<strong>{{ moneyCent(orderTotalCent) }}</strong>
<small
>租金 {{ moneyCent(order.rent_amount_cent) }} / 押金
{{ moneyCent(order.deposit_amount_cent) }}</small
>
</div>
<div class="metric-card">
<span>支付状态</span>
<strong>{{ latestPayment ? paymentStatusLabel(latestPayment.status) : '暂无流水' }}</strong>
<small v-if="paidOrderPayment">支付 {{ moneyCent(paidOrderPayment.amount_cent) }}</small>
</div>
<div class="metric-card">
<span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus?.refund_status || '') }}</strong>
<small v-if="refundStatus?.refund_amount_cent">
{{ moneyCent(refundStatus.refund_amount_cent) }}
</small>
</div>
<div class="metric-card">
<span>预计截止</span>
<strong>{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</strong>
<small>{{ order.estimated_duration_hours }} 小时</small>
</div>
</div>
<div v-if="order" class="order-detail-grid">
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>订单概览</h2>
<div class="status-tags">
<el-tag :type="orderStatusType(order.status)" effect="light">
{{ orderStatusLabel(order.status) }}
</el-tag>
<el-tag :type="settlementStatusType(order.settlement_status)" effect="light">
{{ settlementStatusLabel(order.settlement_status) }}
</el-tag>
</div>
</div>
<dl class="detail-list">
<div>
<dt>订单编号</dt>
<dd>{{ order.order_no }}</dd>
</div>
<div>
<dt>订单ID</dt>
<dd>{{ order.id }}</dd>
</div>
<div>
<dt>商品编号</dt>
<dd>{{ listingCode }}</dd>
</div>
<div>
<dt>账号ID</dt>
<dd>{{ order.account_id }}</dd>
</div>
<div>
<dt>游戏区服</dt>
<dd>{{ order.server_region }}</dd>
</div>
<div>
<dt>登录平台</dt>
<dd>{{ order.login_platform }}</dd>
</div>
<div class="wide">
<dt>商品标题</dt>
<dd>{{ order.title }}</dd>
</div>
</dl>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>用户与时间</h2>
</div>
<dl class="detail-list">
<div>
<dt>租客</dt>
<dd>{{ userDisplay(order.renter_phone, order.renter_id) }}</dd>
</div>
<div>
<dt>号主</dt>
<dd>{{ userDisplay(order.owner_phone, order.owner_id) }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ formatDateTime(order.created_at) }}</dd>
</div>
<div>
<dt>支付截止</dt>
<dd>{{ formatDateTime(order.payment_deadline_at, '未设置') }}</dd>
</div>
<div>
<dt>开始租用</dt>
<dd>{{ formatDateTime(orderRentedAt(), '未开始') }}</dd>
</div>
<div>
<dt>预计结束</dt>
<dd>{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</dd>
</div>
</dl>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>资金拆分</h2>
<span class="panel-subtitle">{{ priceRoleLabel(order.price_role) }}</span>
</div>
<div class="money-list">
<div v-for="row in fundSplitRows" :key="row.label" class="money-row">
<span>{{ row.label }}</span>
<strong>{{ moneyCent(row.amountCent) }}</strong>
</div>
<div class="money-row total">
<span>订单实付合计</span>
<strong>{{ moneyCent(orderTotalCent) }}</strong>
</div>
</div>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>退款概况</h2>
<el-tag :type="refundStatusType(refundStatus?.refund_status || '')" effect="light">
{{ refundStatusLabel(refundStatus?.refund_status || '') }}
</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>退款金额</dt>
<dd>{{ moneyCent(refundStatus?.refund_amount_cent) }}</dd>
</div>
<div>
<dt>可退上限</dt>
<dd>{{ moneyCent(orderTotalCent) }}</dd>
</div>
<div>
<dt>退款完成</dt>
<dd>{{ formatDateTime(refundStatus?.refunded_at, '未完成') }}</dd>
</div>
<div>
<dt>支付流水</dt>
<dd>{{ paymentRecords.length }} </dd>
</div>
</dl>
</section>
<section v-if="order.checkout" class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>结账明细</h2>
<el-tag effect="light">{{ checkoutStatusLabel(order.checkout.status) }}</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>结算租金</dt>
<dd>{{ moneyCent(order.checkout.display_amount_cent) }}</dd>
</div>
<div>
<dt>退还租客</dt>
<dd>{{ moneyCent(order.checkout.renter_refund_amount_cent) }}</dd>
</div>
<div>
<dt>号主收入</dt>
<dd>{{ moneyCent(order.checkout.owner_income_amount_cent) }}</dd>
</div>
<div>
<dt>押金扣除</dt>
<dd>{{ moneyCent(order.checkout.deposit_deduct_amount_cent) }}</dd>
</div>
<div>
<dt>消耗品金额</dt>
<dd>{{ moneyCent(order.checkout.consumable_amount_cent) }}</dd>
</div>
<div>
<dt>哈夫币消耗</dt>
<dd>{{ quantity(order.checkout.coin_consumed_m) }}M</dd>
</div>
<div>
<dt>发起人</dt>
<dd>ID {{ order.checkout.initiated_by }}</dd>
</div>
<div>
<dt>凭证数量</dt>
<dd>{{ order.checkout.evidence_urls?.length || 0 }} </dd>
</div>
<div v-if="order.checkout.content" class="wide">
<dt>结账说明</dt>
<dd>{{ order.checkout.content }}</dd>
</div>
<div v-if="order.checkout.owner_adjustment_reason" class="wide">
<dt>号主修正</dt>
<dd>{{ order.checkout.owner_adjustment_reason }}</dd>
</div>
</dl>
</section>
<section v-if="order.active_dispute" class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>争议信息</h2>
<el-tag type="warning" effect="light">
{{ disputeStatusLabel(order.active_dispute.status) }}
</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>争议ID</dt>
<dd>{{ order.active_dispute.id }}</dd>
</div>
<div>
<dt>争议类型</dt>
<dd>{{ disputeTypeLabel(order.active_dispute.type) }}</dd>
</div>
<div>
<dt>发起人</dt>
<dd>ID {{ order.active_dispute.initiator_id }}</dd>
</div>
<div>
<dt>当前状态</dt>
<dd>{{ disputeStatusLabel(order.active_dispute.status) }}</dd>
</div>
</dl>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>支付与退款流水</h2>
<span class="panel-subtitle"> {{ paymentRecords.length }} </span>
</div>
<el-empty v-if="paymentRecords.length === 0" description="暂无支付流水" />
<div v-else class="record-list">
<article v-for="record in paymentRecords" :key="record.id" class="record-item">
<div class="record-main">
<strong>
{{ paymentBizTypeLabel(record.biz_type) }} · {{ moneyCent(record.amount_cent) }}
</strong>
<el-tag :type="paymentStatusType(record.status)" size="small" effect="light">
{{ paymentStatusLabel(record.status) }}
</el-tag>
</div>
<div class="record-meta">
<span>流水号{{ record.payment_no }}</span>
<span>用户{{ record.user_phone || record.user_id }}</span>
<span>渠道{{ record.provider || '-' }}</span>
<span>商户{{ record.merchant_id || '-' }}</span>
<span>三方单号{{ record.third_order_id || '-' }}</span>
<span>渠道单号{{ record.provider_order_id || '-' }}</span>
<span>创建{{ formatDateTime(record.created_at) }}</span>
<span>完成{{ formatDateTime(paymentPaidAt(record), '-') }}</span>
</div>
<p v-if="record.error_message" class="danger-text">
{{ record.error_code }} {{ record.error_message }}
</p>
</article>
</div>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>交接记录</h2>
<span class="panel-subtitle"> {{ handoffRecords.length }} </span>
</div>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-else class="record-list">
<article v-for="record in handoffRecords" :key="record.id" class="record-item">
<div class="record-main">
<strong>{{ formatHandoffRecordType(record.type) }}</strong>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
<p>{{ record.content }}</p>
<div class="record-meta">
<span>发送ID {{ record.from_user_id }}</span>
<span>接收ID {{ record.to_user_id }}</span>
<span>租客确认{{ formatDateTime(record.confirmed_by_renter_at, '-') }}</span>
<span>号主确认{{ formatDateTime(record.confirmed_by_owner_at, '-') }}</span>
</div>
</article>
</div>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>账号快照</h2>
<span class="panel-subtitle">哈夫币 {{ quantity(snapshotHafCoinM) }}M</span>
</div>
<dl v-if="accountSnapshotItems.length" class="detail-list snapshot-detail-list">
<div v-for="item in accountSnapshotItems" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<div v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)" class="ratio-row">
<div v-if="snapshotRatio.recycleRatio > 0" class="ratio-item">
<span class="ratio-label">回收比例</span>
<strong class="ratio-value">1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong>
</div>
<div v-if="snapshotRatio.sellRatio > 0" class="ratio-item">
<span class="ratio-label">售卖比例</span>
<strong class="ratio-value">1:{{ formatRatioNumber(snapshotRatio.sellRatio) }}</strong>
</div>
</div>
<div v-if="snapshotSeasonTags.length" class="snapshot-tags">
<el-tag v-for="tag in snapshotSeasonTags" :key="tag" size="small" effect="plain">
{{ tag }}
</el-tag>
</div>
<div v-if="snapshotResources.length" class="snapshot-resources">
<div v-for="resource in snapshotResources" :key="resource.key" class="resource-item">
<strong>{{ resource.label }}</strong>
<span>{{ quantity(resource.quantity) }} · {{ resource.price || resource.mode }}</span>
</div>
</div>
<pre class="snapshot-json">{{ snapshotText }}</pre>
</section>
</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>
<style scoped>
.order-metrics {
margin-bottom: 20px;
}
.order-metrics .metric-card strong {
font-size: 20px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.metric-card small {
display: block;
margin-top: 8px;
color: #8f9bba;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.order-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
gap: 20px;
margin-bottom: 28px;
}
.detail-panel {
min-width: 0;
}
.order-wide-panel {
grid-column: 1 / -1;
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel-heading h2 {
margin: 0;
}
.panel-subtitle {
color: #8f9bba;
font-size: 13px;
line-height: 1.6;
text-align: right;
}
.status-tags,
.snapshot-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.detail-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
margin: 0;
}
.detail-list div {
min-width: 0;
border-top: 1px solid #f0f2f5;
padding-top: 10px;
}
.detail-list .wide {
grid-column: 1 / -1;
}
.detail-list dt {
margin-bottom: 5px;
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.detail-list dd {
margin: 0;
color: #1b2559;
font-size: 14px;
font-weight: 700;
line-height: 1.5;
overflow-wrap: anywhere;
}
.money-list {
display: grid;
gap: 8px;
}
.money-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid #f0f2f5;
padding-top: 9px;
color: #52616f;
font-size: 13px;
}
.money-row strong {
color: #1b2559;
font-size: 15px;
}
.money-row.total {
margin-top: 4px;
border-top-color: #d8e2f0;
color: #1b2559;
font-weight: 700;
}
.money-row.total strong {
font-size: 18px;
}
.record-list {
display: grid;
gap: 12px;
}
.record-item {
display: grid;
gap: 8px;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fbfcff;
padding: 14px;
}
.record-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.record-main strong {
color: #1b2559;
font-size: 15px;
}
.record-main span {
color: #8f9bba;
font-size: 13px;
}
.record-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px 12px;
color: #52616f;
font-size: 12px;
line-height: 1.5;
}
.record-item p {
margin: 0;
color: #52616f;
line-height: 1.6;
white-space: pre-wrap;
}
.snapshot-detail-list {
margin-bottom: 14px;
}
.ratio-row {
display: flex;
gap: 24px;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fbfcff;
}
.ratio-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.ratio-label {
font-size: 12px;
color: #8f9bba;
font-weight: 600;
}
.ratio-value {
font-size: 20px;
font-weight: 700;
color: #1b2559;
}
.snapshot-tags {
justify-content: flex-start;
margin-bottom: 14px;
}
.snapshot-resources {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.resource-item {
display: grid;
gap: 4px;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fbfcff;
padding: 10px 12px;
}
.resource-item strong {
color: #1b2559;
font-size: 13px;
}
.resource-item span {
color: #52616f;
font-size: 12px;
}
.snapshot-json {
overflow-x: auto;
max-height: 360px;
margin: 0;
border-radius: 8px;
background: #111827;
color: #dbe4ee;
padding: 14px;
line-height: 1.6;
}
@media (max-width: 960px) {
.order-detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.panel-heading,
.record-main {
align-items: flex-start;
flex-direction: column;
}
.detail-list {
grid-template-columns: 1fr;
}
.status-tags {
justify-content: flex-start;
}
}
</style>