重构订单详情双视图: 抽取 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:
@@ -1,150 +1,56 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import QRCode from 'qrcode'
|
|
||||||
import { showDialog, showToast } from 'vant'
|
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 { paymentPayURL as mobilePaymentPayURL }
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端支付收银台 Composable。
|
||||||
|
*
|
||||||
|
* 在通用 {@link useOrderPaymentCashier} 基础上注入 vant 的 toast 提示,
|
||||||
|
* 并处理微信 / 支付宝内嵌浏览器的直接跳转拉起支付。对外 API 名保持不变,
|
||||||
|
* Mobile 视图无需改动。
|
||||||
|
*/
|
||||||
export function useMobilePaymentCashier() {
|
export function useMobilePaymentCashier() {
|
||||||
const paymentPopupVisible = ref(false)
|
function isInAppPaymentBrowser(): boolean {
|
||||||
const activePayment = ref<PaymentOrder | null>(null)
|
if (typeof navigator === 'undefined') return false
|
||||||
const paymentQRCodeURL = ref('')
|
const ua = navigator.userAgent || ''
|
||||||
const qrGenerating = ref(false)
|
return /MicroMessenger|AlipayClient/i.test(ua)
|
||||||
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: '知道了',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPaymentQRCode(payment = activePayment.value) {
|
const cashier = useOrderPaymentCashier({
|
||||||
const currentPayURL = mobilePaymentPayURL(payment)
|
notifySuccess: message => showToast({ message, icon: 'passed' }),
|
||||||
paymentQRCodeURL.value = ''
|
notifyError: message => showToast({ message, icon: 'cross' }),
|
||||||
if (!currentPayURL || !isHTTPURL(currentPayURL)) return
|
notifyInfo: message => showToast({ message, icon: 'info-o' }),
|
||||||
|
notifyFallback: message => {
|
||||||
qrGenerating.value = true
|
void showDialog({
|
||||||
try {
|
title: '订单支付',
|
||||||
paymentQRCodeURL.value = await QRCode.toDataURL(currentPayURL, {
|
message,
|
||||||
width: 220,
|
confirmButtonText: '知道了',
|
||||||
margin: 1,
|
|
||||||
errorCorrectionLevel: 'M',
|
|
||||||
color: {
|
|
||||||
dark: '#111827',
|
|
||||||
light: '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} catch {
|
},
|
||||||
showToast({ message: '二维码生成失败', icon: 'cross' })
|
paidMessage: '支付成功,等待号主交接',
|
||||||
} finally {
|
pollIntervalMs: 2500,
|
||||||
qrGenerating.value = false
|
qrWidth: 220,
|
||||||
}
|
openExternalURL: url => {
|
||||||
}
|
if (isHTTPURL(url) && isInAppPaymentBrowser()) {
|
||||||
|
window.location.href = url
|
||||||
function startPaymentPolling() {
|
return true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
if (mobilePaymentPayURL(payment) !== previousPayURL) {
|
return false
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(stopPaymentPolling)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentPopupVisible,
|
...cashier,
|
||||||
activePayment,
|
// 保持 Mobile 视图既有调用名。
|
||||||
payURL,
|
openMobilePaymentCashier: cashier.openPaymentCashier,
|
||||||
paymentQRCodeURL,
|
|
||||||
qrGenerating,
|
|
||||||
checkingPayment,
|
|
||||||
openMobilePaymentCashier,
|
|
||||||
refreshPaymentStatus,
|
|
||||||
stopPaymentPolling,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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-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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showDialog } from 'vant'
|
import { showDialog, showToast } from 'vant'
|
||||||
|
|
||||||
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,
|
|
||||||
OrderCheckoutSummary,
|
|
||||||
OrderHandoffTimeline,
|
|
||||||
OrderResourceUsageEditor,
|
|
||||||
type HandoffRecord,
|
|
||||||
type Order,
|
|
||||||
} from '@/features/orders'
|
|
||||||
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||||
|
import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
|
||||||
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||||
import {
|
import {
|
||||||
amountYuan,
|
amountYuan,
|
||||||
hydrateCounterFormFromCheckout,
|
|
||||||
linesToList,
|
|
||||||
money,
|
money,
|
||||||
orderEstimatedEndAt as readOrderEstimatedEndAt,
|
|
||||||
orderRentAmount,
|
|
||||||
ownerActualIncome,
|
|
||||||
readAssetSummary as readOrderAssetSummary,
|
readAssetSummary as readOrderAssetSummary,
|
||||||
readSnapshot as readOrderSnapshot,
|
readSnapshot as readOrderSnapshot,
|
||||||
useOrderCheckoutSnapshot,
|
|
||||||
} from '@/features/orders/composables/useOrderSnapshot'
|
} from '@/features/orders/composables/useOrderSnapshot'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import {
|
||||||
|
OrderCheckoutSummary,
|
||||||
|
OrderHandoffTimeline,
|
||||||
|
OrderResourceUsageEditor,
|
||||||
|
} from '@/features/orders'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
|
||||||
|
|
||||||
const route = useRoute()
|
// 移动端支付收银台(基于通用 useOrderPaymentCashier 的薄封装:注入 vant toast + App 浏览器跳转)。
|
||||||
|
const cashier = useMobilePaymentCashier()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useSessionStore()
|
|
||||||
const loading = ref(false)
|
|
||||||
const cancelling = ref(false)
|
|
||||||
const paying = 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)
|
|
||||||
const order = ref<Order | null>(null)
|
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
|
||||||
const listingCode = computed(() =>
|
|
||||||
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
|
|
||||||
)
|
|
||||||
const handoffContent = ref('')
|
|
||||||
const checkoutForm = ref({
|
|
||||||
content: '',
|
|
||||||
consumableAmountYuan: 0,
|
|
||||||
coin_consumed_m: 0,
|
|
||||||
otherAmountYuan: 0,
|
|
||||||
evidenceText: '',
|
|
||||||
})
|
|
||||||
const resourceUsage = ref<Record<string, number>>({})
|
|
||||||
const counterForm = ref({
|
|
||||||
consumableAmountYuan: 0,
|
|
||||||
coin_consumed_m: 0,
|
|
||||||
otherAmountYuan: 0,
|
|
||||||
depositDeductAmountYuan: 0,
|
|
||||||
reason: '',
|
|
||||||
evidenceText: '',
|
|
||||||
})
|
|
||||||
const rejectReason = ref('')
|
|
||||||
const disputeType = ref('cannot_login')
|
|
||||||
const disputeDescription = ref('')
|
|
||||||
const disputeEvidenceText = ref('')
|
|
||||||
|
|
||||||
// Show state controls
|
|
||||||
const activeNames = ref<string[]>([])
|
|
||||||
const showDisputePopup = ref(false)
|
|
||||||
const showCounterPopup = ref(false)
|
|
||||||
const showRejectPopup = ref(false)
|
|
||||||
let autoPayHandled = false
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
paymentPopupVisible,
|
paymentPopupVisible,
|
||||||
activePayment,
|
activePayment,
|
||||||
@@ -101,389 +30,100 @@ const {
|
|||||||
paymentQRCodeURL,
|
paymentQRCodeURL,
|
||||||
qrGenerating,
|
qrGenerating,
|
||||||
checkingPayment,
|
checkingPayment,
|
||||||
openMobilePaymentCashier,
|
|
||||||
refreshPaymentStatus,
|
refreshPaymentStatus,
|
||||||
} = useMobilePaymentCashier()
|
} = cashier
|
||||||
|
|
||||||
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
|
||||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const {
|
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,
|
checkoutResources,
|
||||||
resourceChargeAmount,
|
resourceChargeAmount,
|
||||||
snapshotHafCoinM,
|
snapshotHafCoinM,
|
||||||
remainingHafCoinM,
|
remainingHafCoinM,
|
||||||
hydrateResourceUsage,
|
|
||||||
resourceLineAmount,
|
resourceLineAmount,
|
||||||
checkoutContentWithSummary,
|
isOwner,
|
||||||
} = useOrderCheckoutSnapshot({ order, checkoutForm, resourceUsage })
|
isRenter,
|
||||||
|
listingCode,
|
||||||
onMounted(loadOrder)
|
orderAmountLabel,
|
||||||
|
orderRentDisplayAmount,
|
||||||
async function loadOrder() {
|
ownerIncomeDisplayAmount,
|
||||||
loading.value = true
|
ownerIncomeLabel,
|
||||||
try {
|
canOpenDispute,
|
||||||
order.value = await fetchOrder(String(route.params.id))
|
canCancelDispute,
|
||||||
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
isCheckoutDisputeStage,
|
||||||
hydrateResourceUsage()
|
handleCancel,
|
||||||
hydrateCounterForm()
|
handlePay,
|
||||||
if (
|
handleSubmitHandoff,
|
||||||
!autoPayHandled &&
|
handleConfirmReceive,
|
||||||
route.query.pay === '1' &&
|
handleSubmitCheckout,
|
||||||
order.value?.status === 'pending_payment' &&
|
handleConfirmCheckout,
|
||||||
order.value?.renter_id === session.userId
|
handleCounterCheckout,
|
||||||
) {
|
handleAcceptCheckout,
|
||||||
autoPayHandled = true
|
handleRejectCheckout,
|
||||||
void router.replace({ path: route.path })
|
handleCreateDispute,
|
||||||
void handlePay()
|
handleCancelDispute,
|
||||||
}
|
handleEvidenceUpload,
|
||||||
} catch {
|
handleCounterEvidenceUpload,
|
||||||
showToast({ message: '加载订单失败', icon: 'cross' })
|
openOrderChat,
|
||||||
} finally {
|
orderEstimatedEndAt,
|
||||||
loading.value = false
|
orderRentedAt,
|
||||||
}
|
} = useOrderActions({
|
||||||
}
|
notifySuccess: message => showToast({ message, icon: 'passed' }),
|
||||||
|
notifyError: message => showToast({ message, icon: 'cross' }),
|
||||||
async function handleCancel() {
|
notifyWarning: message => showToast({ message, icon: 'warning-o' }),
|
||||||
if (!order.value) return
|
confirm: async ({ title, message }) => {
|
||||||
showDialog({
|
|
||||||
title: '取消订单',
|
|
||||||
message: '确定取消订单并释放账号吗?',
|
|
||||||
showCancelButton: true,
|
|
||||||
}).then(async () => {
|
|
||||||
cancelling.value = true
|
|
||||||
try {
|
try {
|
||||||
await cancelOrder(order.value!.id)
|
await showDialog({ title, message, showCancelButton: true })
|
||||||
showToast({ message: '订单已取消,账号已释放', icon: 'passed' })
|
return true
|
||||||
await router.push('/m/orders')
|
} catch {
|
||||||
} catch (error) {
|
return false
|
||||||
showToast({ message: readError(error, '取消失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
cancelling.value = false
|
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
}
|
actionsNeedingConfirm: CONFIRMABLE_ACTIONS,
|
||||||
|
ordersPath: '/m/orders',
|
||||||
|
chatPathPrefix: '/m/chats/',
|
||||||
|
startCashier: (payment, onPaid) => cashier.openMobilePaymentCashier(payment, onPaid),
|
||||||
|
onCounterCheckoutSuccess: () => (showCounterPopup.value = false),
|
||||||
|
onRejectCheckoutSuccess: () => (showRejectPopup.value = false),
|
||||||
|
onCreateDisputeSuccess: () => (showDisputePopup.value = false),
|
||||||
|
})
|
||||||
|
|
||||||
async function handlePay() {
|
// Mobile 独有 UI 状态:折叠面板、各类 popup 显隐。
|
||||||
if (!order.value) return
|
const activeNames = ref<string[]>([])
|
||||||
paying.value = true
|
const showDisputePopup = ref(false)
|
||||||
try {
|
const showCounterPopup = ref(false)
|
||||||
const payment = await startOrderPayment(order.value.id)
|
const showRejectPopup = ref(false)
|
||||||
if (payment.paid) {
|
|
||||||
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} else {
|
|
||||||
await openMobilePaymentCashier(payment, loadOrder)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
paying.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmitHandoff() {
|
|
||||||
if (!order.value) return
|
|
||||||
if (!handoffContent.value.trim()) {
|
|
||||||
showToast({ message: '请填写交接说明', icon: 'warning-o' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handoffing.value = true
|
|
||||||
try {
|
|
||||||
await submitHandoff(order.value.id, handoffContent.value)
|
|
||||||
handoffContent.value = ''
|
|
||||||
showToast({ message: '交接说明已提交', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '提交交接失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
handoffing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmReceive() {
|
|
||||||
if (!order.value) return
|
|
||||||
confirming.value = true
|
|
||||||
try {
|
|
||||||
await confirmReceive(order.value.id)
|
|
||||||
showToast({ message: '已确认收号,订单进入使用中', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '确认收号失败'), icon: 'cross' })
|
|
||||||
} 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 = {
|
|
||||||
content: '',
|
|
||||||
consumableAmountYuan: 0,
|
|
||||||
coin_consumed_m: 0,
|
|
||||||
otherAmountYuan: 0,
|
|
||||||
evidenceText: '',
|
|
||||||
}
|
|
||||||
resourceUsage.value = {}
|
|
||||||
showToast({ message: '结账已发起,等待号主确认', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '发起结账失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
returning.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmCheckout() {
|
|
||||||
if (!order.value) return
|
|
||||||
showDialog({
|
|
||||||
title: '确认结账',
|
|
||||||
message: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
|
|
||||||
showCancelButton: true,
|
|
||||||
}).then(async () => {
|
|
||||||
completing.value = true
|
|
||||||
try {
|
|
||||||
await confirmCheckout(order.value!.id)
|
|
||||||
showToast({ message: '结账已确认,订单完成', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '确认结账失败'), icon: 'cross' })
|
|
||||||
} 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),
|
|
||||||
})
|
|
||||||
showToast({ message: '结账修正已提交,等待租客确认', icon: 'passed' })
|
|
||||||
showCounterPopup.value = false
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '修改结账失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
countering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAcceptCheckout() {
|
|
||||||
if (!order.value) return
|
|
||||||
showDialog({
|
|
||||||
title: '同意修正',
|
|
||||||
message: '确定同意号主的结账修正提案吗?同意后订单将完成结算。',
|
|
||||||
showCancelButton: true,
|
|
||||||
}).then(async () => {
|
|
||||||
acceptingCheckout.value = true
|
|
||||||
try {
|
|
||||||
await acceptCheckout(order.value!.id)
|
|
||||||
showToast({ message: '已确认修正结账,订单完成', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '确认修正失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
acceptingCheckout.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRejectCheckout() {
|
|
||||||
if (!order.value) return
|
|
||||||
if (!rejectReason.value.trim()) {
|
|
||||||
showToast({ message: '请填写不同意原因', icon: 'warning-o' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rejectingCheckout.value = true
|
|
||||||
try {
|
|
||||||
await createDispute(order.value.id, {
|
|
||||||
type: 'checkout_dispute',
|
|
||||||
description: rejectReason.value,
|
|
||||||
evidence_urls: [],
|
|
||||||
})
|
|
||||||
rejectReason.value = ''
|
|
||||||
showToast({ message: '已拒绝修正并提交争议', icon: 'passed' })
|
|
||||||
showRejectPopup.value = false
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '提交拒绝失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
rejectingCheckout.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateDispute() {
|
|
||||||
if (!order.value) return
|
|
||||||
if (!disputeDescription.value.trim()) {
|
|
||||||
showToast({ message: '请填写争议经过说明', icon: 'warning-o' })
|
|
||||||
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 = ''
|
|
||||||
showToast({ message: '申诉已提交,订单进入仲裁', icon: 'passed' })
|
|
||||||
showDisputePopup.value = false
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '提交申诉失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
disputing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCancelDispute() {
|
|
||||||
if (!order.value) return
|
|
||||||
showDialog({
|
|
||||||
title: order.value.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉',
|
|
||||||
message: '取消后订单将恢复到发起申诉前的流程,确定继续吗?',
|
|
||||||
showCancelButton: true,
|
|
||||||
}).then(async () => {
|
|
||||||
cancellingDispute.value = true
|
|
||||||
try {
|
|
||||||
await cancelOrderDispute(order.value!.id)
|
|
||||||
showToast({ message: '申诉已取消,订单已恢复', icon: 'passed' })
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '取消申诉失败'), icon: 'cross' })
|
|
||||||
} 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')
|
|
||||||
showToast({ message: '证据文件已上传', icon: 'passed' })
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
uploadingEvidence.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
|
||||||
showToast({ message: '证据文件已上传', icon: 'passed' })
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
|
|
||||||
} finally {
|
|
||||||
uploadingEvidence.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderEstimatedEndAt() {
|
|
||||||
return readOrderEstimatedEndAt(order.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderRentedAt() {
|
|
||||||
return order.value?.rented_at
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 账号快照展示(PC 不用,保留在本视图)。
|
||||||
function readSnapshot() {
|
function readSnapshot() {
|
||||||
return readOrderSnapshot(order.value) as Record<string, any> | null
|
return readOrderSnapshot(order.value) as Record<string, any> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAssetSummary() {
|
function readAssetSummary() {
|
||||||
return readOrderAssetSummary(order.value) as Record<string, any> | null
|
return readOrderAssetSummary(order.value) as Record<string, any> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateCounterForm() {
|
// 状态颜色映射(vant 标签主题)。
|
||||||
if (!order.value?.checkout) return
|
|
||||||
counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openOrderChat() {
|
|
||||||
if (!order.value || openingChat.value) return
|
|
||||||
openingChat.value = true
|
|
||||||
try {
|
|
||||||
const chat = await fetchOrderChat(order.value.id)
|
|
||||||
router.push(`/m/chats/${chat.id}`)
|
|
||||||
} catch {
|
|
||||||
showToast({ message: '订单群聊暂不可用', icon: 'warning-o' })
|
|
||||||
} finally {
|
|
||||||
openingChat.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Theme Color Mapping */
|
|
||||||
function getStatusTagType(status: string) {
|
function getStatusTagType(status: string) {
|
||||||
if (['completed', 'received'].includes(status)) return 'success'
|
if (['completed', 'received'].includes(status)) return 'success'
|
||||||
if (['pending_payment', 'pending_confirm'].includes(status)) return 'warning'
|
if (['pending_payment', 'pending_confirm'].includes(status)) return 'warning'
|
||||||
@@ -492,6 +132,7 @@ function getStatusTagType(status: string) {
|
|||||||
return 'danger'
|
return 'danger'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function copyListingCode() {
|
async function copyListingCode() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
try {
|
try {
|
||||||
@@ -503,6 +144,7 @@ async function copyListingCode() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="mobile-order-detail-view">
|
<main class="mobile-order-detail-view">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
|||||||
@@ -1,145 +1,111 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ChatDotRound, CopyDocument, Loading } from '@element-plus/icons-vue'
|
import { ChatDotRound, CopyDocument, Loading } from '@element-plus/icons-vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import QRCode from 'qrcode'
|
|
||||||
|
|
||||||
import { fetchOrderChat } from '@/features/chats/api/chats'
|
|
||||||
import { cancelOrderDispute, createDispute } from '@/features/disputes'
|
|
||||||
import { uploadFile } from '@/shared/api/files'
|
|
||||||
import { readError } from '@/shared/utils/error'
|
|
||||||
import { formatCent } from '@/shared/utils/money'
|
|
||||||
import {
|
import {
|
||||||
acceptCheckout,
|
|
||||||
amountYuan,
|
amountYuan,
|
||||||
cancelOrder,
|
|
||||||
confirmCheckout,
|
|
||||||
confirmReceive,
|
|
||||||
counterCheckout,
|
|
||||||
fetchHandoffRecords,
|
|
||||||
fetchOrder,
|
|
||||||
hydrateCounterFormFromCheckout,
|
|
||||||
linesToList,
|
|
||||||
money,
|
money,
|
||||||
OrderCheckoutSummary,
|
OrderCheckoutSummary,
|
||||||
OrderHandoffTimeline,
|
OrderHandoffTimeline,
|
||||||
OrderResourceUsageEditor,
|
OrderResourceUsageEditor,
|
||||||
orderEstimatedEndAt as readOrderEstimatedEndAt,
|
|
||||||
orderRentAmount,
|
|
||||||
ownerActualIncome,
|
|
||||||
queryOrderPayment,
|
|
||||||
startOrderPayment,
|
|
||||||
submitCheckout,
|
|
||||||
submitHandoff,
|
|
||||||
type HandoffRecord,
|
|
||||||
type Order,
|
|
||||||
type PaymentOrder,
|
|
||||||
useOrderCheckoutSnapshot,
|
|
||||||
} from '@/features/orders'
|
} from '@/features/orders'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useOrderActions } from '@/features/orders/composables/useOrderActions'
|
||||||
|
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||||
|
import { formatCent } from '@/shared/utils/money'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
|
||||||
|
|
||||||
const route = useRoute()
|
// 支付收银台:二维码渲染 + 轮询查询。PC 不做 App 浏览器跳转,直接渲染二维码弹窗。
|
||||||
const router = useRouter()
|
const cashier = useOrderPaymentCashier({
|
||||||
const session = useSessionStore()
|
notifySuccess: message => ElMessage.success(message),
|
||||||
const loading = ref(false)
|
notifyError: message => ElMessage.error(message),
|
||||||
const cancelling = ref(false)
|
notifyInfo: message => ElMessage.info(message),
|
||||||
const startingPayment = ref(false)
|
notifyFallback: message => ElMessage.warning(message),
|
||||||
const handoffing = ref(false)
|
paidMessage: '支付成功,等待号主交接',
|
||||||
const confirming = ref(false)
|
pollIntervalMs: 3000,
|
||||||
const returning = ref(false)
|
qrWidth: 240,
|
||||||
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 order = ref<Order | null>(null)
|
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
|
||||||
const handoffContent = ref('')
|
|
||||||
const paymentDialogVisible = ref(false)
|
|
||||||
const activePayment = ref<PaymentOrder | null>(null)
|
|
||||||
|
|
||||||
const listingCode = computed(() =>
|
// 订单详情交互:业务逻辑、状态、计算属性、handler。
|
||||||
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
|
|
||||||
)
|
|
||||||
const paymentQRCodeURL = ref('')
|
|
||||||
const qrGenerating = ref(false)
|
|
||||||
const checkingPayment = ref(false)
|
|
||||||
let paymentPollingTimer: number | undefined
|
|
||||||
const checkoutForm = ref({
|
|
||||||
content: '',
|
|
||||||
consumableAmountYuan: 0,
|
|
||||||
coin_consumed_m: 0,
|
|
||||||
otherAmountYuan: 0,
|
|
||||||
evidenceText: '',
|
|
||||||
})
|
|
||||||
const resourceUsage = ref<Record<string, number>>({})
|
|
||||||
const counterForm = ref({
|
|
||||||
consumableAmountYuan: 0,
|
|
||||||
coin_consumed_m: 0,
|
|
||||||
otherAmountYuan: 0,
|
|
||||||
depositDeductAmountYuan: 0,
|
|
||||||
reason: '',
|
|
||||||
evidenceText: '',
|
|
||||||
})
|
|
||||||
const rejectReason = ref('')
|
|
||||||
const disputeType = ref('cannot_login')
|
|
||||||
const disputeDescription = ref('')
|
|
||||||
const disputeEvidenceText = ref('')
|
|
||||||
const openingChat = ref(false)
|
|
||||||
let autoPayHandled = false
|
|
||||||
|
|
||||||
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
|
||||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const {
|
const {
|
||||||
|
loading,
|
||||||
|
cancelling,
|
||||||
|
startingPayment,
|
||||||
|
handoffing,
|
||||||
|
confirming,
|
||||||
|
returning,
|
||||||
|
completing,
|
||||||
|
countering,
|
||||||
|
acceptingCheckout,
|
||||||
|
rejectingCheckout,
|
||||||
|
disputing,
|
||||||
|
cancellingDispute,
|
||||||
|
uploadingEvidence,
|
||||||
|
openingChat,
|
||||||
|
order,
|
||||||
|
handoffRecords,
|
||||||
|
handoffContent,
|
||||||
|
checkoutForm,
|
||||||
|
resourceUsage,
|
||||||
|
counterForm,
|
||||||
|
rejectReason,
|
||||||
|
disputeType,
|
||||||
|
disputeDescription,
|
||||||
|
disputeEvidenceText,
|
||||||
checkoutResources,
|
checkoutResources,
|
||||||
resourceChargeAmount,
|
resourceChargeAmount,
|
||||||
snapshotHafCoinM,
|
snapshotHafCoinM,
|
||||||
remainingHafCoinM,
|
remainingHafCoinM,
|
||||||
hydrateResourceUsage,
|
|
||||||
resourceLineAmount,
|
resourceLineAmount,
|
||||||
checkoutContentWithSummary,
|
isOwner,
|
||||||
} = useOrderCheckoutSnapshot({ order, checkoutForm, resourceUsage })
|
isRenter,
|
||||||
|
listingCode,
|
||||||
|
orderAmountLabel,
|
||||||
|
orderRentDisplayAmount,
|
||||||
|
ownerIncomeDisplayAmount,
|
||||||
|
ownerIncomeLabel,
|
||||||
|
canOpenDispute,
|
||||||
|
canCancelDispute,
|
||||||
|
isCheckoutDisputeStage,
|
||||||
|
handleCancel,
|
||||||
|
handlePay,
|
||||||
|
handleSubmitHandoff,
|
||||||
|
handleConfirmReceive,
|
||||||
|
handleSubmitCheckout,
|
||||||
|
handleConfirmCheckout,
|
||||||
|
handleCounterCheckout,
|
||||||
|
handleAcceptCheckout,
|
||||||
|
handleRejectCheckout,
|
||||||
|
handleCreateDispute,
|
||||||
|
handleCancelDispute,
|
||||||
|
handleEvidenceUpload,
|
||||||
|
openOrderChat,
|
||||||
|
orderEstimatedEndAt,
|
||||||
|
orderRentedAt,
|
||||||
|
} = useOrderActions({
|
||||||
|
notifySuccess: message => ElMessage.success(message),
|
||||||
|
notifyError: message => ElMessage.error(message),
|
||||||
|
notifyWarning: message => ElMessage.warning(message),
|
||||||
|
confirm: async () => true, // PC 不做二次确认,直接放行。
|
||||||
|
actionsNeedingConfirm: [],
|
||||||
|
ordersPath: '/orders',
|
||||||
|
chatPathPrefix: '/messages/',
|
||||||
|
startCashier: (payment, onPaid) => cashier.openPaymentCashier(payment, onPaid),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 支付弹窗别名:保持 template 字段名不变。
|
||||||
|
const paymentDialogVisible = cashier.paymentPopupVisible
|
||||||
|
const activePayment = cashier.activePayment
|
||||||
|
const paymentQRCodeURL = cashier.paymentQRCodeURL
|
||||||
|
const qrGenerating = cashier.qrGenerating
|
||||||
|
const checkingPayment = cashier.checkingPayment
|
||||||
|
const paymentPayURL = cashier.payURL
|
||||||
|
function handleRefreshPayment() {
|
||||||
|
void cashier.refreshPaymentStatus(false)
|
||||||
|
}
|
||||||
|
function stopPaymentPolling() {
|
||||||
|
cashier.stopPaymentPolling()
|
||||||
|
}
|
||||||
|
|
||||||
function getOrderStep(status: string) {
|
function getOrderStep(status: string) {
|
||||||
const stepMap: Record<string, number> = {
|
const stepMap: Record<string, number> = {
|
||||||
@@ -156,375 +122,24 @@ function getOrderStep(status: string) {
|
|||||||
return stepMap[status] ?? 0
|
return stepMap[status] ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadOrder)
|
|
||||||
onBeforeUnmount(stopPaymentPolling)
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCancel() {
|
|
||||||
if (!order.value) return
|
|
||||||
cancelling.value = true
|
|
||||||
try {
|
|
||||||
await cancelOrder(order.value.id)
|
|
||||||
ElMessage.success('订单已取消,账号已释放')
|
|
||||||
await router.push('/orders')
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(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) {
|
|
||||||
ElMessage.success('支付成功,等待号主交接')
|
|
||||||
await loadOrder()
|
|
||||||
} else {
|
|
||||||
showPaymentDialog(payment)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '支付失败'))
|
|
||||||
} finally {
|
|
||||||
startingPayment.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPaymentDialog(payment: PaymentOrder) {
|
|
||||||
activePayment.value = payment
|
|
||||||
paymentDialogVisible.value = true
|
|
||||||
void renderPaymentQRCode(payment)
|
|
||||||
startPaymentPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
function paymentPayURL(payment = activePayment.value) {
|
|
||||||
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderPaymentQRCode(payment = activePayment.value) {
|
|
||||||
const payURL = paymentPayURL(payment)
|
|
||||||
paymentQRCodeURL.value = ''
|
|
||||||
if (!payURL) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
qrGenerating.value = true
|
|
||||||
try {
|
|
||||||
paymentQRCodeURL.value = await QRCode.toDataURL(payURL, {
|
|
||||||
width: 240,
|
|
||||||
margin: 1,
|
|
||||||
errorCorrectionLevel: 'M',
|
|
||||||
color: {
|
|
||||||
dark: '#111827',
|
|
||||||
light: '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('二维码生成失败')
|
|
||||||
} finally {
|
|
||||||
qrGenerating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPaymentPolling() {
|
|
||||||
stopPaymentPolling()
|
|
||||||
paymentPollingTimer = window.setInterval(() => {
|
|
||||||
void refreshPaymentStatus(true)
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPaymentPolling() {
|
|
||||||
if (paymentPollingTimer !== undefined) {
|
|
||||||
window.clearInterval(paymentPollingTimer)
|
|
||||||
paymentPollingTimer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPaymentStatus(silent = false) {
|
|
||||||
if (!order.value || checkingPayment.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
checkingPayment.value = true
|
|
||||||
const currentPayURL = paymentPayURL()
|
|
||||||
try {
|
|
||||||
const payment = await queryOrderPayment(order.value.id)
|
|
||||||
if (payment.paid) {
|
|
||||||
stopPaymentPolling()
|
|
||||||
ElMessage.success('支付成功')
|
|
||||||
paymentDialogVisible.value = false
|
|
||||||
await loadOrder()
|
|
||||||
} else {
|
|
||||||
activePayment.value = payment
|
|
||||||
if (paymentPayURL(payment) !== currentPayURL) {
|
|
||||||
await renderPaymentQRCode(payment)
|
|
||||||
}
|
|
||||||
if (!silent) {
|
|
||||||
ElMessage.info('支付未完成')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!silent) {
|
|
||||||
ElMessage.error(readError(error, '刷新支付状态失败'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
checkingPayment.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRefreshPayment() {
|
|
||||||
await refreshPaymentStatus(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmitHandoff() {
|
|
||||||
if (!order.value) return
|
|
||||||
handoffing.value = true
|
|
||||||
try {
|
|
||||||
await submitHandoff(order.value.id, handoffContent.value)
|
|
||||||
handoffContent.value = ''
|
|
||||||
ElMessage.success('交接说明已提交')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '提交交接失败'))
|
|
||||||
} finally {
|
|
||||||
handoffing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmReceive() {
|
|
||||||
if (!order.value) return
|
|
||||||
confirming.value = true
|
|
||||||
try {
|
|
||||||
await confirmReceive(order.value.id)
|
|
||||||
ElMessage.success('已确认收号,订单进入使用中')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(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 = {
|
|
||||||
content: '',
|
|
||||||
consumableAmountYuan: 0,
|
|
||||||
coin_consumed_m: 0,
|
|
||||||
otherAmountYuan: 0,
|
|
||||||
evidenceText: '',
|
|
||||||
}
|
|
||||||
resourceUsage.value = {}
|
|
||||||
ElMessage.success('结账已发起,等待号主确认')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '发起结账失败'))
|
|
||||||
} finally {
|
|
||||||
returning.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmCheckout() {
|
|
||||||
if (!order.value) return
|
|
||||||
completing.value = true
|
|
||||||
try {
|
|
||||||
await confirmCheckout(order.value.id)
|
|
||||||
ElMessage.success('结账已确认,订单完成')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(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),
|
|
||||||
})
|
|
||||||
ElMessage.success('结账修正已提交,等待租客确认')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '修改结账失败'))
|
|
||||||
} finally {
|
|
||||||
countering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAcceptCheckout() {
|
|
||||||
if (!order.value) return
|
|
||||||
acceptingCheckout.value = true
|
|
||||||
try {
|
|
||||||
await acceptCheckout(order.value.id)
|
|
||||||
ElMessage.success('已确认修正结账,订单完成')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '确认修正失败'))
|
|
||||||
} finally {
|
|
||||||
acceptingCheckout.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRejectCheckout() {
|
|
||||||
if (!order.value) return
|
|
||||||
rejectingCheckout.value = true
|
|
||||||
try {
|
|
||||||
await createDispute(order.value.id, {
|
|
||||||
type: 'checkout_dispute',
|
|
||||||
description: rejectReason.value,
|
|
||||||
evidence_urls: [],
|
|
||||||
})
|
|
||||||
rejectReason.value = ''
|
|
||||||
ElMessage.success('已拒绝修正结账,订单进入争议处理')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '拒绝修正失败'))
|
|
||||||
} finally {
|
|
||||||
rejectingCheckout.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateDispute() {
|
|
||||||
if (!order.value) 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 = ''
|
|
||||||
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '提交申诉失败'))
|
|
||||||
} finally {
|
|
||||||
disputing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCancelDispute() {
|
|
||||||
if (!order.value) return
|
|
||||||
cancellingDispute.value = true
|
|
||||||
try {
|
|
||||||
await cancelOrderDispute(order.value.id)
|
|
||||||
ElMessage.success('申诉已取消,订单已恢复')
|
|
||||||
await loadOrder()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(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')
|
|
||||||
ElMessage.success('证据文件已上传')
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(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(`/messages/${chat.id}`)
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('订单群聊暂不可用')
|
|
||||||
} finally {
|
|
||||||
openingChat.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderEstimatedEndAt() {
|
|
||||||
return readOrderEstimatedEndAt(order.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderRentedAt() {
|
|
||||||
return order.value?.rented_at
|
|
||||||
}
|
|
||||||
|
|
||||||
function hydrateCounterForm() {
|
|
||||||
if (!order.value?.checkout) return
|
|
||||||
counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToDispute() {
|
function scrollToDispute() {
|
||||||
const disputeSection = document.querySelector('.dispute-section')
|
const disputeSection = document.querySelector('.dispute-section')
|
||||||
if (disputeSection) {
|
if (disputeSection) {
|
||||||
disputeSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
disputeSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToCheckout() {
|
function scrollToCheckout() {
|
||||||
const checkoutSection = document.querySelector('.checkout-form-section')
|
const checkoutSection = document.querySelector('.checkout-form-section')
|
||||||
if (checkoutSection) {
|
if (checkoutSection) {
|
||||||
checkoutSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
checkoutSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToCounterCheckout() {
|
function scrollToCounterCheckout() {
|
||||||
const counterSection = document.querySelector('.counter-checkout-section')
|
const counterSection = document.querySelector('.counter-checkout-section')
|
||||||
if (counterSection) {
|
if (counterSection) {
|
||||||
counterSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
counterSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToRejectCheckout() {
|
function scrollToRejectCheckout() {
|
||||||
const rejectSection = document.querySelector('.reject-checkout-section')
|
const rejectSection = document.querySelector('.reject-checkout-section')
|
||||||
if (rejectSection) {
|
if (rejectSection) {
|
||||||
@@ -543,6 +158,7 @@ async function copyListingCode() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page order-detail-page" v-loading="loading">
|
<section class="page order-detail-page" v-loading="loading">
|
||||||
<div v-if="order" class="page-header">
|
<div v-if="order" class="page-header">
|
||||||
@@ -973,7 +589,7 @@ async function copyListingCode() {
|
|||||||
<strong>¥{{ formatCent(activePayment.amount_cent) }}</strong>
|
<strong>¥{{ formatCent(activePayment.amount_cent) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="paymentPayURL()" class="pay-qr-section">
|
<div v-if="paymentPayURL" class="pay-qr-section">
|
||||||
<div class="pay-qr-box">
|
<div class="pay-qr-box">
|
||||||
<el-icon v-if="qrGenerating" class="is-loading" :size="32"><Loading /></el-icon>
|
<el-icon v-if="qrGenerating" class="is-loading" :size="32"><Loading /></el-icon>
|
||||||
<img v-else-if="paymentQRCodeURL" :src="paymentQRCodeURL" alt="支付二维码" />
|
<img v-else-if="paymentQRCodeURL" :src="paymentQRCodeURL" alt="支付二维码" />
|
||||||
|
|||||||
Reference in New Issue
Block a user