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, type PaymentPayWay, } 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 表示取消。 */ confirm: (opts: { title: string; message: string }) => Promise /** 需要走二次确认的 handler 名集合。 */ actionsNeedingConfirm: readonly string[] /** 支付前选择支付方式。返回 null 表示用户取消支付。 */ selectPayWay: () => Promise /** 订单列表跳转路径,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)时的回调,通常跳转到群消息。 */ startCashier: (payment: PaymentOrder, onPaid: () => Promise) => void | Promise /** 修改结账提交成功后触发(如关闭弹窗)。可选。 */ onCounterCheckoutSuccess?: () => void /** 拒绝修正提交成功后触发(如关闭弹窗)。可选。 */ onRejectCheckoutSuccess?: () => void /** 提交申诉成功后触发(如关闭弹窗)。可选。 */ onCreateDisputeSuccess?: () => void } /** handler 名常量,供 actionsNeedingConfirm 校验。 */ export const CONFIRMABLE_ACTIONS = [ 'cancel', 'confirmCheckout', 'acceptCheckout', 'cancelDispute', 'submitCheckout', ] 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 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, 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('') let checkoutDefaultsHydratedOrderID: number | null = null // 结账快照能力(资源金额、内容摘要、资源使用量规范化) 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 renterDiscountDisplayCent = computed(() => { if (!order.value) return 0 if (order.value.status === 'completed') { return Number(order.value.actual_pure_coin_discount_cent || 0) } return Number(order.value.rent_discount_amount_cent || 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 canCounterCheckout = computed(() => { if (!order.value?.checkout) return false return Boolean(order.value.checkout.can_counter) }) const canAcceptCheckoutProposal = computed(() => { if (!order.value?.checkout) return false // 后端 can_accept 已含 shortfall 校验;无字段时按角色状态兜底 if (typeof order.value.checkout.can_accept === 'boolean') { return order.value.checkout.can_accept } return ( (isOwner.value && order.value.status === 'pending_checkout_confirm') || (isRenter.value && order.value.status === 'pending_checkout_accept') ) }) const checkoutShortfallCent = computed(() => Number(order.value?.checkout?.shortfall_cent || 0)) const checkoutOvershootCent = computed(() => Number(order.value?.checkout?.overshoot_amount_cent || 0) ) const checkoutRoundLabel = computed(() => { const c = order.value?.checkout if (!c) return '' const round = Number(c.round_count || 1) const max = Number(c.max_rounds || 6) return `第 ${round}/${max} 轮协商` }) const canCancelDispute = computed(() => { return ( !!order.value && (isOwner.value || isRenter.value) && ['disputing', 'checkout_disputing'].includes(order.value.status) && order.value.active_dispute?.initiator_type !== 'admin' && 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) } function hydrateCheckoutDefaults() { if (!order.value || !isRenter.value || !['renting', 'overdue'].includes(order.value.status)) { return } if (checkoutDefaultsHydratedOrderID === order.value.id) return const defaultCoinConsumedM = snapshotHafCoinM.value if (defaultCoinConsumedM > 0 && Number(checkoutForm.value.coin_consumed_m || 0) <= 0) { checkoutForm.value = { ...checkoutForm.value, coin_consumed_m: defaultCoinConsumedM, } } checkoutDefaultsHydratedOrderID = order.value.id } async function loadOrder() { loading.value = true try { order.value = await fetchOrder(String(route.params.id)) handoffRecords.value = await fetchHandoffRecords(String(route.params.id)) hydrateResourceUsage() hydrateCheckoutDefaults() 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 const needsRefundReview = order.value.status === 'pending_handoff' if (needsConfirm('cancel')) { const ok = await options.confirm({ title: '取消订单', message: needsRefundReview ? '确定申请取消订单吗?账号将在客服审核通过后释放,退款将原路退回。' : '确定取消订单并释放账号吗?', }) if (!ok) return } cancelling.value = true try { await cancelOrder(order.value.id) options.notifySuccess( needsRefundReview ? '退款申请已提交,等待客服审核' : '订单已取消,账号已释放' ) await router.push(options.ordersPath) } catch (error) { options.notifyError(readError(error, '取消失败')) } finally { cancelling.value = false } } async function handlePay() { if (!order.value) return const payWay = await options.selectPayWay() if (!payWay) return startingPayment.value = true try { const payment = await startOrderPayment(order.value.id, { pay_way: payWay }) if (payment.paid) { options.notifySuccess('支付成功,正在进入群消息') await openOrderChatAfterPayment() return } await options.startCashier(payment, openOrderChatAfterPayment) } 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 const depositDeductAmountYuan = Number(checkoutForm.value.otherAmountYuan || 0) if (needsConfirm('submitCheckout')) { const depositWarning = depositDeductAmountYuan > 0 ? `\n\n注意:押金赔付扣除 ¥${depositDeductAmountYuan.toFixed(2)} 将直接从预收押金中扣除,并计入号主赔付。` : '' const ok = await options.confirm({ title: '确认提交结账', message: '结账数据会影响租金、押金赔付和双方结算金额。请确认哈夫币消耗、额外消耗品、押金赔付和证据链接已认真核对,提交后将发送给号主确认。' + depositWarning, }) if (!ok) 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 (checkoutShortfallCent.value > 0) { options.notifyWarning('押金不足以覆盖打超/赔付,无法自动完结,请修改方案或发起争议由人工处理') 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() if (isRenter.value) { await session.loadMe().catch(() => undefined) } } catch (error) { options.notifyError(readError(error, '确认结账失败')) } finally { completing.value = false } } async function handleCounterCheckout() { if (!order.value) return const reasonText = counterForm.value.reason.trim() if (!reasonText) { options.notifyWarning('请填写修改原因') return } if (!canCounterCheckout.value) { options.notifyWarning('当前不能修改,可能已达 6 轮上限或未轮到你') return } countering.value = true try { await counterCheckout(order.value.id, { content: reasonText, consumableAmountYuan: counterForm.value.consumableAmountYuan, coin_consumed_m: counterForm.value.coin_consumed_m, otherAmountYuan: counterForm.value.depositDeductAmountYuan, depositDeductAmountYuan: counterForm.value.depositDeductAmountYuan, reason: reasonText, evidence_urls: linesToList(counterForm.value.evidenceText), }) options.notifySuccess( isOwner.value ? '结账修正已提交,等待租客处理' : '结账还价已提交,等待号主处理' ) await loadOrder() options.onCounterCheckoutSuccess?.() } catch (error) { options.notifyError(readError(error, '修改结账失败')) } finally { countering.value = false } } async function handleAcceptCheckout() { if (!order.value) return if (checkoutShortfallCent.value > 0) { options.notifyWarning('押金不足以覆盖打超/赔付,无法自动完结,请修改方案或发起争议由人工处理') 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() if (isRenter.value) { await session.loadMe().catch(() => undefined) } } 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 await navigateToOrderChat(order.value.id, '订单群聊暂不可用') } async function openOrderChatAfterPayment() { if (!order.value) return const opened = await navigateToOrderChat( order.value.id, '支付成功,但群消息暂不可用,请稍后从消息进入' ) if (!opened) { await loadOrder() } } async function navigateToOrderChat(orderID: number, errorMessage: string) { if (openingChat.value) return false openingChat.value = true try { const chat = await fetchOrderChat(orderID) await router.push(`${options.chatPathPrefix}${chat.id}`) return true } catch { options.notifyError(errorMessage) return false } 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, renterDiscountDisplayCent, ownerIncomeDisplayAmount, ownerIncomeLabel, canOpenDispute, canCancelDispute, canCounterCheckout, canAcceptCheckoutProposal, checkoutShortfallCent, checkoutOvershootCent, checkoutRoundLabel, isCheckoutDisputeStage, // Methods loadOrder, handleCancel, handlePay, handleSubmitHandoff, handleConfirmReceive, handleSubmitCheckout, handleConfirmCheckout, handleCounterCheckout, handleAcceptCheckout, handleRejectCheckout, handleCreateDispute, handleCancelDispute, handleEvidenceUpload, handleCounterEvidenceUpload, openOrderChat, orderEstimatedEndAt, orderRentedAt, hydrateCounterForm, } }