Files
hfb_sys/frontend/src/features/orders/views/MobileOrderDetailView.vue
T
2026-06-09 20:50:57 +08:00

1716 lines
48 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 { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { createDispute } from '@/features/disputes/api/disputes'
import { uploadFile } from '@/shared/api/files'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import {
acceptCheckout,
cancelOrder,
confirmCheckout,
confirmReceive,
counterCheckout,
fetchHandoffRecords,
fetchOrder,
startOrderPayment,
submitCheckout,
submitHandoff,
type HandoffRecord,
type Order,
type PaymentOrder,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const cancelling = ref(false)
const paying = ref(false)
const handoffing = ref(false)
const confirming = ref(false)
const returning = ref(false)
const completing = ref(false)
const countering = ref(false)
const acceptingCheckout = ref(false)
const rejectingCheckout = ref(false)
const disputing = ref(false)
const uploadingEvidence = ref(false)
const openingChat = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const listingCode = computed(() =>
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
)
const handoffContent = ref('')
const checkoutForm = ref({
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
})
const resourceUsage = ref<Record<string, number>>({})
const counterForm = ref({
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
deposit_deduct_amount: 0,
reason: '',
evidenceText: '',
})
const rejectReason = ref('')
const disputeType = ref('cannot_login')
const disputeDescription = ref('')
const disputeEvidenceText = ref('')
// Show state controls
const activeNames = ref<string[]>([])
const showDisputePopup = ref(false)
const showCounterPopup = ref(false)
const showRejectPopup = ref(false)
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金'))
const orderRentDisplayAmount = computed(() => (order.value ? orderRentAmount(order.value) : 0))
const ownerIncomeDisplayAmount = computed(() =>
order.value ? ownerActualIncome(order.value) : null
)
const ownerIncomeLabel = computed(() =>
order.value?.status === 'completed' ? '实际到手' : '结账预计到手'
)
const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false
return ![
'completed',
'cancelled',
'closed',
'disputing',
'checkout_disputing',
'abnormal',
].includes(order.value.status)
})
const isCheckoutDisputeStage = computed(() => {
return (
!!order.value &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
)
})
const checkoutResources = computed(() => {
const resources = readSnapshotResources()
return resources.filter(item => item.quantity > 0)
})
const resourceChargeAmount = computed(() => {
return roundMoney(
checkoutResources.value.reduce((sum, item) => {
if (!isChargedResource(item)) return sum
const used = readResourceUsage(item.key)
return sum + used * item.unitPrice
}, 0)
)
})
const snapshotHafCoinM = computed(() => {
const snapshot = readSnapshot()
return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
})
const remainingHafCoinM = computed(() => {
return roundQuantity(
Math.max(snapshotHafCoinM.value - Number(checkoutForm.value.coin_consumed_m || 0), 0)
)
})
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchOrder(String(route.params.id))
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
hydrateResourceUsage()
hydrateCounterForm()
} catch {
showToast({ message: '加载订单失败', icon: 'cross' })
} finally {
loading.value = false
}
}
async function handleCancel() {
if (!order.value) return
showDialog({
title: '取消订单',
message: '确定取消订单并释放账号吗?',
showCancelButton: true,
}).then(async () => {
cancelling.value = true
try {
await cancelOrder(order.value!.id)
showToast({ message: '订单已取消,账号已释放', icon: 'passed' })
await router.push('/m/orders')
} catch (error) {
showToast({ message: readError(error, '取消失败'), icon: 'cross' })
} finally {
cancelling.value = false
}
})
}
async function handlePay() {
if (!order.value) return
paying.value = true
try {
const payment = await startOrderPayment(order.value.id)
if (payment.paid) {
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await loadOrder()
} else {
openPaymentCashier(payment)
}
} catch (error) {
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
} finally {
paying.value = false
}
}
function openPaymentCashier(payment: PaymentOrder) {
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || ''
if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL
return
}
showDialog({
title: '订单支付',
message: payURL || '支付单已创建,请稍后刷新订单状态。',
confirmButtonText: '知道了',
})
}
async function handleSubmitHandoff() {
if (!order.value) return
if (!handoffContent.value.trim()) {
showToast({ message: '请填写交接说明', icon: 'warning-o' })
return
}
handoffing.value = true
try {
await submitHandoff(order.value.id, handoffContent.value)
handoffContent.value = ''
showToast({ message: '交接说明已提交', icon: 'passed' })
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '提交交接失败'), icon: 'cross' })
} finally {
handoffing.value = false
}
}
async function handleConfirmReceive() {
if (!order.value) return
confirming.value = true
try {
await confirmReceive(order.value.id)
showToast({ message: '已确认收号,订单进入使用中', icon: 'passed' })
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '确认收号失败'), icon: 'cross' })
} finally {
confirming.value = false
}
}
async function handleSubmitCheckout() {
if (!order.value) return
returning.value = true
try {
const consumableAmount = resourceChargeAmount.value
checkoutForm.value.consumable_amount = consumableAmount
await submitCheckout(order.value.id, {
content: checkoutContentWithSummary(),
consumable_amount: consumableAmount,
coin_consumed_m: checkoutForm.value.coin_consumed_m,
other_amount: checkoutForm.value.other_amount,
evidence_urls: linesToList(checkoutForm.value.evidenceText),
})
checkoutForm.value = {
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
}
resourceUsage.value = {}
showToast({ message: '结账已发起,等待号主确认', icon: 'passed' })
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '发起结账失败'), icon: 'cross' })
} finally {
returning.value = false
}
}
async function handleConfirmCheckout() {
if (!order.value) return
showDialog({
title: '确认结账',
message: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
showCancelButton: true,
}).then(async () => {
completing.value = true
try {
await confirmCheckout(order.value!.id)
showToast({ message: '结账已确认,订单完成', icon: 'passed' })
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '确认结账失败'), icon: 'cross' })
} finally {
completing.value = false
}
})
}
async function handleCounterCheckout() {
if (!order.value) return
countering.value = true
try {
const reasonText = counterForm.value.reason.trim()
await counterCheckout(order.value.id, {
content: reasonText,
consumable_amount: counterForm.value.consumable_amount,
coin_consumed_m: counterForm.value.coin_consumed_m,
other_amount: counterForm.value.other_amount,
deposit_deduct_amount: counterForm.value.deposit_deduct_amount,
reason: reasonText,
evidence_urls: linesToList(counterForm.value.evidenceText),
})
showToast({ message: '结账修正已提交,等待租客确认', icon: 'passed' })
showCounterPopup.value = false
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '修改结账失败'), icon: 'cross' })
} finally {
countering.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
showDialog({
title: '同意修正',
message: '确定同意号主的结账修正提案吗?同意后订单将完成结算。',
showCancelButton: true,
}).then(async () => {
acceptingCheckout.value = true
try {
await acceptCheckout(order.value!.id)
showToast({ message: '已确认修正结账,订单完成', icon: 'passed' })
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '确认修正失败'), icon: 'cross' })
} finally {
acceptingCheckout.value = false
}
})
}
async function handleRejectCheckout() {
if (!order.value) return
if (!rejectReason.value.trim()) {
showToast({ message: '请填写不同意原因', icon: 'warning-o' })
return
}
rejectingCheckout.value = true
try {
await createDispute(order.value.id, {
type: 'checkout_dispute',
description: rejectReason.value,
evidence_urls: [],
})
rejectReason.value = ''
showToast({ message: '已拒绝修正并提交争议', icon: 'passed' })
showRejectPopup.value = false
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '提交拒绝失败'), icon: 'cross' })
} finally {
rejectingCheckout.value = false
}
}
async function handleCreateDispute() {
if (!order.value) return
if (!disputeDescription.value.trim()) {
showToast({ message: '请填写争议经过说明', icon: 'warning-o' })
return
}
disputing.value = true
try {
const evidence_urls = linesToList(disputeEvidenceText.value)
await createDispute(order.value.id, {
type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value,
description: disputeDescription.value,
evidence_urls,
})
disputeDescription.value = ''
disputeEvidenceText.value = ''
showToast({ message: '申诉已提交,订单进入仲裁', icon: 'passed' })
showDisputePopup.value = false
await loadOrder()
} catch (error) {
showToast({ message: readError(error, '提交申诉失败'), icon: 'cross' })
} finally {
disputing.value = false
}
}
async function handleEvidenceUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingEvidence.value = true
try {
const uploaded = await uploadFile(file, 'dispute')
disputeEvidenceText.value = [disputeEvidenceText.value, uploaded.url].filter(Boolean).join('\n')
showToast({ message: '证据文件已上传', icon: 'passed' })
} catch (error) {
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
} finally {
uploadingEvidence.value = false
}
}
async function handleCounterEvidenceUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingEvidence.value = true
try {
const uploaded = await uploadFile(file, 'dispute')
counterForm.value.evidenceText = [counterForm.value.evidenceText, uploaded.url]
.filter(Boolean)
.join('\n')
showToast({ message: '证据文件已上传', icon: 'passed' })
} catch (error) {
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
} finally {
uploadingEvidence.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
function orderRentedAt() {
return order.value?.rented_at
}
function orderEstimatedEndAt() {
if (!order.value) return undefined
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()
}
interface CheckoutResource {
key: string
label: string
price: string
mode: string
quantity: number
unitPrice: number
}
function readSnapshot(): any {
const snapshot = order.value?.account_snapshot
if (isRecord(snapshot)) return snapshot
return null
}
function readAssetSummary(): any {
const summary = readSnapshot()?.asset_summary
if (isRecord(summary)) return summary
return null
}
function readSnapshotResources(): CheckoutResource[] {
const resources = readAssetSummary()?.resources
if (!Array.isArray(resources)) return []
return resources
.filter(isRecord)
.map(item => {
const key = String(item.key || item.label || '')
const label = String(item.label || key || '额外消耗品')
const price = String(item.price || '')
return {
key,
label,
price,
mode: String(item.mode || '收费'),
quantity: readNumber(item.quantity),
unitPrice: readUnitPrice(price),
}
})
.filter(item => item.key && item.quantity > 0)
}
function hydrateResourceUsage() {
const next: Record<string, number> = {}
for (const item of checkoutResources.value) {
next[item.key] = Math.min(
Math.max(Number(resourceUsage.value[item.key] || 0), 0),
item.quantity
)
}
resourceUsage.value = next
}
function readResourceUsage(key: string) {
return Math.max(Number(resourceUsage.value[key] || 0), 0)
}
function isChargedResource(item: CheckoutResource) {
return item.mode !== '赠送'
}
function resourceLineAmount(item: CheckoutResource) {
return roundMoney(readResourceUsage(item.key) * item.unitPrice)
}
function checkoutContentWithSummary() {
const lines = [checkoutForm.value.content.trim()].filter(Boolean)
const usedResources = checkoutResources.value.filter(item => readResourceUsage(item.key) > 0)
if (usedResources.length) {
lines.push(
`额外消耗品:${usedResources
.map(
item =>
`${item.label} ${readResourceUsage(item.key)}/${item.quantity}${
isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款'
}`
)
.join('')}`
)
}
if (Number(checkoutForm.value.coin_consumed_m || 0) > 0) {
lines.push(
`哈夫币消耗:${quantity(Number(checkoutForm.value.coin_consumed_m))}M,预计剩余${quantity(
remainingHafCoinM.value
)}M`
)
}
if (lines.length === 0) {
lines.push('租客发起结账。')
}
return lines.join('\n')
}
function readUnitPrice(priceText: string) {
const normalized = priceText.replace(//g, ',').trim()
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/)
if (fractionMatch) {
const amount = Number(fractionMatch[1])
const count = Number(fractionMatch[2])
return count > 0 ? roundMoney(amount / count) : 0
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/)
if (singleMatch) return Number(singleMatch[1])
const fallback = normalized.match(/(\d+(?:\.\d+)?)/)
return fallback ? Number(fallback[1]) : 0
}
function roundMoney(value: number) {
return Math.round(Number(value || 0) * 10) / 10
}
function roundQuantity(value: number) {
return Math.round(value * 100) / 100
}
function money(value: unknown) {
return formatMoney(readNumber(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 amountYuan(cent: unknown) {
if (cent !== undefined && cent !== null) return centToYuan(readNumber(cent))
return 0
}
function orderRentAmount(item: Order) {
if (item.owner_id === session.userId) return amountYuan(item.owner_rent_amount_cent)
if (item.renter_id === session.userId) return amountYuan(item.rent_amount_cent)
return amountYuan(item.display_amount_cent)
}
function ownerActualIncome(item: Order) {
if (item.owner_id !== session.userId) return null
const value = item.checkout?.owner_income_amount_cent
if (typeof value === 'number') return centToYuan(value)
return null
}
function quantity(value: unknown) {
const rounded = roundQuantity(readNumber(value))
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2)
}
function readNumber(value: unknown) {
const number = Number(value || 0)
return Number.isFinite(number) ? number : 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function hydrateCounterForm() {
if (!order.value?.checkout) return
const checkout = order.value.checkout
counterForm.value.consumable_amount = amountYuan(checkout.consumable_amount_cent)
counterForm.value.coin_consumed_m = checkout.coin_consumed_m
counterForm.value.other_amount = amountYuan(checkout.other_amount_cent)
counterForm.value.deposit_deduct_amount = amountYuan(checkout.deposit_deduct_amount_cent)
}
function linesToList(value: string) {
return value
.split('\n')
.map(item => item.trim())
.filter(Boolean)
}
async function openOrderChat() {
if (!order.value || openingChat.value) return
openingChat.value = true
try {
const chat = await fetchOrderChat(order.value.id)
router.push(`/m/chats/${chat.id}`)
} catch {
showToast({ message: '订单群聊暂不可用', icon: 'warning-o' })
} finally {
openingChat.value = false
}
}
/* Status Theme Color Mapping */
function getStatusTagType(status: string) {
if (['completed', 'received'].includes(status)) return 'success'
if (['pending_payment', 'pending_confirm'].includes(status)) return 'warning'
if (['renting'].includes(status)) return 'primary'
if (['cancelled', 'closed'].includes(status)) return 'danger'
return 'danger'
}
async function copyListingCode() {
if (!order.value) return
try {
await navigator.clipboard.writeText(listingCode.value)
showToast({ message: '商品编号已复制', icon: 'passed' })
} catch {
showToast({ message: '复制失败', icon: 'cross' })
}
}
</script>
<template>
<main class="mobile-order-detail-view">
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>订单详情</h1>
<button
v-if="order"
class="chat-btn"
type="button"
:disabled="openingChat"
@click="openOrderChat"
>
<van-icon name="chat-o" :size="19" />
</button>
<span v-else class="header-spacer"></span>
</header>
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
加载中...
</van-loading>
<template v-else-if="order">
<!-- 状态及核心信息卡片 -->
<section class="card-section info-card">
<div class="card-header-row">
<span class="order-no">{{ order.order_no }}</span>
<van-tag :type="getStatusTagType(order.status)" size="medium">
{{ orderStatusLabel(order.status) }}
</van-tag>
</div>
<button class="listing-code-chip" type="button" @click="copyListingCode">
商品编号 {{ listingCode }}
</button>
<h2 class="order-title">{{ order.title }}</h2>
<div class="order-meta-grid">
<div class="meta-item">
<span class="meta-label">{{ orderAmountLabel }}</span>
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
</div>
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="meta-item">
<span class="meta-label">{{ ownerIncomeLabel }}</span>
<strong class="meta-value income">¥{{ money(ownerIncomeDisplayAmount) }}</strong>
</div>
<div class="meta-item">
<span class="meta-label">押金</span>
<strong class="meta-value">¥{{ money(amountYuan(order.deposit_amount_cent)) }}</strong>
<span v-if="amountYuan(order.deposit_waived_amount_cent) > 0" class="meta-note"
>已免押 ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }}</span
>
</div>
<div class="meta-item">
<span class="meta-label">交接状态</span>
<strong class="meta-value">{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
</div>
</section>
<!-- 时间节点卡片 -->
<section class="card-section">
<van-cell-group inset :border="false">
<van-cell title="开始时间" :value="formatDateTime(orderRentedAt(), '未开始')" />
<van-cell title="预计截止" :value="formatDateTime(orderEstimatedEndAt(), '未设置')" />
</van-cell-group>
</section>
<!-- Renter Action: Pay / Cancel -->
<section v-if="order.status === 'pending_payment'" class="card-section action-card">
<p class="action-hint">订单已创建请确认并在有效期内完成支付过期订单将自动关闭</p>
<div class="action-buttons">
<van-button
v-if="isRenter"
type="primary"
block
round
:loading="paying"
loading-text="支付中..."
@click="handlePay"
>
支付订单
</van-button>
<van-button
type="danger"
block
plain
round
:loading="cancelling"
loading-text="取消中..."
@click="handleCancel"
>
取消订单并释放账号
</van-button>
</div>
</section>
<!-- Handoff Panel: Owner Handoff Info Submission -->
<section
v-if="
isOwner && order.status === 'pending_handoff' && order.handoff_status === 'pending_owner'
"
class="card-section action-card"
>
<h3 class="section-title">提交交接说明</h3>
<p class="action-hint">提交登录所需的账号密码、密保注意事项或扫码交接联系方式。</p>
<van-field
v-model="handoffContent"
rows="3"
autosize
label="交接说明"
type="textarea"
placeholder="请在这里填写登录方式、注意事项或扫码联系渠道"
class="action-field"
/>
<van-button
type="primary"
block
round
:loading="handoffing"
loading-text="正在提交..."
@click="handleSubmitHandoff"
>
提交交接说明
</van-button>
</section>
<!-- Handoff Panel: Renter Confirm Handoff Received -->
<section
v-if="
isRenter &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_renter_confirm'
"
class="card-section action-card"
>
<h3 class="section-title">确认收到账号</h3>
<p class="action-hint">
请按照号主提交的交接说明进行登录测试,确认无误后点击下方按钮确认收号,租期将正式开始计算。
</p>
<div class="action-buttons">
<van-button
type="primary"
block
round
:loading="confirming"
loading-text="确认中..."
@click="handleConfirmReceive"
>
确认收到账号
</van-button>
<van-button
type="danger"
block
plain
round
:loading="cancelling"
loading-text="取消中..."
@click="handleCancel"
>
未收到/无法登录,取消订单
</van-button>
</div>
</section>
<!-- Handoff Records list -->
<section class="card-section">
<h3 class="section-title">交接日志 · 商品编号 {{ listingCode }}</h3>
<van-empty
v-if="handoffRecords.length === 0"
image="search"
description="暂无交接日志记录"
/>
<div v-else class="log-timeline">
<div v-for="record in handoffRecords" :key="record.id" class="log-item">
<div class="log-dot"></div>
<div class="log-content-wrap">
<strong class="log-type">{{ formatHandoffRecordType(record.type) }}</strong>
<p class="log-desc">{{ record.content }}</p>
<span class="log-time">{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
</div>
</section>
<!-- Renter Action: Return Account & Initiate Checkout -->
<section
v-if="isRenter && ['renting', 'overdue'].includes(order.status)"
class="card-section action-card"
>
<h3 class="section-title">退号发起结账</h3>
<p class="action-hint">使用完毕,请输入在此期间消耗的物资与哈夫币进行结账申请。</p>
<van-field
v-model="checkoutForm.content"
rows="3"
autosize
label="结账备注"
type="textarea"
placeholder="可在此说明物品消耗情况或归还留言"
class="action-field"
/>
<!-- Checkout resource usage selection -->
<div v-if="checkoutResources.length" class="resource-usage-panel">
<strong class="resource-panel-title">额外物资消耗登记</strong>
<div v-for="item in checkoutResources" :key="item.key" class="resource-row">
<div class="resource-meta">
<span class="resource-name">{{ item.label }}</span>
<span class="resource-price-text"
>上限 {{ item.quantity }} · {{ item.mode }} · {{ item.price }}</span
>
</div>
<div class="stepper-wrap">
<van-stepper v-model="resourceUsage[item.key]" integer min="0" :max="item.quantity" />
<span class="resource-line-amount">¥{{ money(resourceLineAmount(item)) }}</span>
</div>
</div>
<div class="resource-panel-footer">
<span>物资金额合计:</span>
<strong>¥{{ money(resourceChargeAmount) }}</strong>
</div>
</div>
<van-cell-group inset :border="false" class="action-field">
<van-field label="消耗哈夫币" label-width="100px">
<template #input>
<div class="coin-input-wrap">
<input
v-model.number="checkoutForm.coin_consumed_m"
type="number"
placeholder="消耗数量"
class="custom-inline-input"
/>
<span class="input-unit">M</span>
</div>
</template>
</van-field>
<div class="coin-hint">
订单快照 {{ quantity(snapshotHafCoinM) }}M,预计剩余 {{ quantity(remainingHafCoinM) }}M
</div>
<van-field label="其他押金赔付" label-width="100px">
<template #input>
<div class="coin-input-wrap">
<input
v-model.number="checkoutForm.other_amount"
type="number"
placeholder="不含上方额外物资"
class="custom-inline-input"
/>
<span class="input-unit">元</span>
</div>
</template>
</van-field>
<div class="deposit-deduct-hint">仅填写封禁、违规、资产损坏等需要从押金赔付的费用。</div>
</van-cell-group>
<div class="checkout-fee-preview">
<div>
<span>额外消耗品已用</span>
<strong>¥{{ money(resourceChargeAmount) }}</strong>
<em>计入实际结算租金</em>
</div>
<div>
<span>其他押金赔付</span>
<strong>¥{{ money(checkoutForm.other_amount) }}</strong>
<em>从押金赔付扣除</em>
</div>
</div>
<van-field
v-model="checkoutForm.evidenceText"
rows="2"
autosize
label="结账证据"
type="textarea"
placeholder="结账截图链接(每行一个)"
class="action-field"
/>
<van-button
type="primary"
block
round
:loading="returning"
loading-text="正在提交结账..."
@click="handleSubmitCheckout"
>
提交结账归还
</van-button>
</section>
<!-- Checkout Details Display -->
<section
v-if="
order.checkout &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
"
class="card-section"
>
<h3 class="section-title">结账结算账单</h3>
<van-cell-group inset :border="false">
<van-cell
title="实际结算租金"
:value="`¥${money(amountYuan(order.checkout.display_amount_cent))}`"
/>
<van-cell
title="押金总额"
:label="
amountYuan(order.deposit_waived_amount_cent) > 0
? `已免押 ¥${money(amountYuan(order.deposit_waived_amount_cent))}`
: ''
"
:value="`¥${money(amountYuan(order.checkout.deposit_amount_cent))}`"
/>
<van-cell
title="额外消耗品已用"
:value="`¥${money(amountYuan(order.checkout.consumable_amount_cent))}`"
/>
<van-cell
title="押金赔付扣除"
:value="`¥${money(amountYuan(order.checkout.deposit_deduct_amount_cent))}`"
value-class="red-text"
/>
<van-cell
v-if="isRenter"
title="退还租客"
:value="`¥${money(amountYuan(order.checkout.renter_refund_amount_cent))}`"
value-class="green-text"
/>
<van-cell
v-if="isOwner"
title="号主最终收入"
:value="`¥${money(amountYuan(order.checkout.owner_income_amount_cent))}`"
value-class="green-text"
/>
<van-cell
v-if="order.checkout.content"
title="结账备注"
:label="order.checkout.content"
/>
<van-cell
v-if="order.checkout.owner_adjustment_reason"
title="号主修正原因"
:label="order.checkout.owner_adjustment_reason"
/>
</van-cell-group>
</section>
<!-- Owner Action: Confirm Checkout / Make Counter Adjustment Proposal -->
<section
v-if="isOwner && order.status === 'pending_checkout_confirm'"
class="card-section action-card"
>
<h3 class="section-title">结账处理</h3>
<p class="action-hint">
租客已发起结账并归还账号。确认无误后点击“确认结账”;若扣款不足,可以提出“修改结账”反向提案。
</p>
<div class="action-buttons">
<van-button
type="primary"
block
round
:loading="completing"
loading-text="处理中..."
@click="handleConfirmCheckout"
>
同意并确认结账
</van-button>
<van-button type="warning" block plain round @click="showCounterPopup = true">
修改扣款金额
</van-button>
</div>
</section>
<!-- Renter Action: Accept Counter Checkout Proposal / Refuse and Dispute -->
<section
v-if="isRenter && order.status === 'pending_checkout_accept'"
class="card-section action-card"
>
<h3 class="section-title">号主结账修正审批</h3>
<p class="action-hint">
号主修改了您的结账申请并对押金进行了扣除。请审查,同意将完成结算,不同意将进入仲裁申诉。
</p>
<div class="action-buttons">
<van-button
type="primary"
block
round
:loading="acceptingCheckout"
loading-text="同意中..."
@click="handleAcceptCheckout"
>
同意修正并结账
</van-button>
<van-button type="danger" block plain round @click="showRejectPopup = true">
拒绝修正并提起申诉
</van-button>
</div>
</section>
<!-- Universal Action: File a Dispute -->
<section v-if="canOpenDispute" class="card-section action-card">
<van-button type="warning" block plain round @click="showDisputePopup = true">
{{ isCheckoutDisputeStage ? '对结账发起申诉争议' : '发起订单申诉争议' }}
</van-button>
</section>
<!-- Snapshot Account Details Accordion -->
<section class="card-section">
<van-collapse v-model="activeNames">
<van-collapse-item title="查看订单账号快照信息" name="snapshot">
<template v-if="readSnapshot()">
<van-cell-group :border="false">
<van-cell title="区服" :value="order.server_region" />
<van-cell title="登录平台" :value="order.login_platform" />
<van-cell title="烽火等级" :value="readAssetSummary()?.fire_level || '--'" />
<van-cell title="绝密KD" :value="readAssetSummary()?.secret_kd || '--'" />
<van-cell
title="常用登录地区"
:value="readAssetSummary()?.common_regions?.join('、') || '--'"
/>
<van-cell title="封禁记录" :value="readAssetSummary()?.ban_record || '无'" />
<van-cell title="体力等级" :value="readAssetSummary()?.stamina_level || '--'" />
<van-cell title="负重等级" :value="readAssetSummary()?.load_level || '--'" />
<van-cell title="账号备注" :label="readSnapshot()?.description || '无'" />
</van-cell-group>
</template>
<template v-else>
<div class="no-snapshot-hint">暂无快照数据</div>
</template>
</van-collapse-item>
</van-collapse>
</section>
</template>
<div v-else class="empty-wrap">
<van-empty image="search" description="订单不存在或已被删除" />
</div>
<!-- POPUP: Owner Counter Checkout Adjustments -->
<van-popup v-model:show="showCounterPopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
<h3>修改结账扣款金额</h3>
<button class="popup-close" @click="showCounterPopup = false">✕</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
在此调整实际使用的租金项目和押金赔付金额,提案将交由租客二次确认。
</p>
<van-cell-group :border="false">
<van-field label="额外消耗品已用">
<template #input>
<input
v-model.number="counterForm.consumable_amount"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
<van-field label="消耗哈夫币 M">
<template #input>
<input
v-model.number="counterForm.coin_consumed_m"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
<van-field label="其他押金扣款">
<template #input>
<input
v-model.number="counterForm.other_amount"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
<van-field label="押金赔付扣除" required>
<template #input>
<input
v-model.number="counterForm.deposit_deduct_amount"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
</van-cell-group>
<van-field
v-model="counterForm.reason"
rows="2"
autosize
label="修改原因"
type="textarea"
placeholder="请说明调整扣款的原因,说服租客确认"
class="popup-field"
/>
<van-field
v-model="counterForm.evidenceText"
rows="2"
autosize
label="扣款证据"
type="textarea"
placeholder="证据链接(每行一个)"
class="popup-field"
/>
<div class="evidence-upload-row">
<span>上传新证据截图:</span>
<input type="file" accept="image/*" @change="handleCounterEvidenceUpload" />
</div>
<van-button
type="warning"
block
round
:loading="countering"
loading-text="提交中..."
@click="handleCounterCheckout"
class="popup-submit"
>
提交提案给租客确认
</van-button>
</div>
</van-popup>
<!-- POPUP: Renter Reject Counter Proposal Reason -->
<van-popup v-model:show="showRejectPopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
<h3>拒绝修正扣款说明</h3>
<button class="popup-close" @click="showRejectPopup = false">✕</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
拒绝号主的修正提案后,订单将立即自动发起争议,由客服介入根据证据判定。
</p>
<van-field
v-model="rejectReason"
rows="3"
autosize
label="拒绝原因"
type="textarea"
placeholder="请详细叙述拒绝号主扣款的理由,这些信息将被客服作为判定依据"
class="popup-field"
/>
<van-button
type="danger"
block
round
:loading="rejectingCheckout"
loading-text="发起争议中..."
@click="handleRejectCheckout"
class="popup-submit"
>
提交原因并提起争议
</van-button>
</div>
</van-popup>
<!-- POPUP: Create Generic Order Dispute -->
<van-popup v-model:show="showDisputePopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
<h3>发起申诉争议</h3>
<button class="popup-close" @click="showDisputePopup = false">✕</button>
</header>
<div class="popup-body">
<van-field v-if="!isCheckoutDisputeStage" label="申诉类型" class="popup-field">
<template #input>
<select v-model="disputeType" class="custom-select">
<option value="cannot_login">无法登录</option>
<option value="false_description">虚假描述</option>
<option value="account_banned">账号被封</option>
<option value="asset_loss">资产损失</option>
<option value="haf_coin_dispute">哈夫币争议</option>
<option value="handoff_timeout">超时未交接</option>
<option value="return_timeout">超时未归还</option>
</select>
</template>
</van-field>
<van-field
v-model="disputeDescription"
rows="3"
autosize
label="经过说明"
type="textarea"
placeholder="请在此描述您的诉求、事件经过、时间点等详细情况"
class="popup-field"
/>
<van-field
v-model="disputeEvidenceText"
rows="2"
autosize
label="证据列表"
type="textarea"
placeholder="证据链接(每行一个)"
class="popup-field"
/>
<div class="evidence-upload-row">
<span>上传证据截图:</span>
<input type="file" accept="image/*" @change="handleEvidenceUpload" />
</div>
<van-button
type="warning"
block
round
:loading="disputing"
loading-text="正在发起申诉..."
@click="handleCreateDispute"
class="popup-submit"
>
提交申诉客服仲裁
</van-button>
</div>
</van-popup>
</main>
</template>
<style scoped>
.mobile-order-detail-view {
min-height: 100dvh;
background: #f5f7fa;
padding-bottom: 40px;
}
/* ========== Header ========== */
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #eee;
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.back-btn,
.chat-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #333;
cursor: pointer;
}
.chat-btn:disabled {
color: #a1a1aa;
}
.header-spacer {
width: 36px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
/* ========== Card layout ========== */
.card-section {
margin: 12px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
padding: 14px;
}
.info-card {
padding: 16px;
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.order-no {
font-size: 12px;
color: #999;
font-family: monospace;
}
.listing-code-chip {
margin-bottom: 10px;
padding: 4px 9px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
.order-title {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 14px 0;
}
.order-meta-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
background: #f8f9fa;
border-radius: 8px;
padding: 10px;
}
.meta-item {
display: flex;
flex-direction: column;
align-items: center;
}
.meta-label {
font-size: 11px;
color: #8c96a0;
margin-bottom: 4px;
}
.meta-value {
font-size: 13px;
color: #2c3e50;
font-weight: 600;
}
.meta-value.accent {
color: #ff5f00;
font-weight: 700;
}
.meta-value.income {
color: #16a34a;
font-weight: 700;
}
.meta-note {
margin-top: 2px;
font-size: 10px;
color: #2563eb;
}
.action-card {
padding: 16px;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: #2c3e50;
margin: 0 0 8px 0;
}
.action-hint {
font-size: 12px;
color: #7f8c8d;
line-height: 1.5;
margin: 0 0 14px 0;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.action-field {
background: #f8fafc;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
/* ========== Log Timeline ========== */
.log-timeline {
display: flex;
flex-direction: column;
position: relative;
padding-left: 16px;
margin-top: 10px;
}
.log-timeline::after {
content: '';
position: absolute;
top: 6px;
bottom: 6px;
left: 4px;
width: 1px;
background: #e2e8f0;
}
.log-item {
position: relative;
padding-bottom: 16px;
}
.log-item:last-child {
padding-bottom: 0;
}
.log-dot {
position: absolute;
top: 5px;
left: -15px;
z-index: 2;
width: 7px;
height: 7px;
background: #1477ff;
border-radius: 50%;
}
.log-content-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.log-type {
font-size: 13px;
color: #2d3748;
}
.log-desc {
font-size: 12px;
color: #718096;
margin: 0;
line-height: 1.4;
}
.log-time {
font-size: 11px;
color: #a0aec0;
}
/* ========== Resource Panel ========== */
.resource-usage-panel {
background: #f8fafc;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.resource-panel-title {
display: block;
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
color: #2d3748;
}
.resource-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed #e2e8f0;
}
.resource-row:last-child {
border-bottom: none;
}
.resource-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.resource-name {
font-size: 13px;
color: #2d3748;
font-weight: 600;
}
.resource-price-text {
font-size: 11px;
color: #a0aec0;
}
.stepper-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.resource-line-amount {
min-width: 50px;
text-align: right;
font-size: 13px;
font-weight: 700;
color: #ff5f00;
}
.resource-panel-footer {
display: flex;
justify-content: flex-end;
align-items: baseline;
margin-top: 10px;
font-size: 12px;
color: #4a5568;
}
.resource-panel-footer strong {
font-size: 15px;
color: #ff5f00;
}
.deposit-deduct-hint {
padding: 0 16px 10px;
color: #8a94a6;
font-size: 12px;
line-height: 1.5;
}
.checkout-fee-preview {
display: grid;
gap: 8px;
margin: 0 16px 12px;
}
.checkout-fee-preview div {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.checkout-fee-preview span {
color: #4a5568;
font-size: 12px;
font-weight: 700;
}
.checkout-fee-preview strong {
color: #ff5f00;
font-size: 16px;
font-weight: 800;
}
.checkout-fee-preview em {
color: #8a94a6;
font-size: 11px;
font-style: normal;
}
.coin-input-wrap {
display: flex;
align-items: center;
width: 100%;
}
.custom-inline-input {
border: none;
background: transparent;
outline: none;
font-size: 14px;
flex: 1;
}
.input-unit {
font-size: 14px;
color: #718096;
margin-left: 6px;
}
.coin-hint {
font-size: 11px;
color: #a0aec0;
padding: 0 16px 8px 16px;
margin-top: -6px;
}
.no-snapshot-hint {
text-align: center;
padding: 10px;
color: #cbd5e0;
font-size: 13px;
}
/* ========== Popups ========== */
.mobile-popup-form {
max-height: 85%;
padding-bottom: env(safe-area-inset-bottom);
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #edf2f7;
}
.popup-header h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.popup-close {
border: none;
background: transparent;
font-size: 16px;
color: #a0aec0;
cursor: pointer;
}
.popup-body {
padding: 16px;
overflow-y: auto;
}
.form-hint-info {
font-size: 12px;
color: #718096;
line-height: 1.5;
margin: 0 0 12px 0;
}
.popup-field {
background: #f7fafc;
border-radius: 8px;
margin-bottom: 12px;
}
.evidence-upload-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #4a5568;
margin-bottom: 16px;
}
.popup-submit {
margin-top: 10px;
}
.custom-select {
border: none;
background: transparent;
width: 100%;
font-size: 14px;
color: #2d3748;
outline: none;
}
/* Utilities */
.red-text {
color: #ef4444;
}
.green-text {
color: #10b981;
}
.empty-wrap {
padding: 60px 0;
}
</style>