1116 lines
34 KiB
Vue
1116 lines
34 KiB
Vue
<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>
|