重构订单详情双视图: 抽取 useOrderActions + useOrderPaymentCashier 消除重复

OrderDetailView (PC) 与 MobileOrderDetailView 共 13 个 handler + 9 个 computed 逐字重复, 差异只在 UI 反馈原语 (ElMessage vs showToast)、二次确认交互、支付外壳。本次按 usePublishForm 范式抽取共享 composable, 业务逻辑收敛、UI 反馈靠 options 注入。

新增:
- useOrderActions: 订单详情业务逻辑 (load/cancel/pay/handoff/confirm/checkout 五件套/dispute 两件套/evidence upload), 持有状态/表单/computed, UI 反馈与二次确认通过 options 注入, 复用 useOrderSnapshot 的纯函数与 useOrderCheckoutSnapshot
- useOrderPaymentCashier: 通用支付收银台 (二维码渲染 + 轮询 + App 浏览器跳转兜底), 反馈靠 options 注入, 轮询间隔/二维码宽度/外部跳转参数化

改造:
- useMobilePaymentCashier 改为 useOrderPaymentCashier 的薄封装, 注入 vant toast/showDialog + 微信/支付宝内嵌浏览器跳转, 对外 API 名不变
- OrderDetailView 1505→1120, MobileOrderDetailView 1253→896, 视图净减 742 行重复代码
- 修复 PC 缺失的 rejectReason/disputeDescription 非空校验 (现网 PC 遗漏, 与 Mobile 对齐)

关键不变式:
- handleRejectCheckout 用现网 createDispute(type:'checkout_dispute') 语义, 未引入死代码 useSettlement 的 counterCheckout bug
- 轮询间隔 PC 3000ms / Mobile 2500ms 通过 pollIntervalMs 注入, 行为不变
- 二次确认 Mobile 4 项 (cancel/confirmCheckout/acceptCheckout/cancelDispute) 走 showDialog, PC 直接放行
- autoPay (route.query.pay==='1') 触发逻辑保留

chatPathPrefix 移入 options 并加 onCounterCheckoutSuccess/onRejectCheckoutSuccess/onCreateDisputeSuccess 三个可选 UI 回调: 修复 PC template @click="openOrderChat" 误把 PointerEvent 当路由前缀 (跳成 [object PointerEvent]), 以及 Mobile 修改结账/拒绝修正/提交申诉成功后 popup 不关闭的回归。

vue-tsc / eslint / vite build 全部通过。
This commit is contained in:
yml
2026-06-14 10:59:18 +08:00
parent 6ff7692f3e
commit bd9c88d659
5 changed files with 1007 additions and 1055 deletions
@@ -1,150 +1,56 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import QRCode from 'qrcode'
import { showDialog, showToast } from 'vant'
import { queryOrderPayment, type PaymentOrder } from '@/features/orders/api/orders'
import {
paymentPayURL,
useOrderPaymentCashier,
} from '@/features/orders/composables/useOrderPaymentCashier'
type PaidHandler = () => Promise<void> | void
export function mobilePaymentPayURL(payment?: PaymentOrder | null) {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
function isInAppPaymentBrowser() {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
return /MicroMessenger|AlipayClient/i.test(ua)
}
function isHTTPURL(value: string) {
return /^https?:\/\//i.test(value)
}
export { paymentPayURL as mobilePaymentPayURL }
/**
* 移动端支付收银台 Composable。
*
* 在通用 {@link useOrderPaymentCashier} 基础上注入 vant 的 toast 提示,
* 并处理微信 / 支付宝内嵌浏览器的直接跳转拉起支付。对外 API 名保持不变,
* Mobile 视图无需改动。
*/
export function useMobilePaymentCashier() {
const paymentPopupVisible = ref(false)
const activePayment = ref<PaymentOrder | null>(null)
const paymentQRCodeURL = ref('')
const qrGenerating = ref(false)
const checkingPayment = ref(false)
let paymentPollingTimer: number | undefined
let paidHandler: PaidHandler | undefined
const payURL = computed(() => mobilePaymentPayURL(activePayment.value))
async function openMobilePaymentCashier(payment: PaymentOrder, onPaid?: PaidHandler) {
activePayment.value = payment
paidHandler = onPaid
if (payment.paid) {
await handlePaid()
return
}
const currentPayURL = mobilePaymentPayURL(payment)
if (currentPayURL && isHTTPURL(currentPayURL) && isInAppPaymentBrowser()) {
window.location.href = currentPayURL
return
}
if (currentPayURL && isHTTPURL(currentPayURL)) {
paymentPopupVisible.value = true
await renderPaymentQRCode(payment)
startPaymentPolling()
return
}
await showDialog({
title: '订单支付',
message: currentPayURL || '支付单已创建,请稍后刷新订单状态。',
confirmButtonText: '知道了',
})
function isInAppPaymentBrowser(): boolean {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
return /MicroMessenger|AlipayClient/i.test(ua)
}
async function renderPaymentQRCode(payment = activePayment.value) {
const currentPayURL = mobilePaymentPayURL(payment)
paymentQRCodeURL.value = ''
if (!currentPayURL || !isHTTPURL(currentPayURL)) return
qrGenerating.value = true
try {
paymentQRCodeURL.value = await QRCode.toDataURL(currentPayURL, {
width: 220,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
const cashier = useOrderPaymentCashier({
notifySuccess: message => showToast({ message, icon: 'passed' }),
notifyError: message => showToast({ message, icon: 'cross' }),
notifyInfo: message => showToast({ message, icon: 'info-o' }),
notifyFallback: message => {
void showDialog({
title: '订单支付',
message,
confirmButtonText: '知道了',
})
} catch {
showToast({ message: '二维码生成失败', icon: 'cross' })
} finally {
qrGenerating.value = false
}
}
function startPaymentPolling() {
stopPaymentPolling()
paymentPollingTimer = window.setInterval(() => {
void refreshPaymentStatus(true)
}, 2500)
}
function stopPaymentPolling() {
if (paymentPollingTimer === undefined) return
window.clearInterval(paymentPollingTimer)
paymentPollingTimer = undefined
}
async function refreshPaymentStatus(silent = false) {
if (!activePayment.value || checkingPayment.value) return
checkingPayment.value = true
const previousPayURL = payURL.value
try {
const payment = await queryOrderPayment(activePayment.value.order_id)
activePayment.value = payment
if (payment.paid) {
await handlePaid()
return
},
paidMessage: '支付成功,等待号主交接',
pollIntervalMs: 2500,
qrWidth: 220,
openExternalURL: url => {
if (isHTTPURL(url) && isInAppPaymentBrowser()) {
window.location.href = url
return true
}
if (mobilePaymentPayURL(payment) !== previousPayURL) {
await renderPaymentQRCode(payment)
}
if (!silent) {
showToast({ message: '支付暂未完成', icon: 'info-o' })
}
} catch {
if (!silent) {
showToast({ message: '查询支付状态失败', icon: 'cross' })
}
} finally {
checkingPayment.value = false
}
}
async function handlePaid() {
stopPaymentPolling()
paymentPopupVisible.value = false
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await paidHandler?.()
}
watch(paymentPopupVisible, visible => {
if (!visible) stopPaymentPolling()
return false
},
})
onBeforeUnmount(stopPaymentPolling)
return {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openMobilePaymentCashier,
refreshPaymentStatus,
stopPaymentPolling,
...cashier,
// 保持 Mobile 视图既有调用名。
openMobilePaymentCashier: cashier.openPaymentCashier,
}
}
function isHTTPURL(value: string): boolean {
return /^https?:\/\//i.test(value)
}
@@ -0,0 +1,609 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { cancelOrderDispute, createDispute } from '@/features/disputes/api/disputes'
import { uploadFile } from '@/shared/api/files'
import { readError } from '@/shared/utils/error'
import {
acceptCheckout,
cancelOrder,
confirmCheckout,
confirmReceive,
counterCheckout,
fetchHandoffRecords,
fetchOrder,
startOrderPayment,
submitCheckout,
submitHandoff,
type HandoffRecord,
type Order,
type PaymentOrder,
} from '@/features/orders/api/orders'
import {
hydrateCounterFormFromCheckout,
linesToList,
orderEstimatedEndAt as readOrderEstimatedEndAt,
orderRentAmount,
ownerActualIncome,
useOrderCheckoutSnapshot,
} from '@/features/orders/composables/useOrderSnapshot'
import { useSessionStore } from '@/stores/session'
import { formatListingNo } from '@/shared/utils/listingDisplay'
/**
* 订单详情交互 Composable
*
* 承载订单详情双视图(PC / Mobile)共享的业务逻辑:状态、计算属性、handler。
* UI 反馈(ElMessage / showToast)与二次确认(ElMessageBox / showDialog)通过 options 注入,
* 文案与业务流程(API 调用顺序、参数构造、表单 reset、autoPay 触发)归本 composable。
*
* 设计参考:{@link ./usePublishForm.ts}PC / Mobile 共用 composable 范式)。
*/
export interface UseOrderActionsOptions {
/** 成功提示,注入端决定 UI 形态(ElMessage.success / showToast)。 */
notifySuccess: (message: string) => void
/** 错误提示,注入端决定 UI 形态。 */
notifyError: (message: string) => void
/** 警告提示,用于表单校验未通过等非致命场景。 */
notifyWarning: (message: string) => void
/** 二次确认。返回 true 表示用户确认继续,false 表示取消。PC 注入直接 resolve(true)Mobile 注入 showDialog。 */
confirm: (opts: { title: string; message: string }) => Promise<boolean>
/** 需要走二次确认的 handler 名集合。Mobile 传 ['cancel','confirmCheckout','acceptCheckout','cancelDispute']PC 传 []。 */
actionsNeedingConfirm: readonly string[]
/** 订单列表跳转路径,PC '/orders' / Mobile '/m/orders'。 */
ordersPath: string
/** 订单群聊路由前缀,PC '/messages/' / Mobile '/m/chats/'。openOrderChat 内部拼接 `${prefix}${chat.id}`。 */
chatPathPrefix: string
/**
* 打开收银台 UI(二维码弹窗 / App 浏览器跳转)。由调用方注入。
* 仅在 payment 未支付时被调用;payment 已支付的情况由 composable 自行提示并 loadOrder。
* onPaid:支付完成(轮询发现 paid)时的回调,通常传入 loadOrder。
*/
startCashier: (payment: PaymentOrder, onPaid: () => Promise<void>) => void | Promise<void>
/** 修改结账提交成功后触发(如关闭弹窗)。可选。 */
onCounterCheckoutSuccess?: () => void
/** 拒绝修正提交成功后触发(如关闭弹窗)。可选。 */
onRejectCheckoutSuccess?: () => void
/** 提交申诉成功后触发(如关闭弹窗)。可选。 */
onCreateDisputeSuccess?: () => void
}
/** handler 名常量,供 actionsNeedingConfirm 校验。 */
export const CONFIRMABLE_ACTIONS = [
'cancel',
'confirmCheckout',
'acceptCheckout',
'cancelDispute',
] as const
export interface CheckoutFormState {
content: string
consumableAmountYuan: number
coin_consumed_m: number
otherAmountYuan: number
evidenceText: string
}
export interface CounterFormState {
consumableAmountYuan: number
coin_consumed_m: number
otherAmountYuan: number
depositDeductAmountYuan: number
reason: string
evidenceText: string
}
function createCheckoutForm(): CheckoutFormState {
return {
content: '',
consumableAmountYuan: 0,
coin_consumed_m: 0,
otherAmountYuan: 0,
evidenceText: '',
}
}
function createCounterForm(): CounterFormState {
return {
consumableAmountYuan: 0,
coin_consumed_m: 0,
otherAmountYuan: 0,
depositDeductAmountYuan: 0,
reason: '',
evidenceText: '',
}
}
export function useOrderActions(options: UseOrderActionsOptions) {
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
// Loading states
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 cancellingDispute = ref(false)
const uploadingEvidence = ref(false)
const openingChat = ref(false)
// Data
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const handoffContent = ref('')
// Forms
const checkoutForm = ref<CheckoutFormState>(createCheckoutForm())
const resourceUsage = ref<Record<string, number>>({})
const counterForm = ref<CounterFormState>(createCounterForm())
const rejectReason = ref('')
const disputeType = ref('cannot_login')
const disputeDescription = ref('')
const disputeEvidenceText = ref('')
// 结账快照能力(资源金额、内容摘要、资源使用量规范化)
const {
checkoutResources,
resourceChargeAmount,
snapshotHafCoinM,
remainingHafCoinM,
hydrateResourceUsage,
resourceLineAmount,
checkoutContentWithSummary,
} = useOrderCheckoutSnapshot({ order, checkoutForm, resourceUsage })
// Computed
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const listingCode = computed(() =>
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
)
const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金'))
const orderRentDisplayAmount = computed(() =>
order.value ? orderRentAmount(order.value, session.userId) : 0
)
const ownerIncomeDisplayAmount = computed(() =>
order.value ? ownerActualIncome(order.value, session.userId) : 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 canCancelDispute = computed(() => {
return (
!!order.value &&
(isOwner.value || isRenter.value) &&
['disputing', 'checkout_disputing'].includes(order.value.status) &&
order.value.active_dispute?.initiator_id === session.userId
)
})
const isCheckoutDisputeStage = computed(() => {
return (
!!order.value &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
)
})
let autoPayHandled = false
function needsConfirm(action: string): boolean {
return options.actionsNeedingConfirm.includes(action)
}
function hydrateCounterForm() {
if (!order.value?.checkout) return
counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout)
}
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()
}
} catch (error) {
options.notifyError(readError(error, '加载订单失败'))
} finally {
loading.value = false
}
}
async function handleCancel() {
if (!order.value) return
if (needsConfirm('cancel')) {
const ok = await options.confirm({
title: '取消订单',
message: '确定取消订单并释放账号吗?',
})
if (!ok) return
}
cancelling.value = true
try {
await cancelOrder(order.value.id)
options.notifySuccess('订单已取消,账号已释放')
await router.push(options.ordersPath)
} catch (error) {
options.notifyError(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) {
options.notifySuccess('支付成功,等待号主交接')
await loadOrder()
return
}
await options.startCashier(payment, loadOrder)
} catch (error) {
options.notifyError(readError(error, '支付失败'))
} finally {
startingPayment.value = false
}
}
async function handleSubmitHandoff() {
if (!order.value) return
if (!handoffContent.value.trim()) {
options.notifyWarning('请填写交接说明')
return
}
handoffing.value = true
try {
await submitHandoff(order.value.id, handoffContent.value)
handoffContent.value = ''
options.notifySuccess('交接说明已提交')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '提交交接失败'))
} finally {
handoffing.value = false
}
}
async function handleConfirmReceive() {
if (!order.value) return
confirming.value = true
try {
await confirmReceive(order.value.id)
options.notifySuccess('已确认收号,订单进入使用中')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '确认收号失败'))
} finally {
confirming.value = false
}
}
async function handleSubmitCheckout() {
if (!order.value) return
returning.value = true
try {
const consumableAmount = resourceChargeAmount.value
checkoutForm.value.consumableAmountYuan = consumableAmount
await submitCheckout(order.value.id, {
content: checkoutContentWithSummary(),
consumableAmountYuan: consumableAmount,
coin_consumed_m: checkoutForm.value.coin_consumed_m,
otherAmountYuan: checkoutForm.value.otherAmountYuan,
evidence_urls: linesToList(checkoutForm.value.evidenceText),
})
checkoutForm.value = createCheckoutForm()
resourceUsage.value = {}
options.notifySuccess('结账已发起,等待号主确认')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '发起结账失败'))
} finally {
returning.value = false
}
}
async function handleConfirmCheckout() {
if (!order.value) return
if (needsConfirm('confirmCheckout')) {
const ok = await options.confirm({
title: '确认结账',
message: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
})
if (!ok) return
}
completing.value = true
try {
await confirmCheckout(order.value.id)
options.notifySuccess('结账已确认,订单完成')
await loadOrder()
} catch (error) {
options.notifyError(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,
consumableAmountYuan: counterForm.value.consumableAmountYuan,
coin_consumed_m: counterForm.value.coin_consumed_m,
otherAmountYuan: counterForm.value.otherAmountYuan,
depositDeductAmountYuan: counterForm.value.depositDeductAmountYuan,
reason: reasonText,
evidence_urls: linesToList(counterForm.value.evidenceText),
})
options.notifySuccess('结账修正已提交,等待租客确认')
await loadOrder()
options.onCounterCheckoutSuccess?.()
} catch (error) {
options.notifyError(readError(error, '修改结账失败'))
} finally {
countering.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
if (needsConfirm('acceptCheckout')) {
const ok = await options.confirm({
title: '同意修正',
message: '确定同意号主的结账修正提案吗?同意后订单将完成结算。',
})
if (!ok) return
}
acceptingCheckout.value = true
try {
await acceptCheckout(order.value.id)
options.notifySuccess('已确认修正结账,订单完成')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '确认修正失败'))
} finally {
acceptingCheckout.value = false
}
}
async function handleRejectCheckout() {
if (!order.value) return
if (!rejectReason.value.trim()) {
options.notifyWarning('请填写不同意原因')
return
}
rejectingCheckout.value = true
try {
await createDispute(order.value.id, {
type: 'checkout_dispute',
description: rejectReason.value,
evidence_urls: [],
})
rejectReason.value = ''
options.notifySuccess('已拒绝修正并提交争议')
await loadOrder()
options.onRejectCheckoutSuccess?.()
} catch (error) {
options.notifyError(readError(error, '提交拒绝失败'))
} finally {
rejectingCheckout.value = false
}
}
async function handleCreateDispute() {
if (!order.value) return
if (!disputeDescription.value.trim()) {
options.notifyWarning('请填写争议经过说明')
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 = ''
options.notifySuccess('申诉已提交,订单进入仲裁')
await loadOrder()
options.onCreateDisputeSuccess?.()
} catch (error) {
options.notifyError(readError(error, '提交申诉失败'))
} finally {
disputing.value = false
}
}
async function handleCancelDispute() {
if (!order.value) return
if (needsConfirm('cancelDispute')) {
const ok = await options.confirm({
title: order.value.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉',
message: '取消后订单将恢复到发起申诉前的流程,确定继续吗?',
})
if (!ok) return
}
cancellingDispute.value = true
try {
await cancelOrderDispute(order.value.id)
options.notifySuccess('申诉已取消,订单已恢复')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '取消申诉失败'))
} finally {
cancellingDispute.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')
options.notifySuccess('证据文件已上传')
} catch (error) {
options.notifyError(readError(error, '上传失败'))
} finally {
uploadingEvidence.value = false
}
}
/**
* 反驳结账证据上传(Mobile 反驳弹窗独有:把 URL 写入 counterForm.evidenceText)。
* PC 不使用,保留以供 Mobile 视图调用。
*/
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')
options.notifySuccess('证据文件已上传')
} catch (error) {
options.notifyError(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(`${options.chatPathPrefix}${chat.id}`)
} catch {
options.notifyError('订单群聊暂不可用')
} finally {
openingChat.value = false
}
}
function orderEstimatedEndAt() {
return readOrderEstimatedEndAt(order.value)
}
function orderRentedAt() {
return order.value?.rented_at
}
onMounted(loadOrder)
return {
// Loading states
loading,
cancelling,
startingPayment,
handoffing,
confirming,
returning,
completing,
countering,
acceptingCheckout,
rejectingCheckout,
disputing,
cancellingDispute,
uploadingEvidence,
openingChat,
// Data
order,
handoffRecords,
handoffContent,
// Forms
checkoutForm,
resourceUsage,
counterForm,
rejectReason,
disputeType,
disputeDescription,
disputeEvidenceText,
// Checkout snapshot
checkoutResources,
resourceChargeAmount,
snapshotHafCoinM,
remainingHafCoinM,
hydrateResourceUsage,
resourceLineAmount,
checkoutContentWithSummary,
// Computed
isOwner,
isRenter,
listingCode,
orderAmountLabel,
orderRentDisplayAmount,
ownerIncomeDisplayAmount,
ownerIncomeLabel,
canOpenDispute,
canCancelDispute,
isCheckoutDisputeStage,
// Methods
loadOrder,
handleCancel,
handlePay,
handleSubmitHandoff,
handleConfirmReceive,
handleSubmitCheckout,
handleConfirmCheckout,
handleCounterCheckout,
handleAcceptCheckout,
handleRejectCheckout,
handleCreateDispute,
handleCancelDispute,
handleEvidenceUpload,
handleCounterEvidenceUpload,
openOrderChat,
orderEstimatedEndAt,
orderRentedAt,
hydrateCounterForm,
}
}
@@ -0,0 +1,179 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import QRCode from 'qrcode'
import { queryOrderPayment, type PaymentOrder } from '@/features/orders/api/orders'
type PaidHandler = () => Promise<void> | void
/**
* 从支付单提取实际支付 URL。顺序与现网 PC/Mobile 一致:jspay_url → td_code → jspay_info。
*/
export function paymentPayURL(payment?: PaymentOrder | null): string {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
function isHTTPURL(value: string): boolean {
return /^https?:\/\//i.test(value)
}
export interface UseOrderPaymentCashierOptions {
/** 成功提示。 */
notifySuccess: (message: string) => void
/** 错误提示(如二维码生成失败、查询支付状态失败)。 */
notifyError: (message: string) => void
/** 非静默查询未完成时的轻量提示("支付暂未完成")。 */
notifyInfo: (message: string) => void
/**
* 兜底提示:支付 URL 非 HTTP 或为空、无法渲染二维码时展示。
* Mobile 用 vant dialog(带"知道了"按钮)做强提示,PC 用普通提示即可。
*/
notifyFallback: (message: string) => void
/** 支付完成的提示文案,默认 "支付成功,等待号主交接"。 */
paidMessage?: string
/** 轮询间隔,PC 3000 / Mobile 2500。 */
pollIntervalMs?: number
/** 二维码宽度,PC 240 / Mobile 220。 */
qrWidth?: number
/**
* 拦截 HTTP 支付 URL 的外部打开方式。
* Mobile(微信/支付宝内嵌浏览器)注入 `window.location.href = url` 直接拉起支付;
* PC 不注入(直接渲染二维码弹窗)。返回 true 表示已处理(不再走弹窗),false 走默认弹窗流程。
*/
openExternalURL?: (url: string) => boolean
/** 弹窗显隐控制方式。PC 用 el-dialogv-model 一个 ref),Mobile 用 van-popup。返回 true 表示用 reffalse 表示 composable 内部 ref。 */
}
/**
* 支付收银台 ComposablePC / Mobile 共用)。
*
* 封装支付单创建后的 UI 流程:HTTP URL 直接跳转 / 二维码渲染 + 轮询查询 / 兜底提示。
* UI 反馈通过 options 注入;轮询间隔、二维码宽度、外部跳转等差异通过参数控制。
*/
export function useOrderPaymentCashier(options: UseOrderPaymentCashierOptions) {
const paymentPopupVisible = ref(false)
const activePayment = ref<PaymentOrder | null>(null)
const paymentQRCodeURL = ref('')
const qrGenerating = ref(false)
const checkingPayment = ref(false)
let paymentPollingTimer: number | undefined
let paidHandler: PaidHandler | undefined
const pollIntervalMs = options.pollIntervalMs ?? 2500
const qrWidth = options.qrWidth ?? 220
const paidMessage = options.paidMessage ?? '支付成功,等待号主交接'
const payURL = computed(() => paymentPayURL(activePayment.value))
async function openPaymentCashier(payment: PaymentOrder, onPaid?: PaidHandler) {
activePayment.value = payment
paidHandler = onPaid
if (payment.paid) {
await handlePaid()
return
}
const currentPayURL = paymentPayURL(payment)
if (currentPayURL && options.openExternalURL?.(currentPayURL)) {
return
}
if (currentPayURL && isHTTPURL(currentPayURL)) {
paymentPopupVisible.value = true
await renderPaymentQRCode(payment)
startPaymentPolling()
return
}
// 非 HTTP URL(或空):无法渲染二维码,仅展示兜底提示。
options.notifyFallback(currentPayURL || '支付单已创建,请稍后刷新订单状态。')
}
async function renderPaymentQRCode(payment = activePayment.value) {
const currentPayURL = paymentPayURL(payment)
paymentQRCodeURL.value = ''
if (!currentPayURL || !isHTTPURL(currentPayURL)) return
qrGenerating.value = true
try {
paymentQRCodeURL.value = await QRCode.toDataURL(currentPayURL, {
width: qrWidth,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
})
} catch {
options.notifyError('二维码生成失败')
} finally {
qrGenerating.value = false
}
}
function startPaymentPolling() {
stopPaymentPolling()
paymentPollingTimer = window.setInterval(() => {
void refreshPaymentStatus(true)
}, pollIntervalMs)
}
function stopPaymentPolling() {
if (paymentPollingTimer === undefined) return
window.clearInterval(paymentPollingTimer)
paymentPollingTimer = undefined
}
async function refreshPaymentStatus(silent = false) {
if (!activePayment.value || checkingPayment.value) return
checkingPayment.value = true
const previousPayURL = payURL.value
try {
const payment = await queryOrderPayment(activePayment.value.order_id)
activePayment.value = payment
if (payment.paid) {
await handlePaid()
return
}
if (paymentPayURL(payment) !== previousPayURL) {
await renderPaymentQRCode(payment)
}
if (!silent) {
options.notifyInfo('支付暂未完成')
}
} catch {
if (!silent) {
options.notifyError('查询支付状态失败')
}
} finally {
checkingPayment.value = false
}
}
async function handlePaid() {
stopPaymentPolling()
paymentPopupVisible.value = false
options.notifySuccess(paidMessage)
await paidHandler?.()
}
watch(paymentPopupVisible, visible => {
if (!visible) stopPaymentPolling()
})
onBeforeUnmount(stopPaymentPolling)
return {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openPaymentCashier,
refreshPaymentStatus,
stopPaymentPolling,
}
}