Files
hfb_sys/frontend/src/features/orders/views/OrderDetailView.vue
T

2091 lines
55 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 { ElMessage } from 'element-plus'
import { ChatDotRound, CopyDocument, Loading } from '@element-plus/icons-vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { createDispute } from '@/features/disputes'
import { uploadFile } from '@/shared/api/files'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import {
acceptCheckout,
cancelOrder,
confirmCheckout,
confirmReceive,
counterCheckout,
fetchHandoffRecords,
fetchOrder,
queryOrderPayment,
startOrderPayment,
submitCheckout,
submitHandoff,
type HandoffRecord,
type Order,
type PaymentOrder,
} from '@/features/orders'
import { useSessionStore } from '@/stores/session'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
import { formatListingNo } from '@/utils/listingDisplay'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const cancelling = ref(false)
const startingPayment = 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 order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const handoffContent = ref('')
const paymentDialogVisible = ref(false)
const activePayment = ref<PaymentOrder | null>(null)
const listingCode = computed(() =>
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
)
const paymentQRCodeURL = ref('')
const qrGenerating = ref(false)
const checkingPayment = ref(false)
let paymentPollingTimer: number | undefined
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('')
const openingChat = ref(false)
let autoPayHandled = 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)
)
})
function getOrderStep(status: string) {
const stepMap: Record<string, number> = {
pending_payment: 0,
pending_handoff: 1,
renting: 2,
overdue: 2,
pending_checkout_confirm: 3,
pending_checkout_accept: 3,
completed: 5,
cancelled: 0,
closed: 4,
}
return stepMap[status] ?? 0
}
onMounted(loadOrder)
onBeforeUnmount(stopPaymentPolling)
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()
if (
!autoPayHandled &&
route.query.pay === '1' &&
order.value?.status === 'pending_payment' &&
order.value?.renter_id === session.userId
) {
autoPayHandled = true
void router.replace({ path: route.path })
void handlePay()
}
} finally {
loading.value = false
}
}
async function handleCancel() {
if (!order.value) return
cancelling.value = true
try {
await cancelOrder(order.value.id)
ElMessage.success('订单已取消,账号已释放')
await router.push('/orders')
} catch (error) {
ElMessage.error(readError(error, '取消失败'))
} finally {
cancelling.value = false
}
}
async function handlePay() {
if (!order.value) return
startingPayment.value = true
try {
const payment = await startOrderPayment(order.value.id)
if (payment.paid) {
ElMessage.success('支付成功,等待号主交接')
await loadOrder()
} else {
showPaymentDialog(payment)
}
} catch (error) {
ElMessage.error(readError(error, '支付失败'))
} finally {
startingPayment.value = false
}
}
function showPaymentDialog(payment: PaymentOrder) {
activePayment.value = payment
paymentDialogVisible.value = true
void renderPaymentQRCode(payment)
startPaymentPolling()
}
function paymentPayURL(payment = activePayment.value) {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
async function renderPaymentQRCode(payment = activePayment.value) {
const payURL = paymentPayURL(payment)
paymentQRCodeURL.value = ''
if (!payURL) {
return
}
qrGenerating.value = true
try {
paymentQRCodeURL.value = await QRCode.toDataURL(payURL, {
width: 240,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
})
} catch {
ElMessage.error('二维码生成失败')
} finally {
qrGenerating.value = false
}
}
function startPaymentPolling() {
stopPaymentPolling()
paymentPollingTimer = window.setInterval(() => {
void refreshPaymentStatus(true)
}, 3000)
}
function stopPaymentPolling() {
if (paymentPollingTimer !== undefined) {
window.clearInterval(paymentPollingTimer)
paymentPollingTimer = undefined
}
}
async function refreshPaymentStatus(silent = false) {
if (!order.value || checkingPayment.value) {
return
}
checkingPayment.value = true
const currentPayURL = paymentPayURL()
try {
const payment = await queryOrderPayment(order.value.id)
if (payment.paid) {
stopPaymentPolling()
ElMessage.success('支付成功')
paymentDialogVisible.value = false
await loadOrder()
} else {
activePayment.value = payment
if (paymentPayURL(payment) !== currentPayURL) {
await renderPaymentQRCode(payment)
}
if (!silent) {
ElMessage.info('支付未完成')
}
}
} catch (error) {
if (!silent) {
ElMessage.error(readError(error, '刷新支付状态失败'))
}
} finally {
checkingPayment.value = false
}
}
async function handleRefreshPayment() {
await refreshPaymentStatus(false)
}
async function handleSubmitHandoff() {
if (!order.value) return
handoffing.value = true
try {
await submitHandoff(order.value.id, handoffContent.value)
handoffContent.value = ''
ElMessage.success('交接说明已提交')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '提交交接失败'))
} finally {
handoffing.value = false
}
}
async function handleConfirmReceive() {
if (!order.value) return
confirming.value = true
try {
await confirmReceive(order.value.id)
ElMessage.success('已确认收号,订单进入使用中')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认收号失败'))
} 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 = {}
ElMessage.success('结账已发起,等待号主确认')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '发起结账失败'))
} finally {
returning.value = false
}
}
async function handleConfirmCheckout() {
if (!order.value) return
completing.value = true
try {
await confirmCheckout(order.value.id)
ElMessage.success('结账已确认,订单完成')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认结账失败'))
} 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),
})
ElMessage.success('结账修正已提交,等待租客确认')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '修改结账失败'))
} finally {
countering.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
acceptingCheckout.value = true
try {
await acceptCheckout(order.value.id)
ElMessage.success('已确认修正结账,订单完成')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认修正失败'))
} finally {
acceptingCheckout.value = false
}
}
async function handleRejectCheckout() {
if (!order.value) return
rejectingCheckout.value = true
try {
await createDispute(order.value.id, {
type: 'checkout_dispute',
description: rejectReason.value,
evidence_urls: [],
})
rejectReason.value = ''
ElMessage.success('已拒绝修正结账,订单进入争议处理')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '拒绝修正失败'))
} finally {
rejectingCheckout.value = false
}
}
async function handleCreateDispute() {
if (!order.value) 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 = ''
ElMessage.success('申诉已提交,订单进入仲裁处理')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '提交申诉失败'))
} 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')
ElMessage.success('证据文件已上传')
} catch (error) {
ElMessage.error(readError(error, '上传失败'))
} finally {
uploadingEvidence.value = false
}
}
async function openOrderChat() {
if (!order.value || openingChat.value) return
openingChat.value = true
try {
const chat = await fetchOrderChat(order.value.id)
await router.push(`/messages/${chat.id}`)
} catch {
ElMessage.error('订单群聊暂不可用')
} finally {
openingChat.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() {
const snapshot = order.value?.account_snapshot
if (isRecord(snapshot)) return snapshot
return null
}
function readAssetSummary() {
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 amountYuan(cent: unknown, legacyYuan?: unknown) {
if (cent !== undefined && cent !== null) return centToYuan(readNumber(cent))
return readNumber(legacyYuan)
}
function orderRentAmount(item: Order) {
if (item.owner_id === session.userId)
return amountYuan(item.owner_rent_amount_cent, item.owner_rent_amount ?? item.display_amount)
if (item.renter_id === session.userId)
return amountYuan(item.rent_amount_cent, item.rent_amount ?? item.display_amount)
return amountYuan(item.display_amount_cent, item.display_amount)
}
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 typeof item.checkout?.owner_income_amount === 'number'
? item.checkout.owner_income_amount
: 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,
checkout.consumable_amount
)
counterForm.value.coin_consumed_m = checkout.coin_consumed_m
counterForm.value.other_amount = amountYuan(checkout.other_amount_cent, checkout.other_amount)
counterForm.value.deposit_deduct_amount = amountYuan(
checkout.deposit_deduct_amount_cent,
checkout.deposit_deduct_amount
)
}
function linesToList(value: string) {
return value
.split('\n')
.map(item => item.trim())
.filter(Boolean)
}
function scrollToDispute() {
const disputeSection = document.querySelector('.dispute-section')
if (disputeSection) {
disputeSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function scrollToCheckout() {
const checkoutSection = document.querySelector('.checkout-form-section')
if (checkoutSection) {
checkoutSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function scrollToCounterCheckout() {
const counterSection = document.querySelector('.counter-checkout-section')
if (counterSection) {
counterSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function scrollToRejectCheckout() {
const rejectSection = document.querySelector('.reject-checkout-section')
if (rejectSection) {
rejectSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
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
}
async function copyListingCode() {
if (!order.value) return
try {
await navigator.clipboard.writeText(listingCode.value)
ElMessage.success('商品编号已复制')
} catch {
ElMessage.error('复制失败')
}
}
</script>
<template>
<section class="page order-detail-page" v-loading="loading">
<div v-if="order" class="page-header">
<div class="header-top">
<div class="header-info">
<p class="eyebrow">订单号{{ order.order_no }}</p>
<h1>{{ order.title }}</h1>
<p class="order-meta">{{ order.server_region }} / {{ order.login_platform }}</p>
<el-button size="small" plain :icon="CopyDocument" @click="copyListingCode">
商品编号 {{ listingCode }}
</el-button>
</div>
<el-button type="primary" :loading="openingChat" @click="openOrderChat">
<el-icon style="margin-right: 4px"><ChatDotRound /></el-icon>
联系对方
</el-button>
</div>
</div>
<div v-if="order" class="content-layout">
<div class="main-content">
<div v-if="order" class="order-progress-section">
<el-steps
:active="getOrderStep(order.status)"
align-center
finish-status="success"
process-status="process"
>
<el-step
title="待支付"
:description="order.status === 'pending_payment' ? '等待租客支付' : ''"
/>
<el-step
title="待交接"
:description="
order.status === 'pending_handoff' ? handoffStatusLabel(order.handoff_status) : ''
"
/>
<el-step
title="使用中"
:description="['renting', 'overdue'].includes(order.status) ? '租赁进行中' : ''"
/>
<el-step
title="结账中"
:description="
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
? '等待确认'
: ''
"
/>
<el-step title="已完成" :description="order.status === 'completed' ? '订单完成' : ''" />
</el-steps>
</div>
<div v-if="order" class="detail-grid">
<div class="metric-card primary">
<span class="metric-label">订单状态</span>
<strong class="metric-value">{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">交接状态</span>
<strong class="metric-value">{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card highlight">
<span class="metric-label">{{ orderAmountLabel }}</span>
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
</div>
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="metric-card income">
<span class="metric-label">{{ ownerIncomeLabel }}</span>
<strong class="metric-value amount">¥{{ money(ownerIncomeDisplayAmount) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">押金</span>
<strong class="metric-value"
>¥{{ money(amountYuan(order.deposit_amount_cent, order.deposit_amount)) }}</strong
>
<span
v-if="amountYuan(order.deposit_waived_amount_cent, order.deposit_waived_amount) > 0"
class="metric-note"
>已免押 ¥{{
money(amountYuan(order.deposit_waived_amount_cent, order.deposit_waived_amount))
}}</span
>
</div>
</div>
<div v-if="order" class="info-section">
<div class="info-card">
<div class="info-row">
<span class="info-label">开始时间</span>
<span class="info-value">{{ formatDateTime(orderRentedAt(), '未开始') }}</span>
</div>
<div class="info-row">
<span class="info-label">预计截止</span>
<span class="info-value">{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</span>
</div>
</div>
<div v-if="order.status === 'pending_payment'" class="action-card warning">
<p class="action-message">订单待支付支付后账号进入交接流程</p>
<div class="action-buttons">
<el-button
v-if="isRenter"
type="primary"
size="large"
:loading="startingPayment"
@click="handlePay"
>
立即支付订单
</el-button>
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
</div>
<div v-if="order.status === 'pending_handoff'" class="action-card info">
<div class="action-buttons">
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
</div>
</div>
<div v-if="order" class="timeline-section">
<div class="section-header">
<h2>交接记录</h2>
<span>商品编号 {{ listingCode }}</span>
</div>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<el-timeline v-else>
<el-timeline-item
v-for="record in handoffRecords"
:key="record.id"
:timestamp="formatDateTime(record.created_at)"
placement="top"
>
<div class="timeline-content">
<div class="timeline-title">{{ formatHandoffRecordType(record.type) }}</div>
<div class="timeline-body">{{ record.content }}</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
<div
v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)"
class="form-section checkout-form-section"
>
<div class="section-header">
<h2>发起结账</h2>
</div>
<div class="form-card">
<el-input
v-model="checkoutForm.content"
type="textarea"
:rows="4"
placeholder="填写结账说明、租后资产状态或注意事项"
/>
<div class="checkout-resource-panel">
<div class="checkout-resource-head">
<strong>额外消耗品</strong>
<span class="amount-highlight">已用金额:¥{{ money(resourceChargeAmount) }}</span>
</div>
<el-empty
v-if="checkoutResources.length === 0"
description="订单快照中暂无额外消耗品"
/>
<div
v-for="item in checkoutResources"
v-else
:key="item.key"
class="checkout-resource-row"
>
<div class="checkout-resource-meta">
<strong>{{ item.label }}</strong>
<span
>库存 {{ item.quantity }}{{ item.mode }}{{
item.price || '未设置单价'
}}</span
>
</div>
<el-input-number
v-model="resourceUsage[item.key]"
:min="0"
:max="item.quantity"
:precision="0"
controls-position="right"
/>
<span class="checkout-resource-amount">¥{{ money(resourceLineAmount(item)) }}</span>
</div>
</div>
<el-form class="form-grid" label-position="top">
<el-form-item label="消耗哈夫币(M">
<el-input-number
v-model="checkoutForm.coin_consumed_m"
class="full-control"
:min="0"
:max="snapshotHafCoinM"
:precision="2"
controls-position="right"
/>
<span class="field-hint"
>订单快照 {{ quantity(snapshotHafCoinM) }}M预计剩余
{{ quantity(remainingHafCoinM) }}M</span
>
</el-form-item>
<el-form-item label="其他押金赔付(元)">
<el-input-number
v-model="checkoutForm.other_amount"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
/>
<span class="field-hint"
>不含上方额外消耗品仅填写封禁违规资产损坏等需要从押金赔付的费用</span
>
</el-form-item>
</el-form>
<div class="checkout-deduct-preview">
<div>
<span>额外消耗品已用</span>
<strong>¥{{ money(resourceChargeAmount) }}</strong>
<em>按上方物资数量自动计算计入实际结算租金</em>
</div>
<div>
<span>其他押金赔付</span>
<strong>¥{{ money(checkoutForm.other_amount) }}</strong>
<em>作为押金赔付扣除和额外消耗品分开展示</em>
</div>
</div>
<el-input
v-model="checkoutForm.evidenceText"
type="textarea"
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
/>
<el-button
type="primary"
size="large"
:loading="returning"
@click="handleSubmitCheckout"
>发起结账</el-button
>
</div>
</div>
<div
v-if="
order &&
order.checkout &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
"
class="info-section"
>
<div class="section-header">
<h2>结账明细</h2>
</div>
<div class="checkout-summary-card">
<div class="summary-row">
<span>实际结算租金</span>
<strong
>¥{{
money(
amountYuan(order.checkout.display_amount_cent, order.checkout.display_amount)
)
}}</strong
>
</div>
<div class="summary-row">
<span>预收押金</span>
<strong>
¥{{
money(
amountYuan(order.checkout.deposit_amount_cent, order.checkout.deposit_amount)
)
}}
<em
v-if="
amountYuan(order.deposit_waived_amount_cent, order.deposit_waived_amount) > 0
"
>已免押 ¥{{
money(amountYuan(order.deposit_waived_amount_cent, order.deposit_waived_amount))
}}</em
>
</strong>
</div>
<div class="summary-row">
<span>额外消耗品已用</span>
<strong class="warning"
>¥{{
money(
amountYuan(
order.checkout.consumable_amount_cent,
order.checkout.consumable_amount
)
)
}}</strong
>
</div>
<div class="summary-row">
<span>押金赔付扣除</span>
<strong class="warning"
>¥{{
money(
amountYuan(
order.checkout.deposit_deduct_amount_cent,
order.checkout.deposit_deduct_amount
)
)
}}</strong
>
</div>
<div v-if="isRenter" class="summary-row highlight">
<span>退还租客(未使用租金 + 剩余押金)</span>
<strong class="amount"
>¥{{
money(
amountYuan(
order.checkout.renter_refund_amount_cent,
order.checkout.renter_refund_amount
)
)
}}</strong
>
</div>
<div v-if="isOwner" class="summary-row highlight">
<span>号主最终收入(租金 + 押金赔付)</span>
<strong class="amount"
>¥{{
money(
amountYuan(
order.checkout.owner_income_amount_cent,
order.checkout.owner_income_amount
)
)
}}</strong
>
</div>
<div v-if="order.checkout.content" class="summary-note">
<label>说明:</label>
<p>{{ order.checkout.content }}</p>
</div>
<div v-if="order.checkout.owner_adjustment_reason" class="summary-note warning">
<label>修正原因:</label>
<p>{{ order.checkout.owner_adjustment_reason }}</p>
</div>
</div>
</div>
<div
v-if="order && isOwner && order.status === 'pending_checkout_confirm'"
class="form-section counter-checkout-section"
>
<div class="section-header">
<h2>修改结账</h2>
</div>
<div class="form-card">
<h3 class="sub-title">修改结账金额</h3>
<el-form class="form-grid" label-position="top">
<el-form-item label="额外消耗品已用金额">
<el-input-number
v-model="counterForm.consumable_amount"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="消耗哈夫币(M">
<el-input-number
v-model="counterForm.coin_consumed_m"
class="full-control"
:min="0"
:precision="2"
controls-position="right"
/>
</el-form-item>
<el-form-item label="其他押金扣款(元)">
<el-input-number
v-model="counterForm.other_amount"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="押金扣除(元)">
<el-input-number
v-model="counterForm.deposit_deduct_amount"
class="full-control"
:min="0"
:max="amountYuan(order.deposit_amount_cent, order.deposit_amount)"
:precision="0"
controls-position="right"
/>
</el-form-item>
</el-form>
<el-input
v-model="counterForm.reason"
type="textarea"
:rows="3"
placeholder="填写修改原因"
/>
<el-input
v-model="counterForm.evidenceText"
type="textarea"
:rows="3"
placeholder="修正证据链接,一行一个"
/>
<el-button
type="warning"
size="large"
:loading="countering"
@click="handleCounterCheckout"
>提交修正给租客确认</el-button
>
</div>
</div>
<div
v-if="order && isRenter && order.status === 'pending_checkout_accept'"
class="form-section reject-checkout-section"
>
<div class="section-header">
<h2>拒绝修正</h2>
</div>
<div class="form-card">
<p class="form-hint">不同意修正时填写原因,订单会进入争议处理。</p>
<el-input
v-model="rejectReason"
type="textarea"
:rows="3"
placeholder="不同意时填写原因,会进入争议处理"
/>
<el-button
type="danger"
plain
size="large"
:loading="rejectingCheckout"
@click="handleRejectCheckout"
>拒绝修正并发起争议</el-button
>
</div>
</div>
</div>
<!-- 右侧悬浮操作区 -->
<div v-if="order" class="action-sidebar">
<!-- 交接操作 -->
<div
v-if="
isOwner &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_owner'
"
class="sidebar-card"
>
<h3 class="sidebar-title">提交交接说明</h3>
<el-input
v-model="handoffContent"
type="textarea"
:rows="5"
placeholder="填写登录方式、注意事项和交接说明"
/>
<el-button type="primary" size="large" :loading="handoffing" @click="handleSubmitHandoff"
>提交交接</el-button
>
</div>
<!-- 确认收号 -->
<div
v-if="
isRenter &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_renter_confirm'
"
class="sidebar-card"
>
<h3 class="sidebar-title">确认收号</h3>
<p class="sidebar-hint">
确认账号可以正常登录后,订单会进入使用中并重新计算预计截止时间。
</p>
<el-button type="primary" size="large" :loading="confirming" @click="handleConfirmReceive"
>确认已收到账号</el-button
>
</div>
<!-- 发起结账 -->
<div
v-if="isRenter && ['renting', 'overdue'].includes(order.status)"
class="sidebar-card highlight-card"
>
<h3 class="sidebar-title">快捷结账</h3>
<el-button type="primary" size="large" :loading="returning" @click="handleSubmitCheckout">
立即提交结账
</el-button>
<p class="sidebar-hint">
如需修改消耗品、哈夫币等详细信息,请先向下滚动填写完整表单后再提交
</p>
<el-button type="default" size="small" @click="scrollToCheckout">
查看详细结账表单
</el-button>
</div>
<!-- 确认结账 -->
<div v-if="isOwner && order.status === 'pending_checkout_confirm'" class="sidebar-card">
<h3 class="sidebar-title">确认结账</h3>
<el-button
type="primary"
size="large"
:loading="completing"
@click="handleConfirmCheckout"
>
确认结账并完成订单
</el-button>
<div class="section-divider-mini">
<span>或者</span>
</div>
<el-button type="warning" plain size="large" @click="scrollToCounterCheckout">
需要修改结账金额
</el-button>
<p class="sidebar-hint">如认为金额有误,点击上方按钮修改</p>
</div>
<!-- 确认修正结账 -->
<div v-if="isRenter && order.status === 'pending_checkout_accept'" class="sidebar-card">
<h3 class="sidebar-title">确认修正结账</h3>
<el-button
type="primary"
size="large"
:loading="acceptingCheckout"
@click="handleAcceptCheckout"
>
同意修正并完成订单
</el-button>
<div class="section-divider-mini">
<span>或者</span>
</div>
<el-button type="danger" plain size="large" @click="scrollToRejectCheckout">
不同意,发起争议
</el-button>
<p class="sidebar-hint">不同意修正时需填写原因,将进入争议处理</p>
</div>
<!-- 发起申诉 -->
<div v-if="canOpenDispute" class="sidebar-card dispute-card">
<h3 class="sidebar-title">{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h3>
<el-button type="warning" size="large" @click="scrollToDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p>
</div>
</div>
</div>
<div v-if="order && canOpenDispute" class="form-section dispute-section">
<div class="section-header">
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
</div>
<div class="form-card">
<el-select
v-if="!isCheckoutDisputeStage"
v-model="disputeType"
class="full-control"
placeholder="选择申诉类型"
>
<el-option label="无法登录" value="cannot_login" />
<el-option label="虚假描述" value="false_description" />
<el-option label="账号被封" value="account_banned" />
<el-option label="资产损失" value="asset_loss" />
<el-option label="哈夫币争议" value="haf_coin_dispute" />
<el-option label="超时未交接" value="handoff_timeout" />
<el-option label="超时未归还" value="return_timeout" />
</el-select>
<el-input
v-model="disputeDescription"
type="textarea"
:rows="4"
placeholder="说明争议经过、时间点和希望客服核查的证据"
/>
<el-input
v-model="disputeEvidenceText"
type="textarea"
:rows="3"
placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
/>
<div class="upload-line">
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
:disabled="uploadingEvidence"
@change="handleEvidenceUpload"
/>
</div>
<el-button type="warning" size="large" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
</div>
</div>
<el-dialog
v-model="paymentDialogVisible"
title="订单支付"
width="560px"
append-to-body
:z-index="4000"
class="order-pay-dialog"
@closed="stopPaymentPolling"
>
<div v-if="activePayment" class="pay-dialog-body">
<div class="pay-summary">
<div class="pay-summary-row">
<span>支付金额</span>
<strong>¥{{ formatMoney(activePayment.amount_cent / 100) }}</strong>
</div>
</div>
<div v-if="paymentPayURL()" class="pay-qr-section">
<div class="pay-qr-box">
<el-icon v-if="qrGenerating" class="is-loading" :size="32"><Loading /></el-icon>
<img v-else-if="paymentQRCodeURL" :src="paymentQRCodeURL" alt="支付二维码" />
</div>
<div class="pay-instructions">
<h3>请使用微信或支付宝扫码支付</h3>
<p>扫码完成后将自动刷新,也可手动点击下方按钮确认。</p>
</div>
</div>
<p v-else class="pay-hint">支付单已创建,请完成付款后刷新状态。</p>
</div>
<template #footer>
<div class="pay-dialog-footer">
<el-button type="primary" :loading="checkingPayment" @click="handleRefreshPayment">
我已支付刷新状态
</el-button>
</div>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.order-detail-page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.header-info {
flex: 1;
}
.order-meta {
color: #64748b;
margin-top: 8px;
}
.content-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 24px;
align-items: start;
}
.main-content {
min-width: 0;
}
.action-sidebar {
position: sticky;
top: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.sidebar-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
background: #ffffff;
border: 2px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.sidebar-card.highlight-card {
background: #eff6ff;
border-color: #3b82f6;
}
.sidebar-card.dispute-card {
background: #fef2f2;
border-color: #fecaca;
}
.sidebar-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.sidebar-hint {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.5;
}
.sidebar-card .el-button {
width: 100%;
}
.order-progress-section {
margin-bottom: 32px;
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
transition: all 0.2s;
}
.metric-card:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.metric-card.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: #ffffff;
}
.metric-card.primary .metric-label {
color: rgba(255, 255, 255, 0.9);
}
.metric-card.primary .metric-value {
color: #ffffff;
}
.metric-card.highlight {
background: linear-gradient(135deg, #ff6a00 0%, #ee0979 100%);
border: none;
color: #ffffff;
}
.metric-card.highlight .metric-label {
color: rgba(255, 255, 255, 0.9);
}
.metric-card.highlight .metric-value {
color: #ffffff;
}
.metric-card.income {
background: #f0fdf4;
border-color: #bbf7d0;
}
.metric-card.income .metric-label {
color: #15803d;
}
.metric-card.income .metric-value {
color: #16a34a;
}
.metric-label {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
.metric-value {
font-size: 24px;
font-weight: 700;
color: #1f2937;
}
.metric-value.amount {
color: #ff6a00;
}
.metric-note {
font-size: 12px;
color: #2563eb;
}
.info-section {
margin-bottom: 32px;
}
.info-card {
display: grid;
gap: 12px;
padding: 20px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.info-value {
font-size: 14px;
color: #1f2937;
font-weight: 600;
}
.action-card {
margin-top: 16px;
padding: 20px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.action-card.warning {
background: #fffbeb;
border-color: #fde68a;
}
.action-card.info {
background: #eff6ff;
border-color: #bfdbfe;
}
.action-message {
margin: 0 0 16px 0;
color: #1f2937;
font-size: 14px;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.timeline-section {
margin-bottom: 32px;
}
.timeline-section :deep(.el-timeline) {
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.timeline-content {
display: grid;
gap: 8px;
}
.timeline-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.timeline-body {
font-size: 14px;
color: #64748b;
line-height: 1.6;
white-space: pre-wrap;
}
.form-section {
margin-bottom: 32px;
}
.form-card {
display: grid;
gap: 16px;
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.form-hint {
margin: 0;
padding: 12px 16px;
background: #f8fafc;
border-left: 3px solid #3b82f6;
color: #475569;
font-size: 14px;
line-height: 1.6;
border-radius: 4px;
}
.section-divider {
display: flex;
align-items: center;
justify-content: center;
margin: 24px 0;
position: relative;
}
.section-divider::before,
.section-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e5e7eb;
}
.section-divider span {
padding: 0 16px;
color: #9ca3af;
font-size: 14px;
font-weight: 500;
}
.section-divider-mini {
display: flex;
align-items: center;
justify-content: center;
margin: 8px 0;
position: relative;
}
.section-divider-mini::before,
.section-divider-mini::after {
content: '';
flex: 1;
height: 1px;
background: #e5e7eb;
}
.section-divider-mini span {
padding: 0 12px;
color: #9ca3af;
font-size: 12px;
font-weight: 500;
}
.sub-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin: 0 0 16px 0;
}
.checkout-resource-panel {
display: grid;
gap: 12px;
padding: 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.checkout-resource-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
.checkout-resource-head strong {
color: #1f2937;
font-size: 15px;
}
.amount-highlight {
color: #ff6a00;
font-weight: 700;
font-size: 14px;
}
.checkout-resource-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
padding: 12px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
}
.checkout-resource-meta {
display: grid;
gap: 4px;
}
.checkout-resource-meta strong {
color: #1f2937;
font-size: 14px;
}
.checkout-resource-meta span {
color: #6b7785;
font-size: 12px;
}
.checkout-resource-amount {
min-width: 80px;
text-align: right;
color: #ff6a00;
font-weight: 700;
font-size: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.field-hint {
display: block;
margin-top: 6px;
color: #6b7280;
font-size: 12px;
}
.checkout-deduct-preview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.checkout-deduct-preview div {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.checkout-deduct-preview span {
color: #4b5563;
font-size: 13px;
font-weight: 700;
}
.checkout-deduct-preview strong {
color: #ff6a00;
font-size: 18px;
font-weight: 800;
}
.checkout-deduct-preview em {
color: #6b7280;
font-size: 12px;
font-style: normal;
line-height: 1.45;
}
.checkout-summary-card {
display: grid;
gap: 12px;
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.summary-row:last-child {
border-bottom: none;
}
.summary-row.highlight {
padding: 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.summary-row span {
font-size: 14px;
color: #64748b;
}
.summary-row strong {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.summary-row strong em {
font-style: normal;
font-size: 12px;
font-weight: 500;
color: #2563eb;
}
.summary-row strong.amount {
color: #10b981;
font-size: 20px;
}
.summary-row strong.warning {
color: #f59e0b;
}
.summary-note {
padding: 16px;
background: #f8fafc;
border-left: 3px solid #94a3b8;
border-radius: 4px;
}
.summary-note.warning {
background: #fef3c7;
border-left-color: #f59e0b;
}
.summary-note label {
display: block;
margin-bottom: 8px;
color: #475569;
font-size: 13px;
font-weight: 600;
}
.summary-note p {
margin: 0;
color: #1f2937;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.dispute-section .form-card {
background: #fef2f2;
border-color: #fecaca;
}
.upload-line {
padding: 12px;
background: #f9fafb;
border: 1px dashed #cbd5e1;
border-radius: 6px;
}
.upload-line input[type='file'] {
width: 100%;
font-size: 14px;
}
.pay-dialog-body {
display: grid;
gap: 20px;
}
.pay-summary {
padding: 16px 20px;
background: #f8fafc;
border-radius: 8px;
}
.pay-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.pay-summary-row span {
color: #64748b;
font-size: 14px;
}
.pay-summary-row strong {
color: #111a44;
font-size: 28px;
font-weight: 700;
}
.pay-qr-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 24px;
background: #f8fafc;
border-radius: 12px;
}
.pay-qr-box {
width: 240px;
height: 240px;
display: grid;
place-items: center;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.pay-qr-box img {
width: 220px;
height: 220px;
display: block;
}
.pay-instructions {
text-align: center;
}
.pay-instructions h3 {
margin: 0 0 8px 0;
color: #111a44;
font-size: 18px;
font-weight: 600;
}
.pay-instructions p {
margin: 0;
color: #64748b;
font-size: 14px;
line-height: 1.7;
}
.pay-hint {
margin: 0;
padding: 16px;
background: #f8fafc;
color: #64748b;
font-size: 14px;
text-align: center;
border-radius: 8px;
}
.pay-dialog-footer {
display: flex;
justify-content: center;
padding-top: 8px;
}
:global(.order-pay-dialog) {
border-radius: 16px;
}
:global(.order-pay-dialog .el-dialog__header) {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
:global(.order-pay-dialog .el-dialog__body) {
padding: 24px;
}
:global(.order-pay-dialog .el-dialog__footer) {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
@media (max-width: 768px) {
.content-layout {
grid-template-columns: 1fr;
}
.action-sidebar {
position: static;
order: -1;
}
.header-top {
flex-direction: column;
}
.detail-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-grid {
grid-template-columns: 1fr;
}
.checkout-resource-row {
grid-template-columns: 1fr;
}
.checkout-resource-amount {
text-align: left;
}
.checkout-deduct-preview {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .el-button {
width: 100%;
}
.pay-qr-section {
padding: 16px;
}
}
</style>