优化订单详情界面

This commit is contained in:
yml2213
2026-06-21 12:26:15 +08:00
parent 3bb794f6c4
commit cc88f46960
@@ -17,9 +17,19 @@ import {
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 { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
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'
@@ -78,6 +88,36 @@ const actionTitle = computed(() => {
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 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)
@@ -160,15 +200,6 @@ function orderEstimatedEndAt() {
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
@@ -244,8 +275,8 @@ function paymentBizTypeLabel(type: string) {
return map[type] || type
}
function moneyCent(value: number) {
return formatCentWithSymbol(value)
function moneyCent(value: number | undefined | null) {
return formatCentWithSymbol(Number(value || 0))
}
function firstQueryValue(value: unknown) {
@@ -264,6 +295,85 @@ function formatHandoffRecordType(type: string) {
}
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 paymentPaidAt(record: AdminPayment) {
return record.paid_at || record.updated_at || record.created_at
}
</script>
<template>
@@ -309,81 +419,341 @@ function formatHandoffRecordType(type: string) {
</div>
</div>
<div v-if="order" class="metric-grid dashboard-metrics">
<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>{{ handoffStatusLabel(order.handoff_status) }}</strong>
<span>结算状态</span>
<strong>{{ settlementStatusLabel(order.settlement_status) }}</strong>
<small>更新 {{ formatDateTime(order.updated_at) }}</small>
</div>
<div class="metric-card">
<span>订单</span>
<strong>¥{{ money(amountYuan(order.rent_amount_cent)) }}</strong>
<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>¥{{ money(amountYuan(order.platform_fee_cent)) }}</strong>
<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>¥{{ 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>
<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="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 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>
<div class="order-panel dashboard-panel">
<h2>支付与退款</h2>
<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 class="money-row">
<span>租客租金</span>
<strong>{{ moneyCent(order.rent_amount_cent) }}</strong>
</div>
<div class="money-row">
<span>号主租金</span>
<strong>{{ moneyCent(order.owner_rent_amount_cent) }}</strong>
</div>
<div class="money-row">
<span>平台费用</span>
<strong>{{ moneyCent(order.platform_fee_cent) }}</strong>
</div>
<div class="money-row">
<span>实付押金</span>
<strong>{{ moneyCent(order.deposit_amount_cent) }}</strong>
</div>
<div class="money-row">
<span>原始押金</span>
<strong>{{ moneyCent(order.deposit_original_amount_cent) }}</strong>
</div>
<div class="money-row">
<span>免押额度</span>
<strong>{{ moneyCent(order.deposit_waived_amount_cent) }}</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-for="record in paymentRecords" :key="record.id" class="timeline-item">
<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>
<p>
{{ record.provider || '-' }} · {{ record.third_order_id }}
<span v-if="record.provider_order_id"> / {{ record.provider_order_id }}</span>
</p>
<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>
<el-tag :type="paymentStatusType(record.status)">
{{ paymentStatusLabel(record.status) }}
</el-tag>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</article>
</div>
</section>
<div class="order-panel dashboard-panel">
<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-for="record in handoffRecords" :key="record.id" class="timeline-item">
<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>
<p>{{ record.content }}</p>
<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>
<div v-if="order" class="order-panel dashboard-panel code-panel">
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>账号快照</h2>
<pre>{{ snapshotText }}</pre>
<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="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
@@ -410,3 +780,246 @@ function formatHandoffRecordType(type: string) {
</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;
}
.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>