Files
medical-mall/pages/mall/consumer/payment.uvue

2595 lines
65 KiB
Plaintext
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.
<!-- 支付页面 -->
<template>
<view class="payment-page">
<view class="jd-pay-header" :style="'padding-top:' + statusBarHeight + 'px;'">
<view class="jd-nav-row" :style="'height:' + navHeight + 'px;padding-right:' + capsuleRightReserve + 'px;'">
<view class="jd-left-capsule">
<text class="jd-back-icon" @click="goBack"></text>
<view class="jd-nav-divider"></view>
<text class="jd-menu-icon">☰</text>
</view>
<view class="jd-title-center">
<text class="jd-title-clock">◷</text>
<text class="jd-title-text">{{ orderMissing ? '订单不存在' : (isPaymentExpired ? '已取消' : '等待付款') }}</text>
</view>
</view>
<view class="jd-countdown-row" v-if="paymentReady && !orderMissing && !isPaymentExpired">
<text class="jd-countdown-prefix">请在 </text>
<text class="jd-countdown-number">{{ countdownText }}</text>
<text class="jd-countdown-suffix"> 内支付</text>
</view>
<view class="jd-countdown-row" v-else-if="orderMissing">
<text class="jd-expired-text">订单不存在或已删除</text>
</view>
<view class="jd-countdown-row" v-else>
<text class="jd-expired-text">订单超时未支付,已自动取消</text>
</view>
</view>
<view class="jd-pay-notice" v-if="paymentReady && !orderMissing && !isPaymentExpired">
<text class="jd-pay-notice-text">请在下单后0.16小时内完成付款,超时未付款订单将被自动取消。</text>
</view>
<view class="payment-content">
<view v-if="orderMissing" class="card missing-card">
<view class="card-header-row">
<text class="card-title">订单状态异常</text>
</view>
<text class="missing-title">订单不存在或已删除</text>
<text class="missing-desc">请返回订单列表重新查看有效订单。</text>
<button class="missing-btn" @click="goToOrderList">返回订单列表</button>
</view>
<template v-else>
<view class="card address-card">
<view class="card-header-row">
<text class="card-title">{{ source === 'service' ? '服务地址' : '收货地址' }}</text>
<text class="card-action" @click="goEditAddress">修改</text>
</view>
<view class="address-main-row">
<view class="address-marker">
<text class="address-marker-text">收</text>
</view>
<view class="address-content">
<view class="address-user-row">
<text class="address-name">{{ addressName }}</text>
<text class="address-phone">{{ maskedAddressPhone }}</text>
</view>
<text class="address-detail">{{ fullAddressText }}</text>
</view>
</view>
</view>
<view class="card goods-card">
<view class="shop-row">
<text class="shop-name">{{ shopName }}</text>
</view>
<view v-for="item in orderItems" :key="item.id" class="goods-item" @click="goToProductDetail(item.productId)">
<image class="goods-image" :src="item.image" mode="aspectFill" />
<view class="goods-content">
<text class="goods-name">{{ item.name }}</text>
<text class="goods-spec">{{ item.spec }}</text>
<view class="goods-meta-row">
<text class="goods-price">¥{{ item.price.toFixed(2) }}</text>
<text class="goods-count">x{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
<view class="card fee-card">
<view class="card-header-row">
<text class="card-title">费用明细</text>
</view>
<view class="fee-row">
<text class="fee-label">商品数量</text>
<text class="fee-value">共 {{ totalItemCount }} 件</text>
</view>
<view class="fee-row">
<text class="fee-label">商品总价</text>
<text class="fee-value">¥{{ productAmount.toFixed(2) }}</text>
</view>
<view class="fee-row">
<text class="fee-label">运费</text>
<text class="fee-value">¥{{ deliveryFee.toFixed(2) }}</text>
</view>
<view class="fee-row">
<text class="fee-label">红包减免</text>
<text class="fee-value fee-discount">-¥{{ redPacketDiscount.toFixed(2) }}</text>
</view>
<view class="fee-row">
<text class="fee-label">会员减免</text>
<text class="fee-value fee-discount">-¥{{ memberDiscount.toFixed(2) }}</text>
</view>
<view class="fee-row">
<text class="fee-label">总减免</text>
<text class="fee-value fee-discount">-¥{{ totalDiscount.toFixed(2) }}</text>
</view>
<view class="fee-row fee-row-total">
<text class="fee-label total-label">需付款金额</text>
<text class="fee-value total-value">¥{{ payAmount.toFixed(2) }}</text>
</view>
</view>
<view class="card order-info-card">
<view class="card-header-row">
<text class="card-title">订单信息</text>
</view>
<view class="info-row">
<text class="info-label">订单编号</text>
<view class="info-value-action" @click="copyOrderNo">
<text class="info-value">{{ orderNo }}</text>
<text class="info-copy">复制</text>
</view>
</view>
<view class="info-row">
<text class="info-label">支付方式</text>
<text class="info-value">在线支付</text>
</view>
<view class="info-row">
<text class="info-label">发票类型</text>
<text class="info-value">电子普通发票</text>
</view>
<view class="info-row">
<text class="info-label">下单时间</text>
<text class="info-value">{{ orderTimeText }}</text>
</view>
<view v-if="paymentReady && !isPaymentExpired" class="method-section">
<text class="method-section-title">选择支付方式</text>
<view class="method-list">
<view v-for="method in paymentMethods"
:key="method.id"
class="method-item-modern"
@click="selectMethod(method)">
<view class="method-left">
<image class="method-img" :src="getMethodBrandIcon(method.id)" mode="aspectFit" />
<view class="method-info">
<text class="method-name">{{ method.name }}</text>
<text class="method-desc" v-if="method.id === 'balance'">当前余额: ¥{{ userBalance.toFixed(2) }}</text>
<text class="method-desc" v-else>{{ method.description }}</text>
</view>
</view>
<view class="method-right">
<view :class="['radio-circle', { checked: selectedMethod === method.id }]">
<view class="radio-inner" v-if="selectedMethod === method.id"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<GuessYouLike
title="你可能还喜欢"
:pageSize="8"
:excludeProductIds="excludeRecommendProductIds"
:loadMoreKey="guessLoadMoreKey"
@productClick="goToProductDetail"
/>
<view class="bottom-safe-space"></view>
</template>
</view>
<view class="payment-bottom" v-if="paymentReady && !orderMissing && !showPassword && !isPaymentExpired">
<button class="cancel-btn" :disabled="isCancelling" @click="openCancelSheet">取消订单</button>
<button class="pay-btn"
:class="{ disabled: !canPay || (selectedMethod === 'balance' && userBalance < payAmount) }"
@click="confirmPayment">
<text v-if="!isPaying" class="pay-text">立即支付 ¥{{ payAmount.toFixed(2) }}</text>
<text v-else class="pay-text">支付中...</text>
</button>
</view>
<view v-if="!orderMissing && isPaymentExpired" class="payment-expired-bottom">
<button class="payment-expired-btn secondary" @click="goToOrderList">返回订单列表</button>
<button class="payment-expired-btn primary" @click="goToShoppingHome">重新选购</button>
</view>
<view v-if="orderMissing" class="payment-expired-bottom">
<button class="payment-expired-btn primary" @click="goToOrderList">返回订单列表</button>
</view>
<view v-if="showCancelSheet" class="sheet-mask" @click="closeCancelSheet">
<view class="cancel-sheet" @click.stop="">
<view class="sheet-header">
<text class="sheet-title">取消订单</text>
<text class="sheet-subtitle">请选择取消原因</text>
</view>
<scroll-view class="sheet-scroll" direction="vertical" :show-scrollbar="false">
<view v-for="reason in cancelReasonOptions" :key="reason.id" class="reason-item" @click="selectCancelReason(reason.id)">
<text class="reason-text">{{ reason.label }}</text>
<view :class="['radio-circle', 'sheet-radio', { checked: selectedCancelReason === reason.id }]">
<view class="radio-inner" v-if="selectedCancelReason === reason.id"></view>
</view>
</view>
<view v-if="selectedCancelReason === 'other'" class="other-reason-box">
<input class="other-reason-input" v-model="otherCancelReason" placeholder="请输入其他原因,选填" />
</view>
</scroll-view>
<view class="sheet-actions">
<button class="sheet-btn sheet-btn-secondary" @click="closeCancelSheet">暂不取消</button>
<button class="sheet-btn sheet-btn-primary" :disabled="isCancelling" @click="confirmCancelOrder">确认取消</button>
</view>
</view>
</view>
<view v-if="showPassword" class="password-popup-mask" @click="closePasswordPopup">
<view class="password-popup-content" @click.stop="">
<view class="popup-header">
<text class="popup-close" @click="closePasswordPopup">✕</text>
<text class="popup-title">请输入支付密码</text>
<view class="popup-placeholder"></view>
</view>
<view class="popup-amount-info">
<text class="popup-amount-label">支付金额</text>
<view class="popup-amount-row">
<text class="popup-currency">¥</text>
<text class="popup-value">{{ payAmount.toFixed(2) }}</text>
</view>
</view>
<view class="password-input-row">
<view v-for="(_, index) in 6"
:key="index"
class="password-box">
<view v-if="password.length > index" class="password-dot"></view>
</view>
</view>
<text class="forgot-password-link" @click="forgotPassword">忘记密码?</text>
<view class="password-keyboard-popup">
<view class="keyboard-grid keyboard-grid-popup">
<view v-for="num in 9"
:key="num"
class="keyboard-key keyboard-key-popup"
@click="inputPassword(num.toString())">
<text class="key-text key-text-popup">{{ num }}</text>
</view>
<view class="keyboard-key keyboard-key-popup keyboard-key-empty"></view>
<view class="keyboard-key keyboard-key-popup" @click="inputPassword('0')">
<text class="key-text key-text-popup">0</text>
</view>
<view class="keyboard-key keyboard-key-popup" @click="deletePassword">
<text class="key-text key-text-popup">⌫</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, watch, computed, onUnmounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
import GuessYouLike from '@/components/mall/GuessYouLike/GuessYouLike.uvue'
import { formatCountdownHMS, getUnifiedDisplayState, ORDER_PAY_TIMEOUT_SECONDS, ORDER_STATUS_CANCELLED, ORDER_STATUS_PAID_OR_SHIPPING, ORDER_STATUS_PENDING, ORDER_STATUS_TIMEOUT_LEGACY, ORDER_TIMEOUT_CANCEL_REASON, PAYMENT_STATUS_PAID, PAYMENT_STATUS_TIMEOUT, PAYMENT_STATUS_UNPAID, type OrderStatusSource } from '@/utils/orderStatus.uts'
import { dispatchPaidHomecareOrder, showHomecareDispatchFailureModal } from '@/services/serviceOrderService.uts'
type PaymentMethodType = {
id: string
name: string
description: string
icon: string
enabled: boolean
}
type AddressInfo = {
name: string
phone: string
province: string
city: string
district: string
address: string
}
type OrderProductInfo = {
id: string
productId: string
name: string
spec: string
image: string
price: number
quantity: number
}
type CancelReasonOption = {
id: string
label: string
}
const DEFAULT_IMAGE = '/static/logo.png'
const getOptionString = (options: UTSJSONObject, key: string): string => {
try {
const value = options.getString(key)
if (value != null) {
return value
}
} catch (e) {
}
try {
const normalized = JSON.parse(JSON.stringify(options)) as UTSJSONObject
const value = normalized.getString(key)
if (value != null) {
return value
}
} catch (e) {
}
return ''
}
const parseNumberValue = (value: string): number => {
if (value == null || value === '') {
return 0
}
const parsed = parseFloat(value)
if (isNaN(parsed)) {
return 0
}
return parsed
}
const getStringField = (obj: UTSJSONObject, key: string): string => {
try {
const value = obj.getString(key)
if (value != null) {
return value
}
} catch (e) {
}
return ''
}
const getNumberField = (obj: UTSJSONObject, key: string): number => {
try {
const value = obj.getNumber(key)
if (value != null) {
return value
}
} catch (e) {
}
return 0
}
const createMockAddress = (): AddressInfo => {
return {
name: '张女士',
phone: '13800138000',
province: '广东省',
city: '深圳市',
district: '南山区',
address: '科技园科苑路 15 号 2 栋 1608 室'
}
}
const createEmptyAddress = (): AddressInfo => {
return {
name: '',
phone: '',
province: '',
city: '',
district: '',
address: ''
}
}
const createMockOrderItems = (): Array<OrderProductInfo> => {
return [
{
id: 'mock-product-1',
productId: 'mock-product-1',
name: '医用雾化器 家用便携静音款',
spec: '标准版 / 一机三配件',
image: DEFAULT_IMAGE,
price: 199.00,
quantity: 1
},
{
id: 'mock-product-2',
productId: 'mock-product-2',
name: '护理级一次性口罩 50 只装',
spec: '成人款 / 独立包装',
image: DEFAULT_IMAGE,
price: 39.90,
quantity: 2
}
]
}
const createCancelReasonOptions = (): Array<CancelReasonOption> => {
return [
{ id: 'no-need', label: '不想买了' },
{ id: 'info-error', label: '信息填写错误,重新下单' },
{ id: 'address-error', label: '地址或服务时间填写错误' },
{ id: 'price', label: '商品价格原因' },
{ id: 'payment', label: '支付方式遇到问题' },
{ id: 'other', label: '其他' }
]
}
const orderId = ref<string>('')
const source = ref<string>('goods')
const bizType = ref<string>('goods')
const orderNo = ref<string>('')
const amount = ref<number>(0)
const productAmount = ref<number>(0)
const deliveryFee = ref<number>(0)
const redPacketDiscount = ref<number>(0)
const memberDiscount = ref<number>(0)
const paymentMethods = ref<Array<PaymentMethodType>>([])
const selectedMethod = ref<string>('wechat')
const userBalance = ref<number>(0)
const isPaying = ref<boolean>(false)
const isDispatchSubmitting = ref<boolean>(false)
const showPassword = ref<boolean>(false)
const password = ref<string>('')
const addressInfo = ref<AddressInfo>(createEmptyAddress())
const shopName = ref<string>('')
const orderItems = ref<Array<OrderProductInfo>>([])
const cancelReasonOptions = ref<Array<CancelReasonOption>>(createCancelReasonOptions())
const showCancelSheet = ref<boolean>(false)
const selectedCancelReason = ref<string>('')
const otherCancelReason = ref<string>('')
const isCancelling = ref<boolean>(false)
const orderCreatedAt = ref<string>('')
const remainingSeconds = ref<number>(ORDER_PAY_TIMEOUT_SECONDS)
const timeoutHandled = ref<boolean>(false)
const paymentDeadlineMs = ref<number>(0)
const guessLoadMoreKey = ref<number>(0)
const isPaymentExpired = ref<boolean>(false)
const orderMissing = ref<boolean>(false)
const paymentReady = ref<boolean>(false)
const isPreviewMode = ref<boolean>(false)
const orderStatus = ref<number>(ORDER_STATUS_PENDING)
const paymentStatus = ref<number>(PAYMENT_STATUS_UNPAID)
const cancelReason = ref<string>('')
const payExpireAtText = ref<string>('')
const statusBarHeight = ref<number>(0)
const navHeight = ref<number>(44)
const capsuleRightReserve = ref<number>(12)
let countdownTimer = 0
const ensureLoggedIn = (): boolean => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
const redirectUrl = orderId.value !== ''
? '/pages/mall/consumer/payment?orderId=' + orderId.value + '&bizType=' + bizType.value + '&source=' + source.value + '&amount=' + amount.value
: '/pages/mall/consumer/payment'
goToLogin(redirectUrl)
return false
}
return true
}
const totalDiscount = computed((): number => {
return redPacketDiscount.value + memberDiscount.value
})
const calculatedPayAmount = computed((): number => {
const value = productAmount.value + deliveryFee.value - totalDiscount.value
if (value > 0) {
return value
}
return 0
})
const payAmount = computed((): number => {
if (amount.value > 0) {
return amount.value
}
return calculatedPayAmount.value
})
const canPay = computed((): boolean => {
return paymentReady.value
&& !orderMissing.value
&& !isPaymentExpired.value
&& orderStatus.value == ORDER_STATUS_PENDING
&& paymentStatus.value == PAYMENT_STATUS_UNPAID
&& !isPaying.value
})
const totalItemCount = computed((): number => {
let count = 0
for (let i = 0; i < orderItems.value.length; i++) {
count += orderItems.value[i].quantity
}
return count
})
const maskedAddressPhone = computed((): string => {
const phone = addressInfo.value.phone
if (phone.length >= 11) {
return phone.slice(0, 3) + '****' + phone.slice(phone.length - 4)
}
return phone
})
const fullAddressText = computed((): string => {
return addressInfo.value.province + addressInfo.value.city + addressInfo.value.district + addressInfo.value.address
})
const addressName = computed((): string => {
return addressInfo.value.name
})
const excludeRecommendProductIds = computed((): Array<string> => {
const result: Array<string> = []
for (let i = 0; i < orderItems.value.length; i++) {
const productId = orderItems.value[i].productId
if (productId !== '' && result.indexOf(productId) < 0) {
result.push(productId)
}
}
return result
})
const formatClockCountdown = (seconds: number): string => {
let safeSeconds = seconds
if (safeSeconds < 0) {
safeSeconds = 0
}
const hours = Math.floor(safeSeconds / 3600)
const minutes = Math.floor((safeSeconds % 3600) / 60)
const remainSeconds = safeSeconds % 60
const hourText = hours < 10 ? '0' + hours : '' + hours
const minuteText = minutes < 10 ? '0' + minutes : '' + minutes
const secondText = remainSeconds < 10 ? '0' + remainSeconds : '' + remainSeconds
return hourText + ':' + minuteText + ':' + secondText
}
const countdownText = computed((): string => {
return formatClockCountdown(remainingSeconds.value)
})
const payStatusTitle = computed((): string => {
return isPaymentExpired.value ? '已取消' : '等待付款'
})
const payStatusDesc = computed((): string => {
return isPaymentExpired.value ? '订单超时未支付,已自动取消' : '请在 ' + countdownText.value + ' 内支付'
})
const getDeadlineByCreatedAt = (createdAt: string): number => {
const createdAtMs = parseDateTime(createdAt)
if (createdAtMs <= 0) {
return 0
}
return createdAtMs + ORDER_PAY_TIMEOUT_SECONDS * 1000
}
const orderTimeText = computed((): string => {
if (orderCreatedAt.value === '') {
return '--'
}
const date = new Date(orderCreatedAt.value)
if (isNaN(date.getTime())) {
return orderCreatedAt.value
}
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
const monthText = month < 10 ? '0' + month : '' + month
const dayText = day < 10 ? '0' + day : '' + day
const hourText = hour < 10 ? '0' + hour : '' + hour
const minuteText = minute < 10 ? '0' + minute : '' + minute
const secondText = second < 10 ? '0' + second : '' + second
return year + '-' + monthText + '-' + dayText + ' ' + hourText + ':' + minuteText + ':' + secondText
})
const getLocalDeadline = (targetOrderId: string): number => {
if (targetOrderId === '') {
return 0
}
try {
const cache = uni.getStorageSync('payment_deadline_' + targetOrderId)
if (cache == null || cache === '') {
return 0
}
if (typeof cache === 'number') {
return cache as number
}
const parsed = parseInt('' + cache)
if (!isNaN(parsed) && parsed > 0) {
return parsed
}
} catch (e) {
}
return 0
}
const saveLocalDeadline = (targetOrderId: string, deadlineMs: number): void => {
if (targetOrderId === '' || deadlineMs <= 0) {
return
}
try {
uni.setStorageSync('payment_deadline_' + targetOrderId, '' + deadlineMs)
} catch (e) {
}
}
const removeLocalDeadline = (targetOrderId: string): void => {
if (targetOrderId === '') {
return
}
try {
uni.removeStorageSync('payment_deadline_' + targetOrderId)
} catch (e) {
}
}
const parseDateTime = (value: string): number => {
if (value === '') {
return 0
}
const date = new Date(value)
if (isNaN(date.getTime())) {
return 0
}
return date.getTime()
}
const createCurrentOrderSource = (status: number, payment: number, reason: string, payExpireAt: string): OrderStatusSource => {
return {
source: source.value,
order_status: status,
payment_status: payment,
pay_expire_at: payExpireAt,
created_at: orderCreatedAt.value,
cancel_reason: reason
}
}
const isExpiredState = (status: number, payment: number, reason: string, payExpireAt: string): boolean => {
const displayState = getUnifiedDisplayState(createCurrentOrderSource(status, payment, reason, payExpireAt))
return displayState == 'cancelled' || displayState == 'expired'
}
const stopCountdown = (): void => {
if (countdownTimer > 0) {
clearInterval(countdownTimer)
countdownTimer = 0
}
}
const getCancelledStatusBySource = (): number => {
return source.value == 'service' ? ORDER_STATUS_TIMEOUT_LEGACY : ORDER_STATUS_CANCELLED
}
const resetRealOrderView = (): void => {
addressInfo.value = createEmptyAddress()
shopName.value = ''
orderItems.value = []
productAmount.value = 0
deliveryFee.value = 0
redPacketDiscount.value = 0
memberDiscount.value = 0
}
const enterPreviewMode = (): void => {
isPreviewMode.value = true
orderMissing.value = false
paymentReady.value = true
addressInfo.value = createMockAddress()
shopName.value = '京东健康自营店'
orderItems.value = createMockOrderItems()
productAmount.value = 0
deliveryFee.value = 0
redPacketDiscount.value = 0
memberDiscount.value = 0
calculateProductAmountFromItems()
calculatePriceDetails(amount.value)
if (orderNo.value === '') {
orderNo.value = 'ORD_PENDING_' + Date.now()
}
if (orderCreatedAt.value === '') {
orderCreatedAt.value = new Date().toISOString()
}
paymentDeadlineMs.value = Date.now() + ORDER_PAY_TIMEOUT_SECONDS * 1000
startCountdown(paymentDeadlineMs.value)
}
const markPaymentExpiredInPage = (): void => {
isPaymentExpired.value = true
remainingSeconds.value = 0
orderStatus.value = getCancelledStatusBySource()
paymentStatus.value = PAYMENT_STATUS_TIMEOUT
cancelReason.value = ORDER_TIMEOUT_CANCEL_REASON
paymentDeadlineMs.value = 0
paymentReady.value = !orderMissing.value
stopCountdown()
showCancelSheet.value = false
showPassword.value = false
password.value = ''
}
const applyOrderState = (status: number, payment: number, reason: string, payExpireAt: string): void => {
orderStatus.value = status
paymentStatus.value = payment
cancelReason.value = reason
payExpireAtText.value = payExpireAt
if (isExpiredState(status, payment, reason, payExpireAt)) {
markPaymentExpiredInPage()
orderStatus.value = getCancelledStatusBySource()
paymentStatus.value = PAYMENT_STATUS_TIMEOUT
cancelReason.value = reason !== '' ? reason : ORDER_TIMEOUT_CANCEL_REASON
} else {
isPaymentExpired.value = false
}
}
const updateCountdownByDeadline = (): void => {
if (paymentDeadlineMs.value <= 0) {
return
}
const diff = Math.floor((paymentDeadlineMs.value - Date.now()) / 1000)
remainingSeconds.value = diff > 0 ? diff : 0
if (remainingSeconds.value <= 0) {
stopCountdown()
handlePaymentTimeout()
}
}
const startCountdown = (deadlineMs: number): void => {
if (deadlineMs <= 0) {
paymentDeadlineMs.value = 0
remainingSeconds.value = 0
return
}
if (deadlineMs <= Date.now()) {
paymentDeadlineMs.value = deadlineMs
remainingSeconds.value = 0
handlePaymentTimeout()
return
}
paymentDeadlineMs.value = deadlineMs
stopCountdown()
updateCountdownByDeadline()
if (remainingSeconds.value <= 0) {
return
}
countdownTimer = setInterval(() => {
updateCountdownByDeadline()
}, 1000)
}
const resolvePaymentDeadline = (orderObj: UTSJSONObject | null): number => {
if (orderObj == null) {
return 0
}
const payExpireAt = getStringField(orderObj, 'pay_expire_at')
if (payExpireAt !== '') {
const expireDate = new Date(payExpireAt)
if (!isNaN(expireDate.getTime())) {
return expireDate.getTime()
}
}
const createdAt = getStringField(orderObj, 'created_at')
if (createdAt !== '') {
const createdDate = new Date(createdAt)
if (!isNaN(createdDate.getTime())) {
return createdDate.getTime() + ORDER_PAY_TIMEOUT_SECONDS * 1000
}
}
return 0
}
const loadPaymentMethods = () => {
const methods: PaymentMethodType[] = [
{
id: 'wechat',
name: '微信支付',
description: '使用微信支付完成订单',
icon: '💳',
enabled: true
}
]
paymentMethods.value = methods
}
const loadUserBalance = async () => {
try {
const balance = await supabaseService.getUserBalanceNumber()
userBalance.value = balance
} catch (err) {
console.error('加载用户余额异常:', err)
userBalance.value = 0
}
}
const calculatePriceDetails = (totalAmount: number) => {
if (productAmount.value <= 0) {
productAmount.value = totalAmount > 0 ? totalAmount : 0
}
if (deliveryFee.value < 0) {
deliveryFee.value = 0
}
if (redPacketDiscount.value < 0) {
redPacketDiscount.value = 0
}
if (memberDiscount.value < 0) {
memberDiscount.value = 0
}
const fallbackAmount = productAmount.value + deliveryFee.value - totalDiscount.value
if (totalAmount > 0) {
amount.value = totalAmount
} else if (fallbackAmount > 0) {
amount.value = fallbackAmount
}
}
const updateOrderInStorage = (targetOrderId: string, status: number, cancelReason: string = '') => {
try {
const ordersStr = uni.getStorageSync('orders')
let orders: UTSJSONObject[] = []
if (ordersStr != null && ordersStr !== '') {
const parsed = JSON.parse(ordersStr as string)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
const itemStr = JSON.stringify(parsed[i])
const itemParsed = JSON.parse(itemStr)
if (itemParsed != null) {
orders.push(itemParsed as UTSJSONObject)
}
}
}
}
let foundIndex = -1
for (let i = 0; i < orders.length; i++) {
const order = orders[i]
if (order['id'] === targetOrderId) {
foundIndex = i
break
}
}
if (foundIndex !== -1) {
orders[foundIndex]['status'] = status
orders[foundIndex]['order_status'] = status
orders[foundIndex]['payment_status'] = status == ORDER_STATUS_PAID_OR_SHIPPING ? PAYMENT_STATUS_PAID : (status == ORDER_STATUS_CANCELLED ? PAYMENT_STATUS_TIMEOUT : PAYMENT_STATUS_UNPAID)
orders[foundIndex]['updated_at'] = new Date().toISOString()
if (cancelReason !== '') {
orders[foundIndex]['cancel_reason'] = cancelReason
}
uni.setStorageSync('orders', JSON.stringify(orders))
console.log('订单状态已更新到Storage (orders):', targetOrderId, status)
} else {
console.log('本地缓存中无订单数据,已忽略:', targetOrderId)
}
} catch (e) {
console.error('更新订单状态失败', e)
}
}
const navigateAfterCancel = () => {
setTimeout(() => {
uni.navigateBack({
fail: () => {
uni.redirectTo({
url: '/pages/mall/consumer/orders'
})
}
})
}, 1200)
}
const requestWechatPaymentResult = async (): Promise<string> => {
return await new Promise<string>((resolve: (value: string) => void) => {
uni.showActionSheet({
itemList: ['模拟支付成功', '模拟用户取消支付', '模拟支付失败'],
success: (res) => {
if (res.tapIndex == 0) {
resolve('success')
} else if (res.tapIndex == 1) {
resolve('cancel')
} else {
resolve('fail')
}
},
fail: () => {
resolve('cancel')
}
})
})
}
const handlePaymentTimeout = async () => {
if (timeoutHandled.value) {
return
}
timeoutHandled.value = true
markPaymentExpiredInPage()
let success = false
try {
if (orderId.value !== '') {
success = await supabaseService.expireUnifiedOrder(orderId.value, source.value)
} else {
success = true
}
} catch (e) {
console.error('订单超时更新失败:', e)
}
if (!success && orderId.value !== '') {
try {
const latestOrder = await supabaseService.getUnifiedOrderDetail(orderId.value, source.value)
if (latestOrder != null) {
const latestObj = JSON.parse(JSON.stringify(latestOrder)) as UTSJSONObject
const latestStatus = getNumberField(latestObj, 'order_status')
const latestPayment = getNumberField(latestObj, 'payment_status')
const latestReason = getStringField(latestObj, 'cancel_reason')
const latestExpire = getStringField(latestObj, 'pay_expire_at')
applyOrderState(latestStatus, latestPayment, latestReason, latestExpire)
success = isExpiredState(latestStatus, latestPayment, latestReason, latestExpire)
}
} catch (e) {
}
}
const cancelledStatus = getCancelledStatusBySource()
updateOrderInStorage(orderId.value, cancelledStatus, ORDER_TIMEOUT_CANCEL_REASON)
uni.$emit('orderUpdated', { orderId: orderId.value, status: cancelledStatus, paymentStatus: PAYMENT_STATUS_TIMEOUT, cancelReason: ORDER_TIMEOUT_CANCEL_REASON, payExpireAt: payExpireAtText.value })
if (success) {
uni.showToast({ title: '订单已取消', icon: 'none' })
return
}
uni.showToast({ title: '订单已取消', icon: 'none' })
}
const parseAddressFromOrder = (orderObj: UTSJSONObject) => {
let parsedAddress = createEmptyAddress()
try {
const shippingAddressRaw = orderObj.get('shipping_address')
if (shippingAddressRaw != null) {
const shippingAddress = JSON.parse(JSON.stringify(shippingAddressRaw)) as UTSJSONObject
const nameValue = getStringField(shippingAddress, 'name')
const mobileValue = getStringField(shippingAddress, 'mobile')
const phoneValue = getStringField(shippingAddress, 'phone')
const provinceValue = getStringField(shippingAddress, 'province')
const cityValue = getStringField(shippingAddress, 'city')
const districtValue = getStringField(shippingAddress, 'district')
const detailValue = getStringField(shippingAddress, 'detail')
const addressValue = getStringField(shippingAddress, 'address')
if (nameValue !== '') parsedAddress.name = nameValue
if (mobileValue !== '') parsedAddress.phone = mobileValue
if (phoneValue !== '') parsedAddress.phone = phoneValue
if (provinceValue !== '') parsedAddress.province = provinceValue
if (cityValue !== '') parsedAddress.city = cityValue
if (districtValue !== '') parsedAddress.district = districtValue
if (detailValue !== '') parsedAddress.address = detailValue
if (addressValue !== '') parsedAddress.address = addressValue
}
} catch (e) {
}
const receiverName = getStringField(orderObj, 'receiver_name')
const receiverPhone = getStringField(orderObj, 'receiver_phone')
const addressDetail = getStringField(orderObj, 'address_detail')
if (receiverName !== '') parsedAddress.name = receiverName
if (receiverPhone !== '') parsedAddress.phone = receiverPhone
if (addressDetail !== '') parsedAddress.address = addressDetail
addressInfo.value = parsedAddress
}
const parseItemsFromOrder = (orderObj: UTSJSONObject) => {
const nextItems: Array<OrderProductInfo> = []
try {
const itemsRaw = orderObj.get('items')
if (itemsRaw != null) {
const itemsParsed = JSON.parse(JSON.stringify(itemsRaw))
if (Array.isArray(itemsParsed)) {
for (let i = 0; i < itemsParsed.length; i++) {
const itemObj = JSON.parse(JSON.stringify(itemsParsed[i])) as UTSJSONObject
const itemId = getStringField(itemObj, 'id') !== '' ? getStringField(itemObj, 'id') : 'item-' + i
const productIdValue = getStringField(itemObj, 'product_id') !== '' ? getStringField(itemObj, 'product_id') : itemId
const nameValue = getStringField(itemObj, 'product_name') !== '' ? getStringField(itemObj, 'product_name') : (getStringField(itemObj, 'name') !== '' ? getStringField(itemObj, 'name') : '商品信息加载中')
const specValue = getStringField(itemObj, 'sku_name') !== '' ? getStringField(itemObj, 'sku_name') : (getStringField(itemObj, 'spec') !== '' ? getStringField(itemObj, 'spec') : (getStringField(itemObj, 'specification') !== '' ? getStringField(itemObj, 'specification') : '默认规格'))
const imageValue = getStringField(itemObj, 'product_image') !== '' ? getStringField(itemObj, 'product_image') : (getStringField(itemObj, 'image') !== '' ? getStringField(itemObj, 'image') : (getStringField(itemObj, 'cover') !== '' ? getStringField(itemObj, 'cover') : DEFAULT_IMAGE))
const priceValue = getNumberField(itemObj, 'price') > 0 ? getNumberField(itemObj, 'price') : (getNumberField(itemObj, 'unit_price') > 0 ? getNumberField(itemObj, 'unit_price') : getNumberField(itemObj, 'sale_price'))
const quantityValue = getNumberField(itemObj, 'quantity') > 0 ? getNumberField(itemObj, 'quantity') : (getNumberField(itemObj, 'count') > 0 ? getNumberField(itemObj, 'count') : 1)
nextItems.push({
id: itemId,
productId: productIdValue,
name: nameValue,
spec: specValue,
image: imageValue,
price: priceValue > 0 ? priceValue : 0,
quantity: quantityValue > 0 ? quantityValue : 1
})
}
}
}
} catch (e) {
console.error('解析订单商品失败:', e)
}
if (nextItems.length > 0) {
orderItems.value = nextItems
} else {
orderItems.value = []
}
}
const calculateProductAmountFromItems = () => {
if (productAmount.value > 0) {
return
}
let total = 0
for (let i = 0; i < orderItems.value.length; i++) {
total += orderItems.value[i].price * orderItems.value[i].quantity
}
productAmount.value = total
}
const loadOrderInfo = async () => {
try {
if (orderId.value == '') return
timeoutHandled.value = false
orderMissing.value = false
paymentReady.value = false
isPreviewMode.value = false
resetRealOrderView()
const order = await supabaseService.getUnifiedOrderDetail(orderId.value, source.value)
if (order != null) {
const orderStr = JSON.stringify(order)
const orderParsed = JSON.parse(orderStr)
if (orderParsed == null) {
console.error('订单数据解析失败')
return
}
const orderObj = orderParsed as UTSJSONObject
const latestStatus = getNumberField(orderObj, 'order_status')
const latestPayment = getNumberField(orderObj, 'payment_status')
const latestReason = getStringField(orderObj, 'cancel_reason')
const latestExpire = getStringField(orderObj, 'pay_expire_at')
source.value = getStringField(orderObj, 'source') !== '' ? getStringField(orderObj, 'source') : source.value
bizType.value = getStringField(orderObj, 'biz_type') !== '' ? getStringField(orderObj, 'biz_type') : bizType.value
const orderNoVal = getStringField(orderObj, 'order_no')
if (orderNoVal != '') {
orderNo.value = orderNoVal
}
const createdAtVal = getStringField(orderObj, 'created_at')
if (createdAtVal != '') {
orderCreatedAt.value = createdAtVal
}
let effectivePayExpireAt = latestExpire
if (effectivePayExpireAt == '') {
const ensuredExpireAt = await supabaseService.ensureUnifiedPayExpireAt(orderId.value, source.value)
if (ensuredExpireAt != '') {
effectivePayExpireAt = ensuredExpireAt
}
}
applyOrderState(latestStatus, latestPayment, latestReason, effectivePayExpireAt)
const shopNameValue = getStringField(orderObj, 'merchant_name') !== ''
? getStringField(orderObj, 'merchant_name')
: (getStringField(orderObj, 'store_name') !== ''
? getStringField(orderObj, 'store_name')
: (getStringField(orderObj, 'shop_name') !== '' ? getStringField(orderObj, 'shop_name') : shopName.value))
if (shopNameValue !== '') {
shopName.value = shopNameValue
}
productAmount.value = getNumberField(orderObj, 'product_amount')
if (productAmount.value <= 0) {
productAmount.value = getNumberField(orderObj, 'subtotal_amount')
}
deliveryFee.value = getNumberField(orderObj, 'delivery_fee')
if (deliveryFee.value <= 0) {
deliveryFee.value = getNumberField(orderObj, 'shipping_fee')
}
redPacketDiscount.value = getNumberField(orderObj, 'red_packet_discount')
if (redPacketDiscount.value <= 0) {
redPacketDiscount.value = getNumberField(orderObj, 'coupon_discount')
}
memberDiscount.value = getNumberField(orderObj, 'member_discount')
const discountAmountValue = getNumberField(orderObj, 'discount_amount')
if (memberDiscount.value <= 0 && discountAmountValue > 0 && redPacketDiscount.value <= 0) {
redPacketDiscount.value = discountAmountValue
}
const totalAmountValue = getNumberField(orderObj, 'total_amount')
if (totalAmountValue > 0) {
amount.value = totalAmountValue
}
parseAddressFromOrder(orderObj)
parseItemsFromOrder(orderObj)
calculateProductAmountFromItems()
calculatePriceDetails(totalAmountValue)
const deadlineMs = resolvePaymentDeadline(orderObj)
if (isExpiredState(latestStatus, latestPayment, latestReason, effectivePayExpireAt) || (deadlineMs > 0 && deadlineMs <= Date.now())) {
await supabaseService.expireUnifiedOrder(orderId.value, source.value)
markPaymentExpiredInPage()
payExpireAtText.value = effectivePayExpireAt
paymentReady.value = true
return
}
if (deadlineMs <= 0) {
orderMissing.value = true
stopCountdown()
remainingSeconds.value = 0
uni.showToast({ title: '订单数据异常', icon: 'none' })
return
}
paymentReady.value = true
startCountdown(deadlineMs)
} else {
orderMissing.value = true
paymentReady.value = false
stopCountdown()
remainingSeconds.value = 0
resetRealOrderView()
uni.showToast({ title: '订单不存在或已删除', icon: 'none' })
}
} catch (err) {
console.error('加载订单信息异常:', err)
orderMissing.value = true
paymentReady.value = false
stopCountdown()
remainingSeconds.value = 0
resetRealOrderView()
}
}
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore != null) {
const userStr = JSON.stringify(userStore)
const userParsed = JSON.parse(userStr)
if (userParsed != null) {
const userObj = userParsed as UTSJSONObject
const id = userObj.getString('id')
if (id != null) {
return id
}
}
}
return ''
}
const getMethodBrandIcon = (methodId: string): string => {
if (methodId === 'wechat') {
return '/static/logo.png'
} else if (methodId === 'alipay') {
return '/static/logo.png'
} else if (methodId === 'balance') {
return '/static/logo.png'
} else if (methodId === 'bankcard') {
return '/static/logo.png'
}
return '/static/logo.png'
}
const selectMethod = (method: PaymentMethodType) => {
if (!method.enabled) {
uni.showToast({
title: '该支付方式暂不可用',
icon: 'none'
})
return
}
selectedMethod.value = method.id
showPassword.value = false
password.value = ''
}
const closePasswordPopup = () => {
showPassword.value = false
password.value = ''
}
const openCancelSheet = () => {
if (!paymentReady.value || orderMissing.value || isPaymentExpired.value) {
return
}
showCancelSheet.value = true
}
const closeCancelSheet = () => {
showCancelSheet.value = false
selectedCancelReason.value = ''
otherCancelReason.value = ''
}
const selectCancelReason = (reasonId: string) => {
selectedCancelReason.value = reasonId
if (reasonId !== 'other') {
otherCancelReason.value = ''
}
}
const getSelectedCancelReasonText = (): string => {
for (let i = 0; i < cancelReasonOptions.value.length; i++) {
const item = cancelReasonOptions.value[i]
if (item.id === selectedCancelReason.value) {
if (item.id === 'other' && otherCancelReason.value !== '') {
return item.label + '' + otherCancelReason.value
}
return item.label
}
}
return ''
}
const confirmCancelOrder = async () => {
if (orderMissing.value) {
uni.showToast({ title: '订单不存在或已删除', icon: 'none' })
return
}
if (selectedCancelReason.value === '') {
uni.showToast({ title: '请选择取消原因', icon: 'none' })
return
}
isCancelling.value = true
uni.showLoading({ title: '取消中...' })
const cancelReasonText = getSelectedCancelReasonText()
const cancelledStatus = getCancelledStatusBySource()
let success = false
try {
if (orderId.value !== '') {
success = await supabaseService.cancelUnifiedOrder(orderId.value, source.value, cancelReasonText)
} else {
success = true
}
if (!success && orderId.value === '') {
success = true
}
if (success) {
applyOrderState(cancelledStatus, PAYMENT_STATUS_TIMEOUT, cancelReasonText, payExpireAtText.value)
updateOrderInStorage(orderId.value, cancelledStatus, cancelReasonText)
uni.$emit('orderUpdated', { orderId: orderId.value, status: cancelledStatus, paymentStatus: PAYMENT_STATUS_TIMEOUT, cancelReason: cancelReasonText, payExpireAt: payExpireAtText.value })
closeCancelSheet()
stopCountdown()
uni.hideLoading()
uni.showToast({ title: '订单已取消', icon: 'success' })
navigateAfterCancel()
} else {
uni.hideLoading()
uni.showToast({ title: '取消失败', icon: 'none' })
}
} catch (err) {
console.error('取消订单异常:', err)
uni.hideLoading()
uni.showToast({ title: '取消失败', icon: 'none' })
} finally {
isCancelling.value = false
}
}
const showLeaveConfirm = () => {
uni.showModal({
title: '离开支付页',
content: '确定离开支付页面?订单将保留在待付款中',
confirmText: '确定离开',
cancelText: '继续支付',
success: (res) => {
if (res.confirm) {
uni.navigateBack({
fail: () => {
uni.redirectTo({
url: '/pages/mall/consumer/orders'
})
}
})
}
}
})
}
const navigateBackDirect = () => {
uni.navigateBack({
fail: () => {
uni.redirectTo({
url: '/pages/mall/consumer/orders'
})
}
})
}
const shouldShowLeaveConfirm = (): boolean => {
return paymentReady.value
&& !orderMissing.value
&& !isPaymentExpired.value
&& orderStatus.value == ORDER_STATUS_PENDING
&& paymentStatus.value == PAYMENT_STATUS_UNPAID
&& remainingSeconds.value > 0
}
const goBack = () => {
if (shouldShowLeaveConfirm()) {
showLeaveConfirm()
return
}
navigateBackDirect()
}
const goEditAddress = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address'
})
}
const copyOrderNo = () => {
if (orderNo.value === '') {
uni.showToast({ title: '暂无订单编号', icon: 'none' })
return
}
uni.setClipboardData({
data: orderNo.value,
success: () => {
uni.showToast({ title: '订单编号已复制', icon: 'success' })
}
})
}
const goToProductDetail = (productId: string) => {
const targetId = productId !== '' ? productId : 'mock-product'
uni.navigateTo({ url: '/pages/mall/consumer/product-detail?id=' + targetId })
// TODO: 如果商品详情页参数要求 productId可在该路由稳定后统一切换
}
const confirmPayment = async () => {
if (isPaying.value) return
if (orderMissing.value || !paymentReady.value) {
uni.showToast({ title: '订单不存在或已删除', icon: 'none' })
return
}
if (isPaymentExpired.value || remainingSeconds.value <= 0) {
markPaymentExpiredInPage()
uni.showToast({ title: '订单已取消,不能继续支付', icon: 'none' })
return
}
if (!ensureLoggedIn()) return
isPaying.value = true
try {
if (orderId.value !== '') {
const latestOrder = await supabaseService.getUnifiedOrderDetail(orderId.value, source.value)
if (latestOrder == null) {
orderMissing.value = true
paymentReady.value = false
stopCountdown()
uni.showToast({ title: '订单不存在或已删除', icon: 'none' })
isPaying.value = false
return
}
const latestStatus = getNumberField(latestOrder, 'order_status')
const latestPayment = getNumberField(latestOrder, 'payment_status')
const latestReason = getStringField(latestOrder, 'cancel_reason')
const latestExpire = getStringField(latestOrder, 'pay_expire_at')
applyOrderState(latestStatus, latestPayment, latestReason, latestExpire)
const latestState = getUnifiedDisplayState(createCurrentOrderSource(latestStatus, latestPayment, latestReason, latestExpire))
if (latestState == 'expired' || latestState == 'cancelled') {
await supabaseService.expireUnifiedOrder(orderId.value, source.value)
markPaymentExpiredInPage()
uni.showToast({ title: '订单已取消,不能继续支付', icon: 'none' })
isPaying.value = false
return
}
if (latestState != 'pending_pay') {
uni.showToast({ title: '订单状态已变更', icon: 'none' })
isPaying.value = false
return
}
}
const paymentResult = await requestWechatPaymentResult()
if (paymentResult == 'cancel') {
if (orderId.value !== '') {
await supabaseService.markUnifiedOrderPaymentCancelled(orderId.value, source.value)
}
applyOrderState(ORDER_STATUS_PENDING, PAYMENT_STATUS_UNPAID, '', payExpireAtText.value)
showPassword.value = false
password.value = ''
isPaying.value = false
uni.$emit('orderUpdated', { orderId: orderId.value, status: ORDER_STATUS_PENDING, paymentStatus: PAYMENT_STATUS_UNPAID, cancelReason: '', payExpireAt: payExpireAtText.value })
uni.showToast({ title: '已保留待付款订单', icon: 'none' })
return
}
if (paymentResult == 'fail') {
isPaying.value = false
uni.showToast({ title: '支付未完成,请重试', icon: 'none' })
return
}
uni.showLoading({ title: '支付中...' })
console.log('[confirmPayment] 开始支付, orderId:', orderId.value, 'method:', selectedMethod.value)
const success = await supabaseService.payUnifiedOrder(orderId.value, source.value, selectedMethod.value, payAmount.value)
console.log('[confirmPayment] 支付结果:', success)
if (!success) {
console.error('[confirmPayment] payOrder 返回 false')
if (orderId.value !== '') {
const latestOrder = await supabaseService.getUnifiedOrderDetail(orderId.value, source.value)
if (latestOrder != null) {
const latestStatus = getNumberField(latestOrder, 'order_status')
const latestPayment = getNumberField(latestOrder, 'payment_status')
const latestReason = getStringField(latestOrder, 'cancel_reason')
const latestExpire = getStringField(latestOrder, 'pay_expire_at')
applyOrderState(latestStatus, latestPayment, latestReason, latestExpire)
if (isExpiredState(latestStatus, latestPayment, latestReason, latestExpire)) {
markPaymentExpiredInPage()
uni.hideLoading()
uni.showToast({ title: '订单已取消,不能继续支付', icon: 'none' })
isPaying.value = false
return
}
}
}
uni.hideLoading()
uni.showToast({
title: '支付处理失败',
icon: 'none'
})
isPaying.value = false
return
}
uni.hideLoading()
stopCountdown()
orderStatus.value = ORDER_STATUS_PAID_OR_SHIPPING
paymentStatus.value = PAYMENT_STATUS_PAID
isPaymentExpired.value = false
paymentReady.value = true
updateOrderInStorage(orderId.value, ORDER_STATUS_PAID_OR_SHIPPING)
uni.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
uni.$emit('orderUpdated', { orderId: orderId.value, status: ORDER_STATUS_PAID_OR_SHIPPING, paymentStatus: PAYMENT_STATUS_PAID })
if (source.value == 'service' || bizType.value == 'service') {
isDispatchSubmitting.value = true
uni.showLoading({ title: '正在安排服务', mask: true })
try {
const dispatchResult = await dispatchPaidHomecareOrder(orderId.value)
uni.hideLoading()
if (dispatchResult.success) {
uni.redirectTo({
url: '/pages/mall/consumer/home-service/order-detail?id=' + orderId.value
})
return
}
showHomecareDispatchFailureModal(orderId.value, dispatchResult, (id: string) => {
retryHomecareDispatch(id)
})
} catch (e) {
uni.hideLoading()
console.error('[confirmPayment] 派单异常:', e)
uni.showModal({
title: '派单服务异常',
content: '派单服务暂时异常,请稍后重试',
showCancel: true,
cancelText: '稍后再试',
confirmText: '重新派单',
success: (res) => {
if (res.confirm) {
retryHomecareDispatch(orderId.value)
}
}
})
} finally {
isDispatchSubmitting.value = false
}
return
}
setTimeout(() => {
uni.redirectTo({
url: '/pages/mall/consumer/payment-success?orderId=' + orderId.value
})
}, 1500)
} catch (err) {
console.error('[confirmPayment] 支付异常:', err)
try {
uni.hideLoading()
} catch (e) {
}
uni.showToast({
title: '支付失败',
icon: 'none'
})
isPaying.value = false
}
}
const verifyPassword = async () => {
const userId = getCurrentUserId()
if (userId === '') {
password.value = ''
uni.showToast({ title: '请先登录', icon: 'none' })
showPassword.value = false
return
}
try {
await new Promise<void>((resolve: (value: void) => void) => {
setTimeout(() => {
resolve()
}, 500)
})
const isCorrect = true
if (isCorrect) {
confirmPayment()
} else {
password.value = ''
uni.showToast({
title: '密码错误',
icon: 'none'
})
}
} catch (err) {
console.error('验证密码异常:', err)
}
}
const inputPassword = (num: string) => {
if (password.value.length >= 6) return
password.value += num
}
const deletePassword = () => {
if (password.value.length > 0) {
password.value = password.value.slice(0, -1)
}
}
watch(password, (newPassword: string) => {
if (newPassword.length === 6) {
verifyPassword()
}
})
const forgotPassword = () => {
uni.navigateTo({
url: '/pages/user/forgot-password'
})
}
const retryHomecareDispatch = async (targetOrderId: string) => {
if (isDispatchSubmitting.value) {
return
}
isDispatchSubmitting.value = true
uni.showLoading({ title: '正在重新派单', mask: true })
try {
const result = await dispatchPaidHomecareOrder(targetOrderId)
uni.hideLoading()
if (result.success) {
uni.redirectTo({
url: '/pages/mall/consumer/home-service/order-detail?id=' + targetOrderId
})
return
}
showHomecareDispatchFailureModal(targetOrderId, result, (id: string) => {
retryHomecareDispatch(id)
})
} catch (e) {
uni.hideLoading()
console.error('[retryHomecareDispatch] 重新派单异常:', e)
uni.showModal({
title: '派单服务异常',
content: '派单服务暂时异常,请稍后重试',
showCancel: true,
cancelText: '稍后再试',
confirmText: '重新派单',
success: (res) => {
if (res.confirm) {
retryHomecareDispatch(targetOrderId)
}
}
})
} finally {
isDispatchSubmitting.value = false
}
}
const goToOrderList = () => {
uni.redirectTo({
url: '/pages/mall/consumer/orders'
})
}
const goToShoppingHome = () => {
uni.switchTab({
url: '/pages/main/index'
})
}
onLoad((options) => {
if (!ensureLoggedIn()) {
return
}
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
navHeight.value = 44
capsuleRightReserve.value = 12
// #ifdef MP-WEIXIN
try {
const menuButton = uni.getMenuButtonBoundingClientRect()
navHeight.value = menuButton.height + 8
capsuleRightReserve.value = (systemInfo.windowWidth ?? 375) - menuButton.left + 8
} catch (e) {
navHeight.value = 44
capsuleRightReserve.value = 96
}
// #endif
loadPaymentMethods()
loadUserBalance()
if (options == null) {
enterPreviewMode()
return
}
const optionsObj = options as UTSJSONObject
const orderIdValue = getOptionString(optionsObj, 'orderId')
if (orderIdValue != '') {
orderId.value = orderIdValue
}
const sourceValue = getOptionString(optionsObj, 'source')
if (sourceValue != '') {
source.value = sourceValue
}
const bizTypeValue = getOptionString(optionsObj, 'bizType')
if (bizTypeValue != '') {
bizType.value = bizTypeValue
}
if (source.value == '') {
source.value = orderId.value.startsWith('so-') ? 'service' : 'goods'
}
if (bizType.value == '') {
bizType.value = source.value
}
const orderNoValue = getOptionString(optionsObj, 'orderNo')
if (orderNoValue != '') {
orderNo.value = orderNoValue
}
const amountValue = getOptionString(optionsObj, 'amount')
if (amountValue != '') {
amount.value = parseNumberValue(amountValue)
}
const productAmountValue = getOptionString(optionsObj, 'productAmount')
if (productAmountValue != '') {
productAmount.value = parseNumberValue(productAmountValue)
}
const deliveryFeeValue = getOptionString(optionsObj, 'deliveryFee')
if (deliveryFeeValue != '') {
deliveryFee.value = parseNumberValue(deliveryFeeValue)
}
const redPacketValue = getOptionString(optionsObj, 'redPacketDiscount')
if (redPacketValue != '') {
redPacketDiscount.value = parseNumberValue(redPacketValue)
}
const memberDiscountValue = getOptionString(optionsObj, 'memberDiscount')
if (memberDiscountValue != '') {
memberDiscount.value = parseNumberValue(memberDiscountValue)
}
const discountAmountValue = getOptionString(optionsObj, 'discountAmount')
if (discountAmountValue != '' && redPacketDiscount.value <= 0 && memberDiscount.value <= 0) {
redPacketDiscount.value = parseNumberValue(discountAmountValue)
}
const createdAtValue = getOptionString(optionsObj, 'createdAt')
if (createdAtValue != '') {
orderCreatedAt.value = createdAtValue
}
if (orderId.value != '') {
loadOrderInfo()
} else {
enterPreviewMode()
}
})
onShow(() => {
if (orderId.value !== '') {
loadOrderInfo()
return
}
if (!isPreviewMode.value) {
return
}
updateCountdownByDeadline()
if (!isPaymentExpired.value && paymentDeadlineMs.value > 0 && countdownTimer <= 0 && remainingSeconds.value > 0) {
startCountdown(paymentDeadlineMs.value)
}
})
onHide(() => {
stopCountdown()
})
onReachBottom(() => {
guessLoadMoreKey.value = guessLoadMoreKey.value + 1
})
onBackPress((options) => {
if (options.from === 'navigateBack') {
return false
}
if (showCancelSheet.value) {
closeCancelSheet()
return true
}
if (showPassword.value) {
closePasswordPopup()
return true
}
if (shouldShowLeaveConfirm()) {
showLeaveConfirm()
return true
}
return false
})
onUnmounted(() => {
stopCountdown()
})
onUnload(() => {
stopCountdown()
})
</script>
<style scoped>
.payment-page {
width: 100%;
min-height: 100vh;
background-color: #f5f5f5;
padding-bottom: calc(96px + env(safe-area-inset-bottom));
}
.jd-pay-header {
background: linear-gradient(180deg, #ffffff 0%, #f7f7f7 100%);
padding-left: 12px;
padding-right: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.jd-nav-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
position: relative;
}
.jd-left-capsule {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 32px;
padding: 0 10px;
border-radius: 16px;
background-color: #ffffff;
border: 1px solid #eaeaea;
display: flex;
flex-direction: row;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.jd-back-icon,
.jd-menu-icon {
font-size: 18px;
line-height: 18px;
color: #222222;
font-weight: 600;
padding: 0 4px;
}
.jd-nav-divider {
width: 1px;
height: 16px;
background-color: #ebebeb;
margin: 0 6px;
}
.jd-title-center {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.jd-title-clock {
font-size: 18px;
color: #222222;
margin-right: 6px;
}
.jd-title-text {
font-size: 20px;
font-weight: 700;
color: #222222;
}
.jd-countdown-row {
padding-top: 12px;
padding-bottom: 6px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.jd-countdown-prefix,
.jd-countdown-suffix {
font-size: 14px;
color: #666666;
}
.jd-countdown-number {
font-size: 22px;
font-weight: 700;
color: #ff4d4f;
}
.jd-expired-text {
font-size: 14px;
color: #999999;
}
.jd-pay-notice {
margin: 10px 12px 0;
padding: 10px 12px;
background-color: #fff6d8;
border-radius: 10px;
border: 1px solid #f4d98a;
}
.jd-pay-notice-text {
font-size: 12px;
line-height: 18px;
color: #8a6700;
}
.payment-content {
display: flex;
flex-direction: column;
padding: 12px;
padding-top: 12px;
}
.card {
background-color: #ffffff;
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
}
.missing-card {
align-items: flex-start;
}
.missing-title {
font-size: 18px;
font-weight: bold;
color: #222222;
margin-top: 4px;
}
.missing-desc {
font-size: 13px;
line-height: 20px;
color: #666666;
margin-top: 8px;
margin-bottom: 18px;
}
.missing-btn {
height: 40px;
line-height: 40px;
border-radius: 20px;
padding: 0 20px;
background-color: #ff4d4f;
color: #ffffff;
font-size: 14px;
border-width: 0;
margin: 0;
}
.card-header-row,
.shop-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title,
.shop-name {
font-size: 15px;
font-weight: bold;
color: #222222;
}
.card-action {
font-size: 13px;
color: #ff4d4f;
}
.address-main-row {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.address-marker {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #ff4d4f;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
margin-top: 2px;
}
.address-marker-text {
font-size: 12px;
color: #ffffff;
font-weight: bold;
}
.address-content {
flex: 1;
display: flex;
flex-direction: column;
}
.address-user-row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 6px;
}
.address-name {
font-size: 15px;
font-weight: bold;
color: #222222;
margin-right: 10px;
}
.address-phone {
font-size: 13px;
color: #666666;
}
.address-detail {
font-size: 13px;
line-height: 20px;
color: #333333;
}
.goods-item {
display: flex;
flex-direction: row;
padding-top: 12px;
padding-bottom: 12px;
border-top: 1px solid #f3f3f3;
}
.goods-item:first-of-type {
border-top-width: 0;
padding-top: 0;
}
.goods-image {
width: 84px;
height: 84px;
border-radius: 10px;
background-color: #f2f2f2;
margin-right: 12px;
flex-shrink: 0;
}
.goods-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.goods-name {
font-size: 14px;
line-height: 20px;
color: #222222;
max-height: 40px;
}
.goods-spec {
font-size: 12px;
color: #999999;
margin-top: 6px;
}
.goods-meta-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 12px;
}
.goods-price,
.total-value {
font-size: 16px;
font-weight: bold;
color: #ff4d4f;
}
.goods-count {
font-size: 13px;
color: #999999;
}
.fee-row,
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.fee-row-total {
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid #f3f3f3;
}
.fee-label,
.info-label {
font-size: 13px;
color: #666666;
}
.fee-value,
.info-value {
font-size: 13px;
color: #222222;
}
.fee-discount {
color: #ff4d4f;
}
.total-label {
font-weight: bold;
color: #222222;
}
.info-value-action {
display: flex;
flex-direction: row;
align-items: center;
}
.info-copy {
font-size: 12px;
color: #ff4d4f;
margin-left: 10px;
}
.method-section {
margin-top: 10px;
padding-top: 12px;
border-top: 1px solid #f3f3f3;
}
.method-section-title {
font-size: 14px;
font-weight: bold;
color: #222222;
display: block;
margin-bottom: 8px;
}
.method-list {
display: flex;
flex-direction: column;
}
.method-item-modern {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.method-item-modern:last-child {
border-bottom-width: 0;
}
.method-left {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
}
.method-img {
width: 28px;
height: 28px;
margin-right: 12px;
border-radius: 14px;
background-color: #f5f5f5;
}
.method-info {
display: flex;
flex-direction: column;
flex: 1;
}
.method-name {
font-size: 14px;
color: #222222;
}
.method-desc {
font-size: 12px;
color: #999999;
margin-top: 4px;
}
.method-right {
margin-left: 12px;
}
.radio-circle {
width: 20px;
height: 20px;
border-radius: 10px;
border: 1px solid #dddddd;
display: flex;
align-items: center;
justify-content: center;
background-color: #ffffff;
}
.radio-circle.checked {
border-color: #ff4d4f;
background-color: #ff4d4f;
}
.radio-inner {
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #ffffff;
}
.bottom-safe-space {
height: 12px;
}
.payment-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
padding: 12px;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.payment-expired-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
padding: 12px;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.payment-expired-btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 22px;
font-size: 15px;
font-weight: bold;
border-width: 0;
margin: 0;
}
.payment-expired-btn.secondary {
margin-right: 10px;
background-color: #f0f0f0;
color: #666666;
}
.payment-expired-btn.primary {
background-color: #ff4d4f;
color: #ffffff;
}
.cancel-btn,
.pay-btn,
.sheet-btn {
height: 44px;
line-height: 44px;
border-radius: 22px;
font-size: 15px;
font-weight: bold;
border-width: 0;
margin: 0;
}
.cancel-btn {
width: 112px;
background-color: #f0f0f0;
color: #666666;
flex-shrink: 0;
margin-right: 10px;
}
.pay-btn {
flex: 1;
background-color: #ff4d4f;
color: #ffffff;
}
.pay-btn.disabled {
background-color: #ffb3b4;
opacity: 0.9;
}
.pay-text {
font-size: 15px;
font-weight: bold;
color: #ffffff;
}
.sheet-mask,
.password-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.cancel-sheet,
.password-popup-content {
background-color: #ffffff;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
width: 100%;
}
.cancel-sheet {
height: 75%;
padding: 18px 16px 12px;
display: flex;
flex-direction: column;
}
.sheet-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 14px;
}
.sheet-title {
font-size: 18px;
font-weight: bold;
color: #222222;
margin-bottom: 6px;
}
.sheet-subtitle {
font-size: 12px;
color: #999999;
}
.sheet-scroll {
flex: 1;
min-height: 0;
}
.reason-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px 4px;
border-bottom: 1px solid #f4f4f4;
}
.reason-text {
font-size: 15px;
color: #222222;
}
.sheet-radio {
flex-shrink: 0;
}
.other-reason-box {
padding-top: 12px;
padding-bottom: 4px;
}
.other-reason-input {
height: 42px;
background-color: #f7f7f7;
border-radius: 10px;
padding-left: 12px;
padding-right: 12px;
font-size: 14px;
color: #333333;
}
.sheet-actions {
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: 12px;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.sheet-btn {
flex: 1;
}
.sheet-btn-secondary {
background-color: #f0f0f0;
color: #666666;
margin-right: 10px;
}
.sheet-btn-primary {
background-color: #ff4d4f;
color: #ffffff;
}
.password-popup-content {
padding: 20px 0 0;
display: flex;
flex-direction: column;
align-items: center;
}
.popup-header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 0 20px;
}
.popup-close {
font-size: 20px;
color: #999999;
padding: 4px;
}
.popup-title {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.popup-placeholder {
width: 28px;
}
.popup-amount-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30px;
}
.popup-amount-label {
font-size: 14px;
color: #666666;
margin-bottom: 8px;
}
.popup-amount-row {
display: flex;
flex-direction: row;
align-items: baseline;
}
.popup-currency {
font-size: 18px;
font-weight: bold;
color: #333333;
margin-right: 2px;
}
.popup-value {
font-size: 32px;
font-weight: bold;
color: #ff4d4f;
}
.password-input-row {
display: flex;
flex-direction: row;
justify-content: center;
margin-bottom: 20px;
}
.password-box {
width: 45px;
height: 45px;
border: 1px solid #dddddd;
border-right-width: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
}
.password-box:first-child {
border-radius: 4px 0 0 4px;
}
.password-box:last-child {
border-right-width: 1px;
border-radius: 0 4px 4px 0;
}
.password-dot {
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #000000;
}
.forgot-password-link {
font-size: 13px;
color: #576b95;
margin-bottom: 20px;
}
.password-keyboard-popup {
width: 100%;
background-color: #f5f5f5;
padding: 6px;
padding-bottom: env(safe-area-inset-bottom);
}
.keyboard-grid {
display: flex;
flex-wrap: wrap;
background-color: #f5f5f5;
}
.keyboard-key {
width: 33.33%;
background-color: #ffffff;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #f5f5f5;
box-sizing: border-box;
}
.keyboard-grid-popup {
background-color: #f5f5f5;
}
.keyboard-key-popup {
height: 54px;
border: 3px solid #f5f5f5;
border-radius: 8px;
}
.keyboard-key-empty {
background-color: transparent;
}
.keyboard-key:active {
background-color: #e0e0e0;
}
.key-text {
font-size: 24px;
color: #333333;
}
.key-text-popup {
font-size: 22px;
font-weight: 500;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>