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 表示取消。PC 注入直接 resolve(true),Mobile 注入 showDialog。 */ confirm: (opts: { title: string; message: string }) => Promise /** 需要走二次确认的 handler 名集合。Mobile 传 ['cancel','confirmCheckout','acceptCheckout','cancelDispute'],PC 传 []。 */ 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', ] 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 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 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 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, ownerIncomeDisplayAmount, ownerIncomeLabel, canOpenDispute, canCancelDispute, isCheckoutDisputeStage, // Methods loadOrder, handleCancel, handlePay, handleSubmitHandoff, handleConfirmReceive, handleSubmitCheckout, handleConfirmCheckout, handleCounterCheckout, handleAcceptCheckout, handleRejectCheckout, handleCreateDispute, handleCancelDispute, handleEvidenceUpload, handleCounterEvidenceUpload, openOrderChat, orderEstimatedEndAt, orderRentedAt, hydrateCounterForm, } }