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

935 lines
22 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">
<scroll-view class="payment-content" direction="vertical">
<!-- 价格明细 -->
<view class="price-detail-section">
<text class="section-title">价格明细</text>
<view class="price-detail">
<view class="price-row">
<text class="price-label">商品总价</text>
<text class="price-value">¥{{ productAmount.toFixed(2) }}</text>
</view>
<view class="price-row">
<text class="price-label">运费</text>
<text class="price-value">+¥{{ deliveryFee.toFixed(2) }}</text>
</view>
<view v-if="discountAmount > 0" class="price-row">
<text class="price-label">优惠减免</text>
<text class="price-value discount">-¥{{ discountAmount.toFixed(2) }}</text>
</view>
<view class="price-row total">
<text class="price-label">应付金额</text>
<text class="price-value total-price">¥{{ amount.toFixed(2) }}</text>
</view>
</view>
<text class="order-no">订单号: {{ orderNo }}</text>
</view>
<!-- 支付方式 -->
<view class="methods-section">
<text class="section-title">选择支付方式</text>
<view class="method-list">
<view v-for="method in paymentMethods"
:key="method.id"
:class="['method-item', { selected: selectedMethod === method.id }]"
@click="selectMethod(method)">
<view class="method-left">
<text class="method-icon">{{ getMethodIcon(method.id) }}</text>
<view class="method-info">
<text class="method-name">{{ method.name }}</text>
<text class="method-desc">{{ method.description }}</text>
</view>
</view>
<view v-if="selectedMethod === method.id" class="method-selected">
<text class="selected-icon">✓</text>
</view>
</view>
</view>
</view>
<!-- 余额支付 -->
<view v-if="selectedMethod === 'balance' && userBalance > 0" class="balance-section">
<view class="balance-info">
<text class="balance-label">账户余额</text>
<text class="balance-value">¥{{ userBalance.toFixed(2) }}</text>
</view>
<view v-if="userBalance < amount" class="balance-tip">
<text class="tip-text">余额不足,请选择其他支付方式</text>
</view>
</view>
<!-- 密码输入 -->
<view v-if="showPassword" class="password-section">
<text class="password-title">请输入支付密码</text>
<view class="password-input">
<view v-for="(_, index) in 6"
:key="index"
class="password-dot">
<text v-if="password.length > index" class="password-dot-text">●</text>
</view>
</view>
<text class="forgot-password" @click="forgotPassword">忘记密码?</text>
</view>
</scroll-view>
<!-- 底部支付按钮 -->
<view class="payment-bottom">
<view class="price-summary">
<text class="summary-label">需支付:</text>
<text class="summary-price">¥{{ amount.toFixed(2) }}</text>
</view>
<button class="pay-btn"
:class="{ disabled: isPaying || (selectedMethod === 'balance' && userBalance < amount) }"
@click="confirmPayment">
<text v-if="!isPaying" class="pay-text">{{ getPayButtonText() }}</text>
<text v-else class="pay-text">支付中...</text>
</button>
</view>
<!-- 密码键盘 -->
<view v-if="showPassword" class="password-keyboard">
<view class="keyboard-grid">
<view v-for="num in 9"
:key="num"
class="keyboard-key"
@click="inputPassword(num.toString())">
<text class="key-text">{{ num }}</text>
</view>
<view class="keyboard-key"></view>
<view class="keyboard-key" @click="inputPassword('0')">
<text class="key-text">0</text>
</view>
<view class="keyboard-key" @click="deletePassword">
<text class="key-text">⌫</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch, computed, onUnmounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type PaymentMethodType = {
id: string
name: string
description: string
icon: string
enabled: boolean
}
const orderId = ref<string>('')
const orderNo = ref<string>('')
const amount = ref<number>(0)
const paymentMethods = ref<Array<PaymentMethodType>>([])
const selectedMethod = ref<string>('wechat')
const userBalance = ref<number>(0)
const isPaying = ref<boolean>(false)
const showPassword = ref<boolean>(false)
const password = ref<string>('')
// 价格相关变量
const productAmount = ref<number>(0) // 商品总价
const deliveryFee = ref<number>(0) // 运费
const discountAmount = ref<number>(0) // 优惠减免
// 加载支付方式(必须在 onMounted 之前定义)
const loadPaymentMethods = () => {
const methods: PaymentMethodType[] = [
{
id: 'wechat',
name: '微信支付',
description: '推荐安装微信5.0及以上版本使用',
icon: '💳',
enabled: true
},
{
id: 'alipay',
name: '支付宝',
description: '推荐安装支付宝10.0及以上版本使用',
icon: '💳',
enabled: true
},
{
id: 'balance',
name: '余额支付',
description: '使用账户余额支付',
icon: '💰',
enabled: true
},
{
id: 'bankcard',
name: '银行卡支付',
description: '支持储蓄卡、信用卡',
icon: '💳',
enabled: true
}
]
paymentMethods.value = methods
}
// 加载用户余额(必须在 onMounted 之前定义)
const loadUserBalance = async () => {
try {
const balance = await supabaseService.getUserBalance()
userBalance.value = balance
} catch (err) {
console.error('加载用户余额异常:', err)
userBalance.value = 0
}
}
// 计算价格明细(必须在 onMounted 之前定义)
const calculatePriceDetails = (totalAmount: number) => {
// 模拟计算各项费用
// 假设商品总价占总金额的80%运费占10%优惠减免占10%
productAmount.value = totalAmount * 0.8
deliveryFee.value = totalAmount * 0.1
discountAmount.value = totalAmount * 0.1
// 确保总和等于应付金额
const calculatedTotal = productAmount.value + deliveryFee.value - discountAmount.value
if (Math.abs(calculatedTotal - totalAmount) > 0.01) {
// 调整商品总价以匹配应付金额
productAmount.value = totalAmount + discountAmount.value - deliveryFee.value
}
}
// 更新本地存储中的订单状态(必须在 onMounted 之前定义)
const updateOrderInStorage = (targetOrderId: string, status: number) => {
try {
// 尝试从 'orders' 读取 (checkout页面写入的key)
const ordersStr = uni.getStorageSync('orders')
let orders: Record<string, any>[] = []
if (ordersStr != null && ordersStr !== '') {
const parsed = JSON.parse(ordersStr as string)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
// 使用 JSON 序列化转换
const itemStr = JSON.stringify(parsed[i])
const itemParsed = JSON.parse(itemStr)
if (itemParsed != null) {
orders.push(itemParsed as Record<string, any>)
}
}
}
}
let foundIndex = -1
for (let i = 0; i < orders.length; i++) {
const o = orders[i]
if (o['id'] === targetOrderId) {
foundIndex = i
break
}
}
if (foundIndex !== -1) {
orders[foundIndex]['status'] = status
orders[foundIndex]['payment_status'] = status === 2 ? 1 : 0 // 2=待发货(已支付), 1=待支付(未支付)
orders[foundIndex]['updated_at'] = new Date().toISOString()
// 确保更新的是 'orders' key
uni.setStorageSync('orders', JSON.stringify(orders))
console.log('订单状态已更新到Storage (orders):', targetOrderId, status)
} else {
// 本地缓存中没有订单数据是正常的,数据在数据库中
console.log('本地缓存中无订单数据,已忽略:', targetOrderId)
}
} catch (e) {
console.error('更新订单状态失败', e)
}
}
// 取消支付,更新订单状态(必须在 goBack 之前定义)
const cancelPayment = async () => {
try {
// 这里应该调用API更新订单状态为待支付status: 1
// 模拟更新订单状态
// 更新本地存储
updateOrderInStorage(orderId.value, 1) // 1: 待支付
// 发布订单更新事件让profile页面可以刷新数据
uni.$emit('orderUpdated', { orderId: orderId.value, status: 1 })
uni.showToast({
title: '已保存到待支付订单',
icon: 'success'
})
// 延迟返回,让用户看到提示
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('取消支付异常:', err)
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
}
// 返回(必须在 onBackPress 之前定义)
const goBack = () => {
uni.showModal({
title: '取消支付',
content: '确定要取消支付吗?取消后订单将保存到待支付订单中',
confirmText: '取消支付',
cancelText: '继续支付',
success: (res) => {
if (res.confirm) {
// 用户确认取消支付,更新订单状态为待支付
cancelPayment()
}
}
})
}
// 加载订单信息(必须在 onMounted 之前定义)
const loadOrderInfo = async () => {
try {
if (orderId.value == '') return
const order = await supabaseService.getOrderDetail(orderId.value)
if (order != null) {
// 使用 JSON 序列化转换对象
const orderStr = JSON.stringify(order)
const orderParsed = JSON.parse(orderStr)
if (orderParsed == null) {
console.error('订单数据解析失败')
return
}
const orderObj = orderParsed as UTSJSONObject
const orderNoVal = orderObj.getString('order_no')
if (orderNoVal != null) {
orderNo.value = orderNoVal
}
const totalAmount = orderObj.getNumber('total_amount')
const dbAmount = totalAmount ?? 0
if (dbAmount > 0) {
amount.value = dbAmount
}
const items = orderObj.get('items')
if (items != null && Array.isArray(items) && items.length > 0) {
// Could update product name etc if displayed
}
} else {
// Fallback or error
console.warn('Order not found in DB', orderId.value)
if (orderNo.value == '') orderNo.value = 'ORD_PENDING_' + Date.now()
}
} catch (err) {
console.error('加载订单信息异常:', err)
}
}
// 生命周期
onLoad((options) => {
if (options != null) {
const orderIdValue = options['orderId']
if (orderIdValue != null) {
orderId.value = orderIdValue as string
loadOrderInfo()
}
const amountValue = options['amount']
if (amountValue != null) {
amount.value = parseFloat(amountValue.toString())
}
// 获取传递的价格详情
const productAmountValue = options['productAmount']
if (productAmountValue != null) {
productAmount.value = parseFloat(productAmountValue.toString())
}
const deliveryFeeValue = options['deliveryFee']
if (deliveryFeeValue != null) {
deliveryFee.value = parseFloat(deliveryFeeValue.toString())
}
const discountAmountValue = options['discountAmount']
if (discountAmountValue != null) {
discountAmount.value = parseFloat(discountAmountValue.toString())
}
// 如果没有传详情,尝试根据总价估算(兼容旧逻辑,但优先使用传参)
if (productAmountValue == null && amount.value > 0) {
calculatePriceDetails(amount.value)
}
loadPaymentMethods()
loadUserBalance()
}
})
onMounted(() => {
// onMounted 中的逻辑已移到 onLoad 中
})
// 监听返回操作(包含系统返回键和导航栏返回按钮)
onBackPress((options) => {
// 如果是通过代码主动调用 navigateBack 返回,则允许
if (options.from === 'navigateBack') {
return false
}
// 否则拦截返回,显示确认弹窗
goBack()
return true
})
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore != null) {
// 使用 JSON 序列化转换
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 getMethodIcon = (methodId: string): string => {
if (methodId === 'wechat') {
return '💳'
} else if (methodId === 'alipay') {
return '💳'
} else if (methodId === 'balance') {
return '💰'
} else if (methodId === 'bankcard') {
return '💳'
}
return '💳'
}
// 选择支付方式
const selectMethod = (method: PaymentMethodType) => {
if (!method.enabled) {
uni.showToast({
title: '该支付方式暂不可用',
icon: 'none'
})
return
}
selectedMethod.value = method.id
showPassword.value = method.id === 'balance' || method.id === 'bankcard'
password.value = '' // 清空密码
}
// 获取支付按钮文本
const getPayButtonText = (): string => {
if (selectedMethod.value === 'balance' && userBalance.value < amount.value) {
return '余额不足'
}
if (selectedMethod.value === 'wechat') {
return '微信支付'
} else if (selectedMethod.value === 'alipay') {
return '支付宝支付'
} else if (selectedMethod.value === 'balance') {
return '余额支付'
} else if (selectedMethod.value === 'bankcard') {
return '银行卡支付'
}
return '确认支付'
}
// 减少商品库存
// const reduceStock = (orderId: string) => {
// Update should happen on server side during payment processing
// }
// 确认支付
const confirmPayment = async () => {
if (isPaying.value) return
// 余额支付检查
if (selectedMethod.value === 'balance') {
if (userBalance.value < amount.value) {
uni.showToast({
title: '余额不足',
icon: 'none'
})
return
}
if (!showPassword.value) {
showPassword.value = true
return
}
if (password.value.length !== 6) {
uni.showToast({
title: '请输入6位支付密码',
icon: 'none'
})
return
}
}
isPaying.value = true
uni.showLoading({ title: '支付中...' })
try {
console.log('[confirmPayment] 开始支付, orderId:', orderId.value, 'method:', selectedMethod.value)
const success = await supabaseService.payOrder(orderId.value, selectedMethod.value, amount.value)
console.log('[confirmPayment] 支付结果:', success)
if (!success) {
console.error('[confirmPayment] payOrder 返回 false')
uni.hideLoading()
uni.showToast({
title: '支付处理失败',
icon: 'none'
})
isPaying.value = false
return
}
uni.hideLoading()
updateOrderInStorage(orderId.value, 2)
uni.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
uni.$emit('orderUpdated', { orderId: orderId.value, status: 2 })
setTimeout(() => {
uni.redirectTo({
url: `/pages/mall/consumer/payment-success?orderId=${orderId.value}`
})
}, 1500)
} catch (err) {
console.error('[confirmPayment] 支付异常:', err)
uni.hideLoading()
uni.showToast({
title: '支付失败',
icon: 'none'
})
isPaying.value = false
}
}
// 获取支付方式代码
const getPaymentMethodCode = (methodId: string): number => {
if (methodId === 'wechat') {
return 1
} else if (methodId === 'alipay') {
return 2
} else if (methodId === 'balance') {
return 3
} else if (methodId === 'bankcard') {
return 4
}
return 0
}
// 验证密码(必须在 watch 之前定义)
const verifyPassword = async () => {
// 这里应该验证支付密码,这里简单模拟
const userId = getCurrentUserId()
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'
})
}
// 在组件卸载时移除返回键监听
onUnmounted(() => {
// uni.offBackPress() 在uni-app中不需要手动移除
})
</script>
<style scoped>
.payment-page {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #f5f5f5;
overflow: hidden;
}
.payment-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.payment-content {
flex: 1;
/* overflow-y: auto; */
}
/* 价格明细部分 */
.price-detail-section {
background-color: #ffffff;
padding: 20px 15px;
margin-bottom: 10px;
}
.price-detail {
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 15px;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.price-row.total {
border-top: 1px solid #e5e5e5;
margin-top: 8px;
padding-top: 15px;
}
.price-label {
font-size: 14px;
color: #666666;
}
.price-value {
font-size: 14px;
color: #333333;
}
.price-value.discount {
color: #4caf50;
}
.price-value.total-price {
font-size: 18px;
color: #ff4757;
font-weight: bold;
}
.order-no {
/* display: block; */
font-size: 12px;
color: #999999;
text-align: center;
}
.methods-section {
background-color: #ffffff;
padding: 20px 15px;
margin-bottom: 10px;
}
.section-title {
/* display: block; */
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.method-list {
display: flex;
flex-direction: column;
/* gap: 10px; */
}
.method-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
border: 1px solid #e5e5e5;
border-radius: 8px;
margin-bottom: 10px;
}
.method-item.selected {
border-color: #007aff;
background-color: #f0f8ff;
}
.method-left {
display: flex;
align-items: center;
}
.method-icon {
font-size: 24px;
margin-right: 15px;
}
.method-info {
display: flex;
flex-direction: column;
}
.method-name {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 5px;
}
.method-desc {
font-size: 12px;
color: #999999;
}
.method-selected {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #007aff;
display: flex;
align-items: center;
justify-content: center;
}
.selected-icon {
color: #ffffff;
font-size: 14px;
}
.balance-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.balance-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.balance-label {
font-size: 14px;
color: #333333;
}
.balance-value {
font-size: 18px;
color: #ff4757;
font-weight: bold;
}
.balance-tip {
padding: 10px;
background-color: #fff0f0;
border-radius: 5px;
}
.tip-text {
font-size: 12px;
color: #ff4757;
}
.password-section {
background-color: #ffffff;
padding: 30px 15px;
text-align: center;
margin-bottom: 10px;
}
.password-title {
/* display: block; */
font-size: 16px;
color: #333333;
margin-bottom: 30px;
}
.password-input {
display: flex;
justify-content: center;
/* gap: 15px; */
margin-bottom: 20px;
}
.password-dot {
width: 12px;
height: 12px;
border-radius: 6px;
background-color: #333333;
display: flex;
align-items: center;
justify-content: center;
margin: 0 7.5px;
}
.password-dot-text {
color: #ffffff;
font-size: 8px;
}
.forgot-password {
color: #007aff;
font-size: 14px;
}
.payment-bottom {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.price-summary {
display: flex;
align-items: flex-end;
}
.summary-label {
font-size: 14px;
color: #333333;
margin-right: 5px;
}
.summary-price {
font-size: 20px;
color: #ff4757;
font-weight: bold;
}
.pay-btn {
background-color: #007aff;
color: #ffffff;
padding: 0 40px;
height: 45px;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
border: none;
}
.pay-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
.password-keyboard {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 10px;
}
.keyboard-grid {
display: flex;
flex-wrap: wrap;
/* grid-template-columns: repeat(3, 1fr); uvue unsupported */
/* grid-gap: 1px; uvue unsupported */
background-color: #e5e5e5;
}
.keyboard-key {
width: 33.33%;
background-color: #ffffff;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #f5f5f5; /* mimic grid gap */
box-sizing: border-box;
}
.key-text {
font-size: 24px;
color: #333333;
}
</style>