Files
hfb_sys/frontend/src/features/orders/views/MobileOrderDetailView.vue
T
yml2213 e36bdd28c5 fix(order): 解决移动端弹窗关闭时点击穿透导致弹窗自动重新弹出问题
- 根因定位:移动端点击关闭按钮后弹窗退场,浏览器在~300ms派发合成点击事件,恰好落在底层全宽的「修改结账方案」动作按钮上,导致关闭后立即被再次触发唤起;原关闭按钮触控面积小(12px)也容易引发手抖连点
- 防穿透冷却锁:引入 POPUP_COOLDOWN_MS 冷却机制,弹窗关闭后450ms内拦截底层触发按钮的一切点击唤起请求,彻底隔绝幽灵点击与连击
- 弹窗事件与关闭解耦:关闭按钮添加 @click.stop.prevent 与 type="button",规范监听 van-popup 的 @close 回调以确保遮罩层点击同步触发冷却
- 触控体验优化:将原文字关闭符号升级为 van-icon cross,增大触控靶心至 36px 圆形并添加 :active 反馈与 touch-action: manipulation
- 样式增强:重构数字输入框为带实体边框、聚焦高亮光晕与单位后缀的独立卡片,解决输入框外观类似只读文本的问题
- 验证通过:通过 npm test 与全量门禁 ./scripts/check.sh(Go测试、MySQL测试、TS类型检查、Vite生产构建 100% 通过)
2026-10-10 18:13:09 +08:00

819 lines
26 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, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showDialog, showToast } from 'vant'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
import CheckoutTrialPreview from '@/features/orders/components/CheckoutTrialPreview.vue'
import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutSimulator'
import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
import { amountYuan, money, orderRentalTerms } from '@/features/orders/composables/useOrderSnapshot'
import {
OrderAccountSnapshot,
OrderCheckoutSummary,
OrderHandoffTimeline,
OrderResourceUsageEditor,
} from '@/features/orders'
import { orderHandoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
// 移动端支付收银台(基于通用 useOrderPaymentCashier 的薄封装:注入 vant toast + App 浏览器跳转)。
const cashier = useMobilePaymentCashier()
const router = useRouter()
// 支付方式选择底部弹层(与 PC 端一致的渠道卡片体验)。
const payWaySelect = useMobilePayWaySelect()
const selectPayWay = payWaySelect.select
const {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
refreshPaymentStatus,
} = cashier
const {
loading,
cancelling,
startingPayment: paying,
handoffing,
confirming,
returning,
completing,
countering,
acceptingCheckout,
rejectingCheckout,
disputing,
cancellingDispute,
openingChat,
order,
handoffRecords,
handoffContent,
checkoutForm,
resourceUsage,
counterForm,
rejectReason,
disputeType,
disputeDescription,
disputeEvidenceText,
checkoutResources,
resourceChargeAmount,
snapshotHafCoinM,
remainingHafCoinM,
resourceLineAmount,
isOwner,
isRenter,
listingCode,
orderAmountLabel,
orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount,
ownerIncomeLabel,
canOpenDispute,
canCancelDispute,
canCounterCheckout,
canAcceptCheckoutProposal,
checkoutShortfallCent,
checkoutRoundLabel,
isCheckoutDisputeStage,
handleCancel,
handlePay,
handleSubmitHandoff,
handleConfirmReceive,
handleSubmitCheckout,
handleConfirmCheckout,
handleCounterCheckout,
handleAcceptCheckout,
handleRejectCheckout,
handleCreateDispute,
handleCancelDispute,
handleEvidenceUpload,
handleCounterEvidenceUpload,
openOrderChat,
orderEstimatedEndAt,
orderRentedAt,
} = useOrderActions({
notifySuccess: message => showToast({ message, icon: 'passed' }),
notifyError: message => showToast({ message, icon: 'cross' }),
notifyWarning: message => showToast({ message, icon: 'warning-o' }),
confirm: async ({ title, message }) => {
try {
await showDialog({ title, message, showCancelButton: true })
return true
} catch {
return false
}
},
actionsNeedingConfirm: CONFIRMABLE_ACTIONS,
selectPayWay,
ordersPath: '/m/orders',
chatPathPrefix: '/m/chats/',
startCashier: (payment, onPaid) => cashier.openMobilePaymentCashier(payment, onPaid),
onCounterCheckoutSuccess: () => {
markPopupClosed()
showCounterPopup.value = false
},
onRejectCheckoutSuccess: () => {
markPopupClosed()
showRejectPopup.value = false
},
onCreateDisputeSuccess: () => {
markPopupClosed()
showDisputePopup.value = false
},
})
const rentalTerms = computed(() => orderRentalTerms(order.value))
// Mobile 独有 UI 状态:各类 popup 显隐。
const showDisputePopup = ref(false)
const showCounterPopup = ref(false)
const showRejectPopup = ref(false)
// 弹窗防穿透/防抖冷却:防止移动端/iOS Safari 点击关闭时 300ms 穿透触发底层按钮重新唤起
const POPUP_COOLDOWN_MS = 450
let lastPopupClosedAt = 0
function markPopupClosed() {
lastPopupClosedAt = Date.now()
}
function canOpenPopup(): boolean {
return Date.now() - lastPopupClosedAt >= POPUP_COOLDOWN_MS
}
function openCounterPopup() {
if (!canOpenPopup()) return
showCounterPopup.value = true
}
function closeCounterPopup() {
markPopupClosed()
showCounterPopup.value = false
}
function openRejectPopup() {
if (!canOpenPopup()) return
showRejectPopup.value = true
}
function closeRejectPopup() {
markPopupClosed()
showRejectPopup.value = false
}
function openDisputePopup() {
if (!canOpenPopup()) return
showDisputePopup.value = true
}
function closeDisputePopup() {
markPopupClosed()
showDisputePopup.value = false
}
const isOwnerCheckoutPending = computed(() => {
if (!order.value || !isOwner.value) return false
return (
order.value.status === 'pending_checkout_confirm' ||
(order.value.status === 'abnormal' &&
(order.value.handoff_status === 'owner_checkout_confirm_timeout' ||
order.value.handoff_status === 'owner_return_confirm_timeout' ||
order.value.handoff_status === 'pending_owner_checkout'))
)
})
const showCheckoutCounterAction = computed(
() =>
!!order.value &&
canCounterCheckout.value &&
(isOwnerCheckoutPending.value ||
(isRenter.value && order.value.status === 'pending_checkout_accept'))
)
const checkoutSimulation = useCheckoutSimulator({
order,
isOwner,
isRenter,
form: counterForm,
})
// 状态颜色映射(vant 标签主题)。
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>
<p v-if="rentalTerms.rentalDays || rentalTerms.dailyLossText" class="order-terms">
<span v-if="rentalTerms.rentalDays" class="term-rental"
>租期 {{ rentalTerms.rentalDays }}天</span
>
<span v-if="rentalTerms.dailyLossText" class="term-loss"
>消耗 {{ rentalTerms.dailyLossText }}</span
>
</p>
<div class="order-meta-grid">
<div class="meta-item">
<span class="meta-label">{{ orderAmountLabel }}</span>
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
<span v-if="isRenter && renterDiscountDisplayCent > 0" class="meta-note">
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
money(amountYuan(renterDiscountDisplayCent))
}}
</span>
</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">{{ orderHandoffStatusLabel(order) }}</strong>
</div>
<div v-if="isRenter && order.status === 'completed' && !order.checkout" class="meta-item">
<span class="meta-label">实际纯币原价</span>
<strong class="meta-value accent"
>¥{{ money(amountYuan(order.actual_pure_coin_amount_cent)) }}</strong
>
</div>
<div v-if="isRenter && order.status === 'completed' && !order.checkout" class="meta-item">
<span class="meta-label">本单成长积分</span>
<strong class="meta-value income">+{{ order.growth_points_awarded || 0 }}</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.status === 'abnormal') &&
(order.handoff_status === 'pending_owner' || order.handoff_status === 'owner_timeout')
"
class="card-section action-card"
>
<h3 class="section-title">提交交接说明</h3>
<p
v-if="order.handoff_status === 'owner_timeout'"
class="action-hint"
style="color: #e6a23c; font-weight: 500"
>
当前交接已超时,请尽快补提交交接说明避免订单被关闭。
</p>
<p v-else 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 -->
<OrderHandoffTimeline
variant="mobile"
:records="handoffRecords"
:listing-code="listingCode"
/>
<!-- Renter Action: Return Account & Initiate Checkout -->
<OrderResourceUsageEditor
v-if="isRenter && ['renting', 'overdue'].includes(order.status)"
v-model:checkout-form="checkoutForm"
v-model:resource-usage="resourceUsage"
variant="mobile"
:resources="checkoutResources"
:resource-charge-amount="resourceChargeAmount"
:snapshot-haf-coin-m="snapshotHafCoinM"
:remaining-haf-coin-m="remainingHafCoinM"
:returning="returning"
:resource-line-amount="resourceLineAmount"
@submit="handleSubmitCheckout"
/>
<!-- Checkout Details Display(结账中 / 已完成均可查看) -->
<OrderCheckoutSummary
v-if="
order.checkout &&
([
'pending_checkout_confirm',
'pending_checkout_accept',
'checkout_disputing',
'completed',
].includes(order.status) ||
order.status === 'abnormal')
"
variant="mobile"
:order="order"
:is-owner="isOwner"
:is-renter="isRenter"
/>
<!-- Owner Action: Confirm Checkout / Make Counter Adjustment Proposal -->
<section v-if="isOwnerCheckoutPending" class="card-section action-card">
<h3 class="section-title">结账处理</h3>
<p
v-if="order.handoff_status === 'owner_checkout_confirm_timeout'"
class="action-hint"
style="color: #e6a23c; font-weight: 500"
>
结账确认已超时,你可以直接同意当前方案完结,或修改方案交对方确认。
</p>
<p v-else class="action-hint">
{{
checkoutRoundLabel || '请核对消耗与扣款'
}}。确认无误后同意完结;也可修改方案交对方确认(最多 6
轮)。押金不足无法自动完结,请改方案或争议。
</p>
<p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444">
押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
</p>
<div class="action-buttons">
<van-button
type="primary"
block
round
:loading="completing"
:disabled="!canAcceptCheckoutProposal"
loading-text="处理中..."
@click="handleConfirmCheckout"
>
同意并确认结账
</van-button>
<van-button
v-if="showCheckoutCounterAction"
type="warning"
block
plain
round
@click="openCounterPopup"
>
修改结账方案
</van-button>
</div>
</section>
<!-- Renter Action: Accept / Counter / Dispute -->
<section
v-if="isRenter && order.status === 'pending_checkout_accept'"
class="card-section action-card"
>
<h3 class="section-title">结账方案确认</h3>
<p class="action-hint">
{{ checkoutRoundLabel || '对方已提出结账方案' }}。可同意完结、还价修改(最多 6
轮),或发起争议由人工处理。
</p>
<p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444">
押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
</p>
<div class="action-buttons">
<van-button
type="primary"
block
round
:loading="acceptingCheckout"
:disabled="!canAcceptCheckoutProposal"
loading-text="同意中..."
@click="handleAcceptCheckout"
>
同意并结账
</van-button>
<van-button
v-if="showCheckoutCounterAction"
type="warning"
block
plain
round
@click="openCounterPopup"
>
还价修改方案
</van-button>
<van-button type="danger" block plain round @click="openRejectPopup">
发起结账争议
</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="openDisputePopup">
{{ isCheckoutDisputeStage ? '对结账发起申诉争议' : '发起订单申诉争议' }}
</van-button>
</section>
<section v-if="canCancelDispute" class="card-section action-card">
<van-button
type="warning"
block
plain
round
:loading="cancellingDispute"
loading-text="取消中..."
@click="handleCancelDispute"
>
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
</van-button>
</section>
<OrderAccountSnapshot v-if="order" :order="order" variant="mobile" />
</template>
<div v-else class="empty-wrap">
<van-empty image="search" description="订单不存在或已被删除" />
</div>
<MobilePayWaySelectPopup
v-model:show="payWaySelect.visible.value"
@choose="payWaySelect.choose"
@closed="payWaySelect.handleClosed"
/>
<MobilePaymentCashierPopup
v-model:show="paymentPopupVisible"
:payment="activePayment"
:pay-url="payURL"
:qr-code-url="paymentQRCodeURL"
:qr-generating="qrGenerating"
:checking="checkingPayment"
@refresh="refreshPaymentStatus(false)"
/>
<!-- POPUP: Counter Checkout Adjustments(号主/租客) -->
<van-popup
v-model:show="showCounterPopup"
position="bottom"
round
class="mobile-popup-form"
@close="markPopupClosed"
>
<header class="popup-header">
<h3>修改结账方案</h3>
<button
type="button"
class="popup-close"
aria-label="关闭"
@click.stop.prevent="closeCounterPopup"
>
<van-icon name="cross" :size="18" />
</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
{{
checkoutRoundLabel
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
</p>
<div class="counter-input-card">
<div class="counter-input-row">
<label class="counter-label" for="counter-consumable">物资/损耗金额</label>
<div class="counter-input-wrapper">
<input
id="counter-consumable"
v-model.number="counterForm.consumableAmountYuan"
type="number"
placeholder="0"
class="counter-input-control"
/>
<span class="counter-unit">元</span>
</div>
</div>
<div class="counter-input-row">
<label class="counter-label" for="counter-coin">实际消耗哈夫币</label>
<div class="counter-input-wrapper">
<input
id="counter-coin"
v-model.number="counterForm.coin_consumed_m"
type="number"
placeholder="0"
class="counter-input-control"
/>
<span class="counter-unit">M</span>
</div>
</div>
<div class="counter-input-row">
<label class="counter-label" for="counter-deposit-deduct">押金赔付(损坏等)</label>
<div class="counter-input-wrapper">
<input
id="counter-deposit-deduct"
v-model.number="counterForm.depositDeductAmountYuan"
type="number"
placeholder="0"
class="counter-input-control"
/>
<span class="counter-unit">元</span>
</div>
</div>
</div>
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="mobile" />
<van-field
v-model="counterForm.reason"
rows="2"
autosize
required
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>
</div>
<footer class="popup-footer">
<van-button
type="warning"
block
round
:loading="countering"
loading-text="提交中..."
@click="handleCounterCheckout"
class="popup-submit"
>
提交给对方确认
</van-button>
</footer>
</van-popup>
<!-- POPUP: Renter Reject Counter Proposal Reason -->
<van-popup
v-model:show="showRejectPopup"
position="bottom"
round
class="mobile-popup-form"
@close="markPopupClosed"
>
<header class="popup-header">
<h3>拒绝修正扣款说明</h3>
<button
type="button"
class="popup-close"
aria-label="关闭"
@click.stop.prevent="closeRejectPopup"
>
<van-icon name="cross" :size="18" />
</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"
/>
</div>
<footer class="popup-footer">
<van-button
type="danger"
block
round
:loading="rejectingCheckout"
loading-text="发起争议中..."
@click="handleRejectCheckout"
class="popup-submit"
>
提交原因并提起争议
</van-button>
</footer>
</van-popup>
<!-- POPUP: Create Generic Order Dispute -->
<van-popup
v-model:show="showDisputePopup"
position="bottom"
round
class="mobile-popup-form"
@close="markPopupClosed"
>
<header class="popup-header">
<h3>发起申诉争议</h3>
<button
type="button"
class="popup-close"
aria-label="关闭"
@click.stop.prevent="closeDisputePopup"
>
<van-icon name="cross" :size="18" />
</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>
</div>
<footer class="popup-footer">
<van-button
type="warning"
block
round
:loading="disputing"
loading-text="正在发起申诉..."
@click="handleCreateDispute"
class="popup-submit"
>
提交申诉客服仲裁
</van-button>
</footer>
</van-popup>
</main>
</template>
<style scoped src="./MobileOrderDetailView.css"></style>