From 8dae7d9a3a96dda8c9db9ae74b50ff2bc831a7fc Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 4 Jun 2026 08:12:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E5=8F=96=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=85=B1=E4=BA=AB=E9=80=BB=E8=BE=91=E5=88=B0?= =?UTF-8?q?composables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创建可复用的订单业务逻辑: - useOrderDetail.ts (405行) - 订单操作逻辑 - useOrderSnapshot.ts (147行) - 快照计算工具 可复用于: - OrderDetailView.vue (1507行) - MobileOrderDetailView.vue (1494行) 优化收益: - 消除重复代码 - 业务逻辑可测试 - PC和移动端共享逻辑 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/composables/order/useOrderDetail.ts | 405 ++++++++++++++++++ .../src/composables/order/useOrderSnapshot.ts | 147 +++++++ 2 files changed, 552 insertions(+) create mode 100644 frontend/src/composables/order/useOrderDetail.ts create mode 100644 frontend/src/composables/order/useOrderSnapshot.ts diff --git a/frontend/src/composables/order/useOrderDetail.ts b/frontend/src/composables/order/useOrderDetail.ts new file mode 100644 index 0000000..15c0dbe --- /dev/null +++ b/frontend/src/composables/order/useOrderDetail.ts @@ -0,0 +1,405 @@ +import { computed, ref, onMounted, onBeforeUnmount, type Ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import type { + Order, + HandoffRecord, + PaymentOrder, +} from '@/api/orders' +import { + fetchOrder, + fetchHandoffRecords, + cancelOrder, + startOrderPayment, + queryOrderPayment, + submitHandoff, + confirmReceive, + submitCheckout, + acceptCheckout, + counterCheckout, + confirmCheckout, +} from '@/api/orders' +import { createDispute } from '@/api/disputes' +import { uploadFile } from '@/api/files' +import { fetchOrderChat } from '@/api/chats' +import { useSessionStore } from '@/stores/session' + +export interface CheckoutForm { + content: string + consumable_amount: number + coin_consumed_m: number + other_amount: number + evidenceText: string +} + +export interface CounterForm { + consumable_amount: number + coin_consumed_m: number + other_amount: number + deposit_deduct_amount: number + reason: string + evidenceText: string +} + +export function useOrderDetail() { + 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 uploadingEvidence = ref(false) + const openingChat = ref(false) + + // Data + const order = ref(null) + const handoffRecords = ref([]) + const handoffContent = ref('') + + // Payment states + const activePayment = ref(null) + const paymentQRCodeURL = ref('') + const qrGenerating = ref(false) + const checkingPayment = ref(false) + let paymentPollingTimer: number | undefined + let autoPayHandled = false + + // Forms + const checkoutForm = ref({ + content: '', + consumable_amount: 0, + coin_consumed_m: 0, + other_amount: 0, + evidenceText: '', + }) + + const resourceUsage = ref>({}) + + const counterForm = ref({ + consumable_amount: 0, + coin_consumed_m: 0, + other_amount: 0, + deposit_deduct_amount: 0, + reason: '', + evidenceText: '', + }) + + const rejectReason = ref('') + const disputeType = ref('cannot_login') + const disputeDescription = ref('') + const disputeEvidenceText = ref('') + + // Computed + const isOwner = computed(() => order.value?.owner_id === session.userId) + const isRenter = computed(() => order.value?.renter_id === session.userId) + const orderAmountLabel = computed(() => (isOwner.value ? '我的租金' : '订单金额')) + + 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 isCheckoutDisputeStage = computed(() => { + return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status) + }) + + // Methods + async function loadOrder() { + loading.value = true + try { + order.value = await fetchOrder(String(route.params.id)) + handoffRecords.value = await fetchHandoffRecords(String(route.params.id)) + return order.value + } finally { + loading.value = false + } + } + + async function handleCancel() { + if (!order.value) return + cancelling.value = true + try { + await cancelOrder(order.value.id) + return true + } finally { + cancelling.value = false + } + } + + async function handlePay() { + if (!order.value) return null + startingPayment.value = true + try { + const payment = await startOrderPayment(order.value.id) + return payment + } finally { + startingPayment.value = false + } + } + + async function handleSubmitHandoff() { + if (!order.value || !handoffContent.value.trim()) return + handoffing.value = true + try { + await submitHandoff(order.value.id, handoffContent.value.trim()) + handoffContent.value = '' + await loadOrder() + return true + } finally { + handoffing.value = false + } + } + + async function handleConfirmReceive() { + if (!order.value) return + confirming.value = true + try { + await confirmReceive(order.value.id) + await loadOrder() + return true + } finally { + confirming.value = false + } + } + + async function handleSubmitCheckout() { + if (!order.value) return + returning.value = true + try { + await submitCheckout(order.value.id, { + content: checkoutForm.value.content.trim(), + consumable_amount: checkoutForm.value.consumable_amount, + coin_consumed_m: checkoutForm.value.coin_consumed_m, + other_amount: checkoutForm.value.other_amount, + evidence: checkoutForm.value.evidenceText.trim(), + }) + await loadOrder() + return true + } finally { + returning.value = false + } + } + + async function handleAcceptCheckout() { + if (!order.value) return + acceptingCheckout.value = true + try { + await acceptCheckout(order.value.id) + await loadOrder() + return true + } finally { + acceptingCheckout.value = false + } + } + + async function handleCounterCheckout() { + if (!order.value) return + countering.value = true + try { + await counterCheckout(order.value.id, { + consumable_amount: counterForm.value.consumable_amount, + coin_consumed_m: counterForm.value.coin_consumed_m, + other_amount: counterForm.value.other_amount, + deposit_deduct_amount: counterForm.value.deposit_deduct_amount, + reason: counterForm.value.reason.trim(), + evidence: counterForm.value.evidenceText.trim(), + }) + await loadOrder() + return true + } finally { + countering.value = false + } + } + + async function handleRejectCheckout(reason: string) { + if (!order.value) return + rejectingCheckout.value = true + try { + await counterCheckout(order.value.id, { + consumable_amount: 0, + coin_consumed_m: 0, + other_amount: 0, + deposit_deduct_amount: 0, + reason: reason.trim(), + evidence: '', + }) + await loadOrder() + return true + } finally { + rejectingCheckout.value = false + } + } + + async function handleConfirmCheckout() { + if (!order.value) return + completing.value = true + try { + await confirmCheckout(order.value.id) + await loadOrder() + return true + } finally { + completing.value = false + } + } + + async function handleCreateDispute() { + if (!order.value) return + disputing.value = true + try { + await createDispute({ + order_id: order.value.id, + type: disputeType.value, + description: disputeDescription.value.trim(), + evidence: disputeEvidenceText.value.trim(), + }) + await loadOrder() + return true + } finally { + disputing.value = false + } + } + + async function handleUploadEvidence(file: File) { + uploadingEvidence.value = true + try { + const result = await uploadFile(file) + return result.url + } finally { + uploadingEvidence.value = false + } + } + + async function handleOpenChat() { + if (!order.value) return + openingChat.value = true + try { + const chat = await fetchOrderChat(order.value.id) + return chat + } finally { + openingChat.value = false + } + } + + function startPaymentPolling() { + stopPaymentPolling() + paymentPollingTimer = window.setInterval(checkPaymentStatus, 2000) + } + + function stopPaymentPolling() { + if (paymentPollingTimer !== undefined) { + clearInterval(paymentPollingTimer) + paymentPollingTimer = undefined + } + } + + async function checkPaymentStatus() { + if (!activePayment.value || checkingPayment.value) return + checkingPayment.value = true + try { + const updated = await queryOrderPayment(activePayment.value.id) + if (updated.paid) { + stopPaymentPolling() + activePayment.value = null + await loadOrder() + return true + } + } catch { + // ignore + } finally { + checkingPayment.value = false + } + return false + } + + function getOrderStep(status: string) { + const stepMap: Record = { + pending_payment: 0, + pending_handoff: 1, + renting: 2, + overdue: 2, + pending_checkout_confirm: 3, + pending_checkout_accept: 3, + completed: 4, + cancelled: 0, + closed: 4, + } + return stepMap[status] ?? 0 + } + + onMounted(loadOrder) + onBeforeUnmount(stopPaymentPolling) + + return { + // States + loading, + cancelling, + startingPayment, + handoffing, + confirming, + returning, + completing, + countering, + acceptingCheckout, + rejectingCheckout, + disputing, + uploadingEvidence, + openingChat, + + // Data + order, + handoffRecords, + handoffContent, + + // Payment + activePayment, + paymentQRCodeURL, + qrGenerating, + checkingPayment, + + // Forms + checkoutForm, + resourceUsage, + counterForm, + rejectReason, + disputeType, + disputeDescription, + disputeEvidenceText, + + // Computed + isOwner, + isRenter, + orderAmountLabel, + canOpenDispute, + isCheckoutDisputeStage, + + // Methods + loadOrder, + handleCancel, + handlePay, + handleSubmitHandoff, + handleConfirmReceive, + handleSubmitCheckout, + handleAcceptCheckout, + handleCounterCheckout, + handleRejectCheckout, + handleConfirmCheckout, + handleCreateDispute, + handleUploadEvidence, + handleOpenChat, + startPaymentPolling, + stopPaymentPolling, + checkPaymentStatus, + getOrderStep, + } +} diff --git a/frontend/src/composables/order/useOrderSnapshot.ts b/frontend/src/composables/order/useOrderSnapshot.ts new file mode 100644 index 0000000..0e3a09c --- /dev/null +++ b/frontend/src/composables/order/useOrderSnapshot.ts @@ -0,0 +1,147 @@ +import type { Order } from '@/api/orders' + +export interface SnapshotResource { + key: string + label: string + quantity: number + unitPrice: number + chargeMode: '赠送' | '收费' +} + +export function readSnapshot(order: Order | null) { + if (!order?.listing_snapshot) return null + try { + return JSON.parse(order.listing_snapshot) as Record + } catch { + return null + } +} + +export function readNumber(value: unknown): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +export function roundQuantity(value: number): number { + return Math.round(value * 10) / 10 +} + +export function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} + +export function readSnapshotResources(order: Order | null): SnapshotResource[] { + const snapshot = readSnapshot(order) + if (!snapshot?.quantities) return [] + + const quantities = snapshot.quantities as Record[] + return quantities + .map((item) => ({ + key: String(item.key || ''), + label: String(item.label || ''), + quantity: readNumber(item.quantity), + unitPrice: readNumber(item.price), + chargeMode: item.charge_mode === '收费' ? '收费' : '赠送', + })) + .filter((item) => item.key && item.label) +} + +export function isChargedResource(resource: SnapshotResource): boolean { + return resource.chargeMode === '收费' +} + +export function getSnapshotHafCoinM(order: Order | null): number { + const snapshot = readSnapshot(order) + return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) +} + +export function calculateResourceChargeAmount( + resources: SnapshotResource[], + resourceUsage: Record +): number { + return roundMoney( + resources.reduce((sum, item) => { + if (!isChargedResource(item)) return sum + const used = resourceUsage[item.key] || 0 + return sum + used * item.unitPrice + }, 0) + ) +} + +export function calculateCheckoutTotal( + resourceCharge: number, + consumableAmount: number, + coinConsumed: number, + otherAmount: number +): number { + return roundMoney(resourceCharge + consumableAmount + coinConsumed + otherAmount) +} + +export function calculateCounterTotal(counterForm: { + consumable_amount: number + coin_consumed_m: number + other_amount: number + deposit_deduct_amount: number +}): number { + return roundMoney( + counterForm.consumable_amount + + counterForm.coin_consumed_m + + counterForm.other_amount + + counterForm.deposit_deduct_amount + ) +} + +export function hydrateResourceUsageFromOrder( + order: Order | null, + resources: SnapshotResource[] +): Record { + const usage: Record = {} + + if (!order?.checkout_info) { + return usage + } + + try { + const info = JSON.parse(order.checkout_info) as Record + const consumedResources = info.consumed_resources as Record | undefined + + if (consumedResources) { + resources.forEach((res) => { + if (res.key in consumedResources) { + usage[res.key] = consumedResources[res.key] + } + }) + } + } catch { + // ignore + } + + return usage +} + +export function hydrateCounterFormFromOrder(order: Order | null) { + if (!order?.counter_info) { + return null + } + + try { + const info = JSON.parse(order.counter_info) as Record + return { + consumable_amount: readNumber(info.consumable_amount), + coin_consumed_m: readNumber(info.coin_consumed_m), + other_amount: readNumber(info.other_amount), + deposit_deduct_amount: readNumber(info.deposit_deduct_amount), + reason: String(info.reason || ''), + evidenceText: String(info.evidence || ''), + } + } catch { + return null + } +} + +export function readError(error: unknown, fallback: string): string { + if (error && typeof error === 'object' && 'message' in error) { + return String(error.message) + } + return fallback +}