Files
hfb_sys/frontend/src/features/orders/composables/useOrderActions.ts
T

734 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { cancelOrderDispute, createDispute } from '@/features/disputes/api/disputes'
import { uploadFile } from '@/shared/api/files'
import { readError } from '@/shared/utils/error'
import {
acceptCheckout,
cancelOrder,
confirmCheckout,
confirmReceive,
counterCheckout,
fetchHandoffRecords,
fetchOrder,
startOrderPayment,
submitCheckout,
submitHandoff,
type HandoffRecord,
type Order,
type PaymentOrder,
type PaymentPayWay,
} from '@/features/orders/api/orders'
import {
hydrateCounterFormFromCheckout,
linesToList,
orderEstimatedEndAt as readOrderEstimatedEndAt,
orderRentAmount,
ownerActualIncome,
useOrderCheckoutSnapshot,
} from '@/features/orders/composables/useOrderSnapshot'
import { useSessionStore } from '@/stores/session'
import { formatListingNo } from '@/shared/utils/listingDisplay'
/**
* 订单详情交互 Composable
*
* 承载订单详情双视图(PC / Mobile)共享的业务逻辑:状态、计算属性、handler。
* UI 反馈(ElMessage / showToast)与二次确认(ElMessageBox / showDialog)通过 options 注入,
* 文案与业务流程(API 调用顺序、参数构造、表单 reset、autoPay 触发)归本 composable。
*
* 设计参考:{@link ./usePublishForm.ts}PC / Mobile 共用 composable 范式)。
*/
export interface UseOrderActionsOptions {
/** 成功提示,注入端决定 UI 形态(ElMessage.success / showToast)。 */
notifySuccess: (message: string) => void
/** 错误提示,注入端决定 UI 形态。 */
notifyError: (message: string) => void
/** 警告提示,用于表单校验未通过等非致命场景。 */
notifyWarning: (message: string) => void
/** 二次确认。返回 true 表示用户确认继续,false 表示取消。 */
confirm: (opts: { title: string; message: string }) => Promise<boolean>
/** 需要走二次确认的 handler 名集合。 */
actionsNeedingConfirm: readonly string[]
/** 支付前选择支付方式。返回 null 表示用户取消支付。 */
selectPayWay: () => Promise<PaymentPayWay | null>
/** 订单列表跳转路径,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>) => void | Promise<void>
/** 修改结账提交成功后触发(如关闭弹窗)。可选。 */
onCounterCheckoutSuccess?: () => void
/** 拒绝修正提交成功后触发(如关闭弹窗)。可选。 */
onRejectCheckoutSuccess?: () => void
/** 提交申诉成功后触发(如关闭弹窗)。可选。 */
onCreateDisputeSuccess?: () => void
}
/** handler 名常量,供 actionsNeedingConfirm 校验。 */
export const CONFIRMABLE_ACTIONS = [
'cancel',
'confirmCheckout',
'acceptCheckout',
'cancelDispute',
'submitCheckout',
] as const
export interface CheckoutFormState {
content: string
consumableAmountYuan: number
coin_consumed_m: number
otherAmountYuan: number
evidenceText: string
}
export interface CounterFormState {
consumableAmountYuan: number
coin_consumed_m: number
depositDeductAmountYuan: number
reason: string
evidenceText: string
}
function createCheckoutForm(): CheckoutFormState {
return {
content: '',
consumableAmountYuan: 0,
coin_consumed_m: 0,
otherAmountYuan: 0,
evidenceText: '',
}
}
function createCounterForm(): CounterFormState {
return {
consumableAmountYuan: 0,
coin_consumed_m: 0,
depositDeductAmountYuan: 0,
reason: '',
evidenceText: '',
}
}
export function useOrderActions(options: UseOrderActionsOptions) {
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
// Loading states
const loading = ref(false)
const cancelling = ref(false)
const startingPayment = ref(false)
const handoffing = ref(false)
const confirming = ref(false)
const returning = ref(false)
const completing = ref(false)
const countering = ref(false)
const acceptingCheckout = ref(false)
const rejectingCheckout = ref(false)
const disputing = ref(false)
const cancellingDispute = ref(false)
const uploadingEvidence = ref(false)
const openingChat = ref(false)
// Data
const order = ref<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('')
let checkoutDefaultsHydratedOrderID: number | null = null
// 结账快照能力(资源金额、内容摘要、资源使用量规范化)
const {
checkoutResources,
resourceChargeAmount,
snapshotHafCoinM,
remainingHafCoinM,
hydrateResourceUsage,
resourceLineAmount,
checkoutContentWithSummary,
} = useOrderCheckoutSnapshot({ order, checkoutForm, resourceUsage })
// Computed
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const listingCode = computed(() =>
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
)
const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金'))
const orderRentDisplayAmount = computed(() =>
order.value ? orderRentAmount(order.value, session.userId) : 0
)
const renterDiscountDisplayCent = computed(() => {
if (!order.value) return 0
if (order.value.status === 'completed') {
return Number(order.value.actual_pure_coin_discount_cent || 0)
}
return Number(order.value.rent_discount_amount_cent || 0)
})
const ownerIncomeDisplayAmount = computed(() =>
order.value ? ownerActualIncome(order.value, session.userId) : null
)
const ownerIncomeLabel = computed(() =>
order.value?.status === 'completed' ? '实际到手' : '结账预计到手'
)
const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false
return ![
'completed',
'cancelled',
'closed',
'disputing',
'checkout_disputing',
'abnormal',
].includes(order.value.status)
})
const canCounterCheckout = computed(() => {
if (!order.value?.checkout) return false
return Boolean(order.value.checkout.can_counter)
})
const canAcceptCheckoutProposal = computed(() => {
if (!order.value?.checkout) return false
// 后端 can_accept 已含 shortfall 校验;无字段时按角色状态兜底
if (typeof order.value.checkout.can_accept === 'boolean') {
return order.value.checkout.can_accept
}
return (
(isOwner.value && order.value.status === 'pending_checkout_confirm') ||
(isRenter.value && order.value.status === 'pending_checkout_accept')
)
})
const checkoutShortfallCent = computed(() => Number(order.value?.checkout?.shortfall_cent || 0))
const checkoutOvershootCent = computed(() =>
Number(order.value?.checkout?.overshoot_amount_cent || 0)
)
const checkoutRoundLabel = computed(() => {
const c = order.value?.checkout
if (!c) return ''
const round = Number(c.round_count || 1)
const max = Number(c.max_rounds || 6)
return `第 ${round}/${max} 轮协商`
})
const canCancelDispute = computed(() => {
return (
!!order.value &&
(isOwner.value || isRenter.value) &&
['disputing', 'checkout_disputing'].includes(order.value.status) &&
order.value.active_dispute?.initiator_type !== 'admin' &&
order.value.active_dispute?.initiator_id === session.userId
)
})
const isCheckoutDisputeStage = computed(() => {
return (
!!order.value &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
)
})
let autoPayHandled = false
function needsConfirm(action: string): boolean {
return options.actionsNeedingConfirm.includes(action)
}
function hydrateCounterForm() {
if (!order.value?.checkout) return
counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout)
}
function hydrateCheckoutDefaults() {
if (!order.value || !isRenter.value || !['renting', 'overdue'].includes(order.value.status)) {
return
}
if (checkoutDefaultsHydratedOrderID === order.value.id) return
const defaultCoinConsumedM = snapshotHafCoinM.value
if (defaultCoinConsumedM > 0 && Number(checkoutForm.value.coin_consumed_m || 0) <= 0) {
checkoutForm.value = {
...checkoutForm.value,
coin_consumed_m: defaultCoinConsumedM,
}
}
checkoutDefaultsHydratedOrderID = order.value.id
}
async function loadOrder() {
loading.value = true
try {
order.value = await fetchOrder(String(route.params.id))
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
hydrateResourceUsage()
hydrateCheckoutDefaults()
hydrateCounterForm()
if (
!autoPayHandled &&
route.query.pay === '1' &&
order.value?.status === 'pending_payment' &&
order.value?.renter_id === session.userId
) {
autoPayHandled = true
void router.replace({ path: route.path })
void handlePay()
}
} catch (error) {
options.notifyError(readError(error, '加载订单失败'))
} finally {
loading.value = false
}
}
async function handleCancel() {
if (!order.value) return
const needsRefundReview = order.value.status === 'pending_handoff'
if (needsConfirm('cancel')) {
const ok = await options.confirm({
title: '取消订单',
message: needsRefundReview
? '确定申请取消订单吗?账号将在客服审核通过后释放,退款将原路退回。'
: '确定取消订单并释放账号吗?',
})
if (!ok) return
}
cancelling.value = true
try {
await cancelOrder(order.value.id)
options.notifySuccess(
needsRefundReview ? '退款申请已提交,等待客服审核' : '订单已取消,账号已释放'
)
await router.push(options.ordersPath)
} catch (error) {
options.notifyError(readError(error, '取消失败'))
} finally {
cancelling.value = false
}
}
async function handlePay() {
if (!order.value) return
const payWay = await options.selectPayWay()
if (!payWay) return
startingPayment.value = true
try {
const payment = await startOrderPayment(order.value.id, { pay_way: payWay })
if (payment.paid) {
options.notifySuccess('支付成功,正在进入群消息')
await openOrderChatAfterPayment()
return
}
await options.startCashier(payment, openOrderChatAfterPayment)
} catch (error) {
options.notifyError(readError(error, '支付失败'))
} finally {
startingPayment.value = false
}
}
async function handleSubmitHandoff() {
if (!order.value) return
if (!handoffContent.value.trim()) {
options.notifyWarning('请填写交接说明')
return
}
handoffing.value = true
try {
await submitHandoff(order.value.id, handoffContent.value)
handoffContent.value = ''
options.notifySuccess('交接说明已提交')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '提交交接失败'))
} finally {
handoffing.value = false
}
}
async function handleConfirmReceive() {
if (!order.value) return
confirming.value = true
try {
await confirmReceive(order.value.id)
options.notifySuccess('已确认收号,订单进入使用中')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '确认收号失败'))
} finally {
confirming.value = false
}
}
async function handleSubmitCheckout() {
if (!order.value) return
const depositDeductAmountYuan = Number(checkoutForm.value.otherAmountYuan || 0)
if (needsConfirm('submitCheckout')) {
const depositWarning =
depositDeductAmountYuan > 0
? `\n\n注意:押金赔付扣除 ¥${depositDeductAmountYuan.toFixed(2)} 将直接从预收押金中扣除,并计入号主赔付。`
: ''
const ok = await options.confirm({
title: '确认提交结账',
message:
'结账数据会影响租金、押金赔付和双方结算金额。请确认哈夫币消耗、额外消耗品、押金赔付和证据链接已认真核对,提交后将发送给号主确认。' +
depositWarning,
})
if (!ok) return
}
returning.value = true
try {
const consumableAmount = resourceChargeAmount.value
checkoutForm.value.consumableAmountYuan = consumableAmount
await submitCheckout(order.value.id, {
content: checkoutContentWithSummary(),
consumableAmountYuan: consumableAmount,
coin_consumed_m: checkoutForm.value.coin_consumed_m,
otherAmountYuan: checkoutForm.value.otherAmountYuan,
evidence_urls: linesToList(checkoutForm.value.evidenceText),
})
checkoutForm.value = createCheckoutForm()
resourceUsage.value = {}
options.notifySuccess('结账已发起,等待号主确认')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '发起结账失败'))
} finally {
returning.value = false
}
}
async function handleConfirmCheckout() {
if (!order.value) return
if (checkoutShortfallCent.value > 0) {
options.notifyWarning('押金不足以覆盖打超/赔付,无法自动完结,请修改方案或发起争议由人工处理')
return
}
if (needsConfirm('confirmCheckout')) {
const ok = await options.confirm({
title: '确认结账',
message: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
})
if (!ok) return
}
completing.value = true
try {
await confirmCheckout(order.value.id)
options.notifySuccess('结账已确认,订单完成')
await loadOrder()
if (isRenter.value) {
await session.loadMe().catch(() => undefined)
}
} catch (error) {
options.notifyError(readError(error, '确认结账失败'))
} finally {
completing.value = false
}
}
async function handleCounterCheckout() {
if (!order.value) return
const reasonText = counterForm.value.reason.trim()
if (!reasonText) {
options.notifyWarning('请填写修改原因')
return
}
if (!canCounterCheckout.value) {
options.notifyWarning('当前不能修改,可能已达 6 轮上限或未轮到你')
return
}
countering.value = true
try {
await counterCheckout(order.value.id, {
content: reasonText,
consumableAmountYuan: counterForm.value.consumableAmountYuan,
coin_consumed_m: counterForm.value.coin_consumed_m,
otherAmountYuan: counterForm.value.depositDeductAmountYuan,
depositDeductAmountYuan: counterForm.value.depositDeductAmountYuan,
reason: reasonText,
evidence_urls: linesToList(counterForm.value.evidenceText),
})
options.notifySuccess(
isOwner.value ? '结账修正已提交,等待租客处理' : '结账还价已提交,等待号主处理'
)
await loadOrder()
options.onCounterCheckoutSuccess?.()
} catch (error) {
options.notifyError(readError(error, '修改结账失败'))
} finally {
countering.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
if (checkoutShortfallCent.value > 0) {
options.notifyWarning('押金不足以覆盖打超/赔付,无法自动完结,请修改方案或发起争议由人工处理')
return
}
if (needsConfirm('acceptCheckout')) {
const ok = await options.confirm({
title: '同意结账方案',
message: '确定同意当前结账方案吗?同意后订单将完成结算。',
})
if (!ok) return
}
acceptingCheckout.value = true
try {
await acceptCheckout(order.value.id)
options.notifySuccess('已确认结账,订单完成')
await loadOrder()
if (isRenter.value) {
await session.loadMe().catch(() => undefined)
}
} catch (error) {
options.notifyError(readError(error, '确认结账失败'))
} finally {
acceptingCheckout.value = false
}
}
async function handleRejectCheckout() {
if (!order.value) return
if (!rejectReason.value.trim()) {
options.notifyWarning('请填写不同意原因')
return
}
rejectingCheckout.value = true
try {
await createDispute(order.value.id, {
type: 'checkout_dispute',
description: rejectReason.value,
evidence_urls: [],
})
rejectReason.value = ''
options.notifySuccess('已拒绝修正并提交争议')
await loadOrder()
options.onRejectCheckoutSuccess?.()
} catch (error) {
options.notifyError(readError(error, '提交拒绝失败'))
} finally {
rejectingCheckout.value = false
}
}
async function handleCreateDispute() {
if (!order.value) return
if (!disputeDescription.value.trim()) {
options.notifyWarning('请填写争议经过说明')
return
}
disputing.value = true
try {
const evidence_urls = linesToList(disputeEvidenceText.value)
await createDispute(order.value.id, {
type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value,
description: disputeDescription.value,
evidence_urls,
})
disputeDescription.value = ''
disputeEvidenceText.value = ''
options.notifySuccess('申诉已提交,订单进入仲裁')
await loadOrder()
options.onCreateDisputeSuccess?.()
} catch (error) {
options.notifyError(readError(error, '提交申诉失败'))
} finally {
disputing.value = false
}
}
async function handleCancelDispute() {
if (!order.value) return
if (needsConfirm('cancelDispute')) {
const ok = await options.confirm({
title: order.value.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉',
message: '取消后订单将恢复到发起申诉前的流程,确定继续吗?',
})
if (!ok) return
}
cancellingDispute.value = true
try {
await cancelOrderDispute(order.value.id)
options.notifySuccess('申诉已取消,订单已恢复')
await loadOrder()
} catch (error) {
options.notifyError(readError(error, '取消申诉失败'))
} finally {
cancellingDispute.value = false
}
}
async function handleEvidenceUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingEvidence.value = true
try {
const uploaded = await uploadFile(file, 'dispute')
disputeEvidenceText.value = [disputeEvidenceText.value, uploaded.url]
.filter(Boolean)
.join('\n')
options.notifySuccess('证据文件已上传')
} catch (error) {
options.notifyError(readError(error, '上传失败'))
} finally {
uploadingEvidence.value = false
}
}
/**
* 反驳结账证据上传(Mobile 反驳弹窗独有:把 URL 写入 counterForm.evidenceText)。
* PC 不使用,保留以供 Mobile 视图调用。
*/
async function handleCounterEvidenceUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingEvidence.value = true
try {
const uploaded = await uploadFile(file, 'dispute')
counterForm.value.evidenceText = [counterForm.value.evidenceText, uploaded.url]
.filter(Boolean)
.join('\n')
options.notifySuccess('证据文件已上传')
} catch (error) {
options.notifyError(readError(error, '上传失败'))
} finally {
uploadingEvidence.value = false
}
}
async function openOrderChat() {
if (!order.value || openingChat.value) return
await navigateToOrderChat(order.value.id, '订单群聊暂不可用')
}
async function openOrderChatAfterPayment() {
if (!order.value) return
const opened = await navigateToOrderChat(
order.value.id,
'支付成功,但群消息暂不可用,请稍后从消息进入'
)
if (!opened) {
await loadOrder()
}
}
async function navigateToOrderChat(orderID: number, errorMessage: string) {
if (openingChat.value) return false
openingChat.value = true
try {
const chat = await fetchOrderChat(orderID)
await router.push(`${options.chatPathPrefix}${chat.id}`)
return true
} catch {
options.notifyError(errorMessage)
return false
} finally {
openingChat.value = false
}
}
function orderEstimatedEndAt() {
return readOrderEstimatedEndAt(order.value)
}
function orderRentedAt() {
return order.value?.rented_at
}
onMounted(loadOrder)
return {
// Loading states
loading,
cancelling,
startingPayment,
handoffing,
confirming,
returning,
completing,
countering,
acceptingCheckout,
rejectingCheckout,
disputing,
cancellingDispute,
uploadingEvidence,
openingChat,
// Data
order,
handoffRecords,
handoffContent,
// Forms
checkoutForm,
resourceUsage,
counterForm,
rejectReason,
disputeType,
disputeDescription,
disputeEvidenceText,
// Checkout snapshot
checkoutResources,
resourceChargeAmount,
snapshotHafCoinM,
remainingHafCoinM,
hydrateResourceUsage,
resourceLineAmount,
checkoutContentWithSummary,
// Computed
isOwner,
isRenter,
listingCode,
orderAmountLabel,
orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount,
ownerIncomeLabel,
canOpenDispute,
canCancelDispute,
canCounterCheckout,
canAcceptCheckoutProposal,
checkoutShortfallCent,
checkoutOvershootCent,
checkoutRoundLabel,
isCheckoutDisputeStage,
// Methods
loadOrder,
handleCancel,
handlePay,
handleSubmitHandoff,
handleConfirmReceive,
handleSubmitCheckout,
handleConfirmCheckout,
handleCounterCheckout,
handleAcceptCheckout,
handleRejectCheckout,
handleCreateDispute,
handleCancelDispute,
handleEvidenceUpload,
handleCounterEvidenceUpload,
openOrderChat,
orderEstimatedEndAt,
orderRentedAt,
hydrateCounterForm,
}
}