From bd9c88d659512e4913c371fdca9bed6eb69ff00e Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 14 Jun 2026 10:59:18 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=A2=E5=8D=95=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=8F=8C=E8=A7=86=E5=9B=BE:=20=E6=8A=BD=E5=8F=96=20us?= =?UTF-8?q?eOrderActions=20+=20useOrderPaymentCashier=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全部通过。 --- .../composables/useMobilePaymentCashier.ts | 178 ++--- .../orders/composables/useOrderActions.ts | 609 ++++++++++++++++++ .../composables/useOrderPaymentCashier.ts | 179 +++++ .../orders/views/MobileOrderDetailView.vue | 534 +++------------ .../features/orders/views/OrderDetailView.vue | 562 +++------------- 5 files changed, 1007 insertions(+), 1055 deletions(-) create mode 100644 frontend/src/features/orders/composables/useOrderActions.ts create mode 100644 frontend/src/features/orders/composables/useOrderPaymentCashier.ts diff --git a/frontend/src/features/orders/composables/useMobilePaymentCashier.ts b/frontend/src/features/orders/composables/useMobilePaymentCashier.ts index 35d4a96..d10b711 100644 --- a/frontend/src/features/orders/composables/useMobilePaymentCashier.ts +++ b/frontend/src/features/orders/composables/useMobilePaymentCashier.ts @@ -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 - -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(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) +} diff --git a/frontend/src/features/orders/composables/useOrderActions.ts b/frontend/src/features/orders/composables/useOrderActions.ts new file mode 100644 index 0000000..71a9f6c --- /dev/null +++ b/frontend/src/features/orders/composables/useOrderActions.ts @@ -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 + /** 需要走二次确认的 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 | Promise + /** 修改结账提交成功后触发(如关闭弹窗)。可选。 */ + 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(null) + const handoffRecords = ref([]) + const handoffContent = ref('') + + // Forms + const checkoutForm = ref(createCheckoutForm()) + const resourceUsage = ref>({}) + const counterForm = ref(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, + } +} diff --git a/frontend/src/features/orders/composables/useOrderPaymentCashier.ts b/frontend/src/features/orders/composables/useOrderPaymentCashier.ts new file mode 100644 index 0000000..d8c9a3f --- /dev/null +++ b/frontend/src/features/orders/composables/useOrderPaymentCashier.ts @@ -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 + +/** + * 从支付单提取实际支付 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-dialog(v-model 一个 ref),Mobile 用 van-popup。返回 true 表示用 ref,false 表示 composable 内部 ref。 */ +} + +/** + * 支付收银台 Composable(PC / Mobile 共用)。 + * + * 封装支付单创建后的 UI 流程:HTTP URL 直接跳转 / 二维码渲染 + 轮询查询 / 兜底提示。 + * UI 反馈通过 options 注入;轮询间隔、二维码宽度、外部跳转等差异通过参数控制。 + */ +export function useOrderPaymentCashier(options: UseOrderPaymentCashierOptions) { + const paymentPopupVisible = ref(false) + const activePayment = ref(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, + } +} diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index 4dcc829..2296114 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -1,99 +1,28 @@ +