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

1375 lines
48 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="checkout-page">
<scroll-view class="checkout-content" scroll-y>
<!-- 收货地址 -->
<view class="address-section" @click="selectAddress">
<view v-if="selectedAddress" class="address-info">
<view class="address-header">
<text class="recipient">{{ selectedAddress.recipient_name }}</text>
<text class="phone">{{ selectedAddress.phone }}</text>
<view v-if="selectedAddress.is_default" class="default-tag">
<text class="tag-text">默认</text>
</view>
</view>
<text class="address-detail">{{ getFullAddress(selectedAddress) }}</text>
</view>
<view v-else class="no-address">
<text class="no-address-text">请选择收货地址</text>
<text class="no-address-arrow"></text>
</view>
</view>
<!-- 商品列表 (按店铺分组) -->
<view class="products-section">
<!-- 调试信息 -->
<view v-if="checkoutItems.length > 0" class="debug-info">
<text class="debug-text">共 {{ checkoutItems.length }} 件商品</text>
</view>
<view v-if="shopGroups.length > 0">
<view v-for="group in shopGroups" :key="group.shopId" class="shop-group">
<view class="shop-header">
<text class="shop-icon">🏪</text>
<text class="shop-name">{{ group.shopName }}</text>
</view>
<view v-for="item in group.items" :key="item.id" class="product-item">
<image class="product-image" :src="item.product_image" mode="aspectFill" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ item.price }}</text>
<text class="product-quantity">×{{ item.quantity }}</text>
</view>
</view>
</view>
<!-- 店铺小计 -->
<view class="shop-subtotal">
<text class="subtotal-label">配送方式</text>
<text class="subtotal-value">快递 免邮</text>
</view>
<view class="shop-subtotal">
<text class="subtotal-text">小计: </text>
<text class="subtotal-price">¥{{ getGroupTotal(group) }}</text>
</view>
</view>
</view>
<view v-else class="no-products">
<text class="no-products-text">暂无商品信息</text>
</view>
</view>
<!-- 配送方式 -->
<view class="delivery-section">
<text class="section-title">配送方式</text>
<view class="delivery-options">
<view v-for="option in deliveryOptions"
:key="option.id"
:class="['delivery-option', { selected: selectedDelivery === option.id }]"
@click="selectDelivery(option)">
<text class="option-name">{{ option.name }}</text>
<text class="option-price">¥{{ option.price }}</text>
<text v-if="selectedDelivery === option.id" class="option-selected">✓</text>
</view>
</view>
</view>
<!-- 优惠券 -->
<view class="coupon-section" @click="selectCoupon">
<text class="section-title">优惠券</text>
<view class="coupon-info">
<text v-if="selectedCoupon" class="coupon-selected">{{ selectedCoupon.template?.name || '已选择优惠券 (¥' + selectedCoupon.template?.discount_value + ')' }}</text>
<text v-else class="coupon-placeholder">选择优惠券</text>
<text class="coupon-arrow"></text>
</view>
</view>
<!-- 买家留言 -->
<view class="remark-section">
<text class="section-title">买家留言</text>
<textarea class="remark-input"
v-model="remark"
placeholder="选填,请先和商家协商一致"
maxlength="100" />
</view>
<!-- 价格明细 -->
<view class="price-section">
<text class="section-title">价格明细</text>
<view class="price-detail">
<view class="price-row">
<text class="price-label">商品总价</text>
<text class="price-value">¥{{ totalAmount.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">¥{{ actualAmount.toFixed(2) }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部结算栏 -->
<view class="bottom-bar">
<view class="price-summary">
<text class="summary-label">合计:</text>
<text class="summary-price">¥{{ actualAmount.toFixed(2) }}</text>
</view>
<button class="submit-btn" @click="submitOrder">提交订单</button>
</view>
<!-- 地址选择弹窗 -->
<view v-if="showAddressPopup" class="address-popup-mask" @click="showAddressPopup = false">
<view class="address-popup" @click.stop>
<view class="popup-header">
<text class="popup-title">选择收货地址</text>
<text class="popup-close" @click="showAddressPopup = false">×</text>
</view>
<scroll-view class="address-list-container" scroll-y>
<!-- 登录提示 -->
<view v-if="!isLoggedIn" class="login-prompt" @click="goToLogin">
<text class="login-prompt-icon">🔒</text>
<text class="login-prompt-text">您尚未登录,点击登录以同步服务器地址</text>
<text class="login-prompt-arrow"></text>
</view>
<!-- 地址列表 -->
<view v-if="isLoggedIn">
<view v-if="addressList.length > 0">
<view v-for="address in addressList" :key="address.id"
class="popup-address-item" @click="handleSelectAddress(address)">
<view class="popup-address-header">
<text class="popup-address-name">{{ address.recipient_name }}</text>
<text class="popup-address-phone">{{ address.phone }}</text>
<view v-if="address.is_default" class="popup-default-tag">
<text class="popup-tag-text">默认</text>
</view>
</view>
<text class="popup-address-detail">{{ getFullAddress(address) }}</text>
<view v-if="selectedAddress && selectedAddress.id === address.id" class="popup-selected-indicator">
<text>✓</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="popup-empty-address">
<text class="popup-empty-icon">📍</text>
<text class="popup-empty-text">暂无收货地址</text>
</view>
</view>
<!-- 未登录时的本地地址展示 -->
<view v-if="!isLoggedIn && addressList.length > 0">
<text class="local-address-title">本地地址(未同步)</text>
<view v-for="address in addressList" :key="address.id"
class="popup-address-item" @click="handleSelectAddress(address)">
<view class="popup-address-header">
<text class="popup-address-name">{{ address.recipient_name }}</text>
<text class="popup-address-phone">{{ address.phone }}</text>
<view v-if="address.is_default" class="popup-default-tag">
<text class="popup-tag-text">默认</text>
</view>
</view>
<text class="popup-address-detail">{{ getFullAddress(address) }}</text>
<view v-if="selectedAddress && selectedAddress.id === address.id" class="popup-selected-indicator">
<text>✓</text>
</view>
</view>
</view>
<!-- 完全无地址状态 -->
<view v-if="isLoggedIn && addressList.length === 0" class="popup-empty-address">
<text class="popup-empty-icon">📍</text>
<text class="popup-empty-text">暂无收货地址</text>
</view>
</scroll-view>
<!-- 新建地址按钮 -->
<view class="popup-add-address-btn" @click="handleAddNewAddress">
<text class="popup-btn-icon">+</text>
<text class="popup-btn-text">新建收货地址</text>
</view>
</view>
</view>
<!-- 新建地址表单弹窗 -->
<view v-if="showNewAddressForm" class="address-form-mask" @click="cancelNewAddress">
<view class="address-form-popup" @click.stop>
<view class="form-header">
<text class="form-title">新建收货地址</text>
<text class="form-close" @click="cancelNewAddress">×</text>
</view>
<scroll-view class="form-content" scroll-y>
<view class="form-item">
<text class="form-label">收货人</text>
<input class="form-input" v-model="newAddress.recipient_name"
placeholder="请输入收货人姓名" />
</view>
<view class="form-item">
<text class="form-label">手机号</text>
<input class="form-input" v-model="newAddress.phone"
placeholder="请输入手机号码" type="number" />
</view>
<view class="form-item">
<text class="form-label">智能填写地址</text>
<textarea class="form-textarea smart-address-input"
v-model="smartAddressInput"
placeholder="粘贴如北京市朝阳区三里屯SOHO A座 张三 13800138000"
@input="parseSmartAddress"
maxlength="200" />
<text class="smart-tip">自动识别:地址+姓名+电话(支持粘贴文本)</text>
</view>
<view class="form-item">
<text class="form-label">所在地区</text>
<view class="region-inputs">
<input class="form-input region-input" v-model="newAddress.province"
placeholder="省" readonly />
<input class="form-input region-input" v-model="newAddress.city"
placeholder="市" readonly />
<input class="form-input region-input" v-model="newAddress.district"
placeholder="区/县" readonly />
</view>
</view>
<view class="form-item">
<text class="form-label">详细地址</text>
<textarea class="form-textarea" v-model="newAddress.detail"
placeholder="街道、小区、楼栋、门牌号等"
maxlength="100" />
</view>
<view class="form-item checkbox-item">
<view class="checkbox-wrapper" @click="newAddress.is_default = !newAddress.is_default">
<view :class="['checkbox', { checked: newAddress.is_default }]">
<text v-if="newAddress.is_default" class="checkbox-check">✓</text>
</view>
<text class="checkbox-label">设为默认地址</text>
</view>
</view>
</scroll-view>
<view class="form-buttons">
<button class="form-cancel-btn" @click="cancelNewAddress">取消</button>
<button class="form-submit-btn" @click="saveNewAddress">保存</button>
</view>
</view>
</view>
<!-- 确认保存弹窗 -->
<view v-if="showSaveConfirm" class="confirm-popup-mask">
<view class="confirm-popup">
<view class="confirm-header">
<text class="confirm-title">保存地址</text>
</view>
<view class="confirm-content">
<text class="confirm-message">是否保存该地址用于下次使用?</text>
</view>
<view class="confirm-buttons">
<button class="confirm-btn cancel" @click="handleSaveConfirm(false)">仅本次</button>
<button class="confirm-btn confirm" @click="handleSaveConfirm(true)">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed, watch, onUnmounted, getCurrentInstance } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService, type UserAddress as SupabaseUserAddress } from '@/utils/supabaseService.uts'
type CheckoutItemType = {
id: string
product_id: string
sku_id: string
product_name: string
product_image: string
sku_specifications: any
price: number
quantity: number
shop_id?: string
shop_name?: string
merchant_id?: string
}
type DeliveryOptionType = {
id: string
name: string
price: number
description: string
}
type UserCouponType = {
id: string
template: {
name: string
discount_value: number
min_order_amount: number
} | null
}
const checkoutItems = ref<Array<CheckoutItemType>>([])
const selectedAddress = ref<any>(null)
const deliveryOptions = ref<Array<DeliveryOptionType>>([
{ id: 'standard', name: '快递配送', price: 8.00, description: '1-3天送达' },
{ id: 'express', name: '加急配送', price: 15.00, description: '当天送达' }
])
const selectedDelivery = ref<string>('standard')
const selectedCoupon = ref<UserCouponType | null>(null)
const remark = ref<string>('')
const showAddressPopup = ref<boolean>(false)
const addressList = ref<Array<any>>([])
const newAddress = ref<any>({
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
})
const showNewAddressForm = ref<boolean>(false)
const showSaveConfirm = ref<boolean>(false)
const smartAddressInput = ref<string>('')
// 计算属性 - 修复价格同步问题
// 按店铺分组商品
const shopGroups = computed(() => {
const groups = new Map<string, any>()
checkoutItems.value.forEach(item => {
// 使用类型断言访问可能的额外属性
const rawItem = item as any
const shopId = rawItem.shop_id || 'unknown'
if (!groups.has(shopId)) {
groups.set(shopId, {
shopId: shopId,
shopName: rawItem.shop_name || '商城优选',
merchant_id: rawItem.merchant_id || rawItem.shop_id,
items: [] as any[]
})
}
groups.get(shopId).items.push(item)
})
return Array.from(groups.values())
})
const getGroupTotal = (group: any) => {
return group.items.reduce((sum: number, item: any) => {
return sum + (Number(item.price) * Number(item.quantity))
}, 0).toFixed(2)
}
const totalAmount = computed(() => {
console.log('计算商品总价checkoutItems:', checkoutItems.value)
if (!checkoutItems.value || checkoutItems.value.length === 0) {
console.log('商品列表为空返回0')
return 0
}
// 确保每个商品的价格和数量都是数字类型,并计算总和
const total = checkoutItems.value.reduce((sum, item) => {
// 确保item存在且包含必要的属性
if (!item) return sum
// 将价格转换为数字(确保是数字类型)
let price = 0
if (item.price !== undefined && item.price !== null) {
price = typeof item.price === 'string' ? parseFloat(item.price) : Number(item.price)
}
// 将数量转换为数字
let quantity = 0
if (item.quantity !== undefined && item.quantity !== null) {
quantity = typeof item.quantity === 'string' ? parseInt(item.quantity) : Number(item.quantity)
}
// 验证转换后的数字是否有效
if (isNaN(price) || isNaN(quantity) || price <= 0 || quantity <= 0) {
console.warn('商品价格或数量无效:', item, 'price:', price, 'quantity:', quantity)
return sum
}
const itemTotal = price * quantity
return sum + itemTotal
}, 0)
return total
})
const deliveryFee = computed(() => {
const option = deliveryOptions.value.find(opt => opt.id === selectedDelivery.value)
return option?.price || 0
})
const discountAmount = computed(() => {
if (!selectedCoupon.value || !selectedCoupon.value.template) return 0
const coupon = selectedCoupon.value.template
// 确保使用计算后的商品总价进行比较 (should be min_order_amount)
if (totalAmount.value < coupon.min_order_amount) return 0
// 简单处理:假设都是满减券
return coupon.discount_value
})
const actualAmount = computed(() => {
// 确保所有值都是数字类型
const total = typeof totalAmount.value === 'number' ? totalAmount.value : 0
const delivery = typeof deliveryFee.value === 'number' ? deliveryFee.value : 0
const discount = typeof discountAmount.value === 'number' ? discountAmount.value : 0
// 正确计算:商品总价 + 运费 - 优惠减免
let amount = total + delivery - discount
// 金额必须大于等于0
return amount > 0 ? amount : 0
})
// 监听checkoutItems变化 - 调试用
watch(checkoutItems, (newItems) => {
console.log('checkoutItems变化了:', newItems)
console.log('商品总价计算:', totalAmount.value)
}, { deep: true })
// 页面加载时监听eventChannel
onLoad(() => {
let dataLoaded = false
// 优先检查Storage中是否有结算数据 (支持 buy_now 和 cart 两种模式)
const checkoutType = uni.getStorageSync('checkout_type')
if (checkoutType === 'buy_now' || checkoutType === 'cart') {
console.log(`检测到结算模式(${checkoutType})从Storage加载数据`)
const itemsStr = uni.getStorageSync('checkout_items')
if (itemsStr) {
try {
const items = JSON.parse(itemsStr as string)
console.log('从Storage加载的商品数据:', items)
if (items && Array.isArray(items) && items.length > 0) {
processCheckoutItems(items)
dataLoaded = true
}
} catch (e) {
console.error('解析结算数据失败', e)
}
}
}
// 如果没有从checkout_items加载到数据则尝试从通用购物车Storage加载 (回退方案)
if (!dataLoaded) {
console.log('未找到预结算数据,尝试从购物车本地存储加载')
loadFromLocalStorage()
} else {
// 如果已经加载了数据还需要单独加载地址因为loadFromLocalStorage通常会附带加载地址
loadDefaultAddress()
}
// 从上一页获取数据
const eventChannel = uni.getEventChannel ? uni.getEventChannel() : null
if (eventChannel) {
eventChannel.on('acceptData', (data: any) => {
console.log('接收到商品数据:', data)
let items = data.selectedItems || []
if (items.length > 0) {
processCheckoutItems(items)
}
loadDefaultAddress()
})
}
})
// 处理商品数据清洗
const processCheckoutItems = (items: any[]) => {
// 数据清洗:确保价格和数量是数字类型
if (items && items.length > 0) {
// 关键修复:需要保留 shop_id 和 merchant_id
items = items.map((item: any) => {
// 确保价格是数字
let price = 0
if (item.price !== undefined && item.price !== null) {
price = typeof item.price === 'string' ? parseFloat(item.price) : Number(item.price)
if (isNaN(price)) price = 0
}
// 确保数量是数字
let quantity = 1
if (item.quantity !== undefined && item.quantity !== null) {
quantity = typeof item.quantity === 'string' ? parseInt(item.quantity) : Number(item.quantity)
if (isNaN(quantity) || quantity < 1) quantity = 1
}
return {
id: item.id,
product_id: item.product_id || item.id,
sku_id: item.sku_id || item.id,
product_name: item.product_name || item.name || '',
product_image: item.product_image || item.image || '',
sku_specifications: item.sku_specifications || item.spec || {},
price: Number(price.toFixed(2)),
quantity: quantity,
// 保留店铺信息,用于分组
shop_id: item.shop_id || item.shopId || 'unknown',
shop_name: item.shop_name || item.shopName || '',
merchant_id: item.merchant_id || item.merchantId
}
})
}
checkoutItems.value = items
// 调试:打印每个商品的价格
if (checkoutItems.value && checkoutItems.value.length > 0) {
console.log('清洗后商品价格明细:')
checkoutItems.value.forEach((item: any, index: number) => {
console.log(`商品${index}:`, item.product_name, '价格:', item.price, 'shop:', item.shop_id)
})
}
}
// 生命周期
onMounted(() => {
// 监听地址更新事件
uni.$on('addressUpdated', (updatedAddressList: any) => {
addressList.value = updatedAddressList
// 如果当前没有选中地址,尝试选择默认地址
if (!selectedAddress.value && addressList.value.length > 0) {
const defaultAddress = addressList.value.find(addr => addr.is_default)
if (defaultAddress) {
selectedAddress.value = defaultAddress
}
}
})
})
// 页面显示时触发
const onShow = () => {
// 检查用户登录状态
const userId = getCurrentUserId()
if (userId) {
// 重新加载默认地址和地址列表
loadDefaultAddress()
loadAddressList()
}
}
// 注册页面显示事件
uni.$on('checkoutPageShow', onShow)
// 组件卸载时移除事件监听
onUnmounted(() => {
uni.$off('addressUpdated')
uni.$off('checkoutPageShow')
// 离开页面时清除结算数据,防止下次进入时显示旧数据
uni.removeStorageSync('checkout_type')
uni.removeStorageSync('checkout_items')
})
// 从本地存储加载结算数据(例如从购物车进入)
const loadFromLocalStorage = () => {
// 尝试从本地存储获取购物车选中的商品
const cartData = uni.getStorageSync('cart')
if (cartData) {
try {
const cartItems = JSON.parse(cartData as string) as any[]
// 筛选选中的商品
const selectedCartItems = cartItems.filter(item => item.selected === true)
if (selectedCartItems.length > 0) {
// 转换为CheckoutItemType格式
const convertedItems: CheckoutItemType[] = selectedCartItems.map(item => {
let quantity = typeof item.quantity === 'string' ? parseInt(item.quantity) : Number(item.quantity)
if (isNaN(quantity) || quantity < 1) quantity = 1
return {
id: item.id,
product_id: item.product_id || item.productId || item.id,
sku_id: item.sku_id || item.id,
product_name: item.name || '',
product_image: item.image || '',
sku_specifications: item.spec ? { spec: item.spec } : {},
price: Number(item.price) || 0,
quantity: quantity,
shop_id: item.shop_id || item.shopId,
shop_name: item.shop_name || item.shopName,
merchant_id: item.merchant_id || item.merchantId
}
})
// 再次经过process处理确保类型正确
processCheckoutItems(convertedItems)
}
} catch (e) {
console.error('解析购物车数据失败:', e)
}
}
loadDefaultAddress()
}
// 加载结算数据兼容旧版本现在主要在onLoad中处理
const loadCheckoutData = () => {
// 为了兼容性保留但主要逻辑已在onLoad中实现
loadFromLocalStorage()
}
// 加载默认地址
const loadDefaultAddress = async () => {
try {
// 首先检查用户是否登录
const currentUserId = getCurrentUserId()
// 如果用户已登录尝试从Supabase加载地址数据
if (currentUserId) {
const supabaseAddresses = await supabaseService.getAddresses()
if (supabaseAddresses && supabaseAddresses.length > 0) {
// 查找默认地址
const defaultAddress = supabaseAddresses.find((addr: SupabaseUserAddress) => addr.is_default === true)
if (defaultAddress) {
// 转换地址格式以匹配selectedAddress的结构
selectedAddress.value = {
id: defaultAddress.id,
recipient_name: defaultAddress.recipient_name,
phone: defaultAddress.phone,
province: defaultAddress.province,
city: defaultAddress.city,
district: defaultAddress.district,
detail: defaultAddress.detail_address,
is_default: defaultAddress.is_default
}
} else {
// 如果没有默认地址,使用第一个地址
const firstAddress = supabaseAddresses[0]
selectedAddress.value = {
id: firstAddress.id,
recipient_name: firstAddress.recipient_name,
phone: firstAddress.phone,
province: firstAddress.province,
city: firstAddress.city,
district: firstAddress.district,
detail: firstAddress.detail_address,
is_default: firstAddress.is_default
}
}
// 同时更新本地存储缓存
const localAddresses = supabaseAddresses.map((addr: SupabaseUserAddress) => ({
id: addr.id,
name: addr.recipient_name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail_address,
isDefault: addr.is_default
}))
uni.setStorageSync('addresses', JSON.stringify(localAddresses))
}
}
// 如果Supabase没有地址数据或用户未登录尝试从本地存储加载
if (!selectedAddress.value) {
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses) {
try {
const addresses = JSON.parse(storedAddresses as string) as any[]
if (addresses && addresses.length > 0) {
// 查找默认地址
const defaultAddress = addresses.find((addr: any) => addr.isDefault === true)
if (defaultAddress) {
selectedAddress.value = {
id: defaultAddress.id,
recipient_name: defaultAddress.name,
phone: defaultAddress.phone,
province: defaultAddress.province,
city: defaultAddress.city,
district: defaultAddress.district,
detail: defaultAddress.detail,
is_default: defaultAddress.isDefault
}
} else {
// 如果没有默认地址,使用第一个地址
const firstAddress = addresses[0]
selectedAddress.value = {
id: firstAddress.id,
recipient_name: firstAddress.name,
phone: firstAddress.phone,
province: firstAddress.province,
city: firstAddress.city,
district: firstAddress.district,
detail: firstAddress.detail,
is_default: firstAddress.isDefault
}
}
}
} catch (err) {
console.error('解析本地地址数据失败:', err)
}
}
}
// 如果仍然没有地址,使用模拟地址数据
if (!selectedAddress.value) {
// 模拟地址数据
const mockAddresses = [
{
id: 'addr_001',
name: '张三',
phone: '13800138001',
province: '北京市',
city: '北京市',
district: '朝阳区',
detail: '建国路88号SOHO现代城A座1001',
isDefault: true
},
{
id: 'addr_002',
name: '李四',
phone: '13900139001',
province: '上海市',
city: '上海市',
district: '浦东新区',
detail: '陆家嘴环路1000号汇亚大厦20层',
isDefault: false
}
]
// 保存模拟地址到本地存储
uni.setStorageSync('addresses', JSON.stringify(mockAddresses))
// 使用第一个地址作为默认地址
selectedAddress.value = {
id: mockAddresses[0].id,
recipient_name: mockAddresses[0].name,
phone: mockAddresses[0].phone,
province: mockAddresses[0].province,
city: mockAddresses[0].city,
district: mockAddresses[0].district,
detail: mockAddresses[0].detail,
is_default: mockAddresses[0].isDefault
}
}
} catch (error) {
console.error('加载地址失败:', error)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
// 使用 SupabaseService 获取当前用户ID
const userId = supabaseService.getCurrentUserId()
return userId ?? ''
}
// 用户登录状态
const isLoggedIn = computed(() => {
const userId = getCurrentUserId()
return !!userId
})
// 获取完整地址
const getFullAddress = (address: any): string => {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 加载地址列表
const loadAddressList = async () => {
// 逻辑保持不变,略微简化控制台输出以免刷屏
try {
const currentUserId = getCurrentUserId()
if (currentUserId) {
const supabaseAddresses = await supabaseService.getAddresses()
if (supabaseAddresses && supabaseAddresses.length > 0) {
addressList.value = supabaseAddresses.map((addr: SupabaseUserAddress) => ({
id: addr.id,
recipient_name: addr.recipient_name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail_address,
is_default: addr.is_default
}))
const localAddresses = supabaseAddresses.map((addr: SupabaseUserAddress) => ({
id: addr.id,
name: addr.recipient_name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail_address,
isDefault: addr.is_default
}))
uni.setStorageSync('addresses', JSON.stringify(localAddresses))
}
}
if (!addressList.value || addressList.value.length === 0) {
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses) {
try {
const addresses = JSON.parse(storedAddresses as string) as any[]
if (addresses && addresses.length > 0) {
addressList.value = addresses.map((addr: any) => ({
id: addr.id,
recipient_name: addr.name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail,
is_default: addr.isDefault
}))
} else {
addressList.value = []
}
} catch (err) {
addressList.value = []
}
} else {
addressList.value = []
}
}
if (!addressList.value || addressList.value.length === 0) {
const mockAddresses = [
{
id: 'addr_001',
name: '张三',
phone: '13800138001',
province: '北京市',
city: '北京市',
district: '朝阳区',
detail: '建国路88号SOHO现代城A座1001',
isDefault: true
},
{
id: 'addr_002',
name: '李四',
phone: '13900139001',
province: '上海市',
city: '上海市',
district: '浦东新区',
detail: '陆家嘴环路1000号汇亚大厦20层',
isDefault: false
}
]
uni.setStorageSync('addresses', JSON.stringify(mockAddresses))
addressList.value = mockAddresses.map((addr: any) => ({
id: addr.id,
recipient_name: addr.name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail,
is_default: addr.isDefault
}))
}
} catch (error) {
console.error('加载地址列表失败:', error)
}
}
// 选择地址
const handleSelectAddress = (address: any) => {
selectedAddress.value = address
showAddressPopup.value = false
}
// 新建地址
const handleAddNewAddress = () => {
showNewAddressForm.value = true
}
// 保存新地址
const saveNewAddress = async () => {
if (!newAddress.value.recipient_name || !newAddress.value.phone || !newAddress.value.detail) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
// 触发保存确认弹窗
showSaveConfirm.value = true
}
// 处理保存确认
const handleSaveConfirm = async (save: boolean) => {
showSaveConfirm.value = false
const newAddressData = {
id: `addr_${Date.now()}`,
name: newAddress.value.recipient_name,
phone: newAddress.value.phone,
province: newAddress.value.province,
city: newAddress.value.city,
district: newAddress.value.district,
detail: newAddress.value.detail,
isDefault: newAddress.value.is_default,
label: ''
}
if (save) {
const storedAddresses = uni.getStorageSync('addresses')
let addresses: any[] = []
if (storedAddresses) {
try {
addresses = JSON.parse(storedAddresses as string) as any[]
} catch (e) {
addresses = []
}
}
if (newAddressData.isDefault) {
addresses.forEach(addr => {
addr.isDefault = false
})
}
if (addresses.length === 0 && !newAddressData.isDefault) {
newAddressData.isDefault = true
}
addresses.unshift(newAddressData)
uni.setStorageSync('addresses', JSON.stringify(addresses))
uni.$emit('addressUpdated', addresses.map((addr) => ({
id: addr.id,
recipient_name: addr.name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail,
is_default: addr.isDefault
})))
}
const checkoutFormatAddress = {
id: newAddressData.id,
recipient_name: newAddressData.name,
phone: newAddressData.phone,
province: newAddressData.province,
city: newAddressData.city,
district: newAddressData.district,
detail: newAddressData.detail,
is_default: newAddressData.isDefault
}
if (checkoutFormatAddress.is_default) {
addressList.value.forEach(addr => {
addr.is_default = false
})
}
addressList.value.unshift(checkoutFormatAddress)
if (checkoutFormatAddress.is_default || !selectedAddress.value) {
selectedAddress.value = checkoutFormatAddress
}
newAddress.value = {
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
}
smartAddressInput.value = ''
showNewAddressForm.value = false
uni.showToast({
title: '地址保存成功',
icon: 'success'
})
}
// 解析智能地址
const parseSmartAddress = () => {
const input = smartAddressInput.value.trim()
if (!input) return
newAddress.value.recipient_name = ''
newAddress.value.phone = ''
newAddress.value.province = ''
newAddress.value.city = ''
newAddress.value.district = ''
newAddress.value.detail = ''
const phoneRegex = /(1[3-9]\d{9})/g
const phoneMatches = input.match(phoneRegex)
if (phoneMatches && phoneMatches.length > 0) {
newAddress.value.phone = phoneMatches[0]
}
const nameRegex = /([\u4e00-\u9fa5]{2,4})/g
const nameMatches = input.match(nameRegex)
if (nameMatches && nameMatches.length > 0) {
newAddress.value.recipient_name = nameMatches[0]
}
let addressText = input
if (newAddress.value.recipient_name) {
addressText = addressText.replace(newAddress.value.recipient_name, '')
}
if (newAddress.value.phone) {
addressText = addressText.replace(newAddress.value.phone, '')
}
addressText = addressText.replace(/[,;\s]+/g, ' ').trim()
const patterns = [
/^(.*?省)?(.*?市)?(.*?[区县])?(.*)$/,
/^(.*?省)?(.*?市)?(.*)$/
]
for (const pattern of patterns) {
const match = addressText.match(pattern)
if (match) {
const [, province, city, district, detail] = match
if (province) newAddress.value.province = province.replace('省', '').trim()
if (city) newAddress.value.city = city.replace('市', '').trim()
if (district) newAddress.value.district = district.trim()
if (detail) newAddress.value.detail = detail.trim()
if (!newAddress.value.detail && district && detail) {
newAddress.value.detail = detail.trim()
}
break
}
}
if (!newAddress.value.province && !newAddress.value.city && !newAddress.value.district) {
const parts = addressText.split(/[省市县区]/)
if (parts.length >= 2) {
newAddress.value.province = parts[0] || ''
newAddress.value.city = parts[1] || ''
newAddress.value.detail = parts.slice(2).join('').trim() || addressText
} else {
newAddress.value.detail = addressText
}
}
if (!newAddress.value.detail && addressText.trim()) {
newAddress.value.detail = addressText.trim()
}
}
// 取消新建地址
const cancelNewAddress = () => {
showNewAddressForm.value = false
newAddress.value = {
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
}
smartAddressInput.value = ''
}
// 获取规格文本
const getSpecText = (specs: any): string => {
if (!specs) return ''
if (typeof specs === 'object') {
return Object.keys(specs)
.map(key => `${key}: ${specs[key]}`)
.join('; ')
}
return String(specs)
}
// 选择配送方式
const selectDelivery = (option: DeliveryOptionType) => {
selectedDelivery.value = option.id
}
// 选择优惠券
const selectCoupon = () => {
uni.navigateTo({
url: '/pages/mall/consumer/coupons',
success: (res) => {
res.eventChannel.emit('setSelectMode', { selectMode: true })
}
})
uni.$on('couponSelected', (coupon: any) => {
selectedCoupon.value = coupon
uni.$off('couponSelected')
})
}
// 提交订单 - 逻辑保持注意shopGroups已有完整信息
const submitOrder = async () => {
if (!selectedAddress.value) {
uni.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
if (checkoutItems.value.length === 0) {
uni.showToast({ title: '订单中没有商品', icon: 'none' })
return
}
uni.showLoading({ title: '提交中...' })
try {
const userId = supabaseService.getCurrentUserId()
if (!userId) {
uni.hideLoading()
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const groups = shopGroups.value.map((group: any): any => {
return {
merchant_id: group.merchant_id || group.shopId,
shopName: group.shopName,
items: group.items.map((item: any): any => ({
id: item.id,
product_id: item.product_id,
sku_id: item.sku_id,
quantity: item.quantity,
price: item.price,
product_name: item.product_name,
product_image: item.product_image,
specifications: item.sku_specifications
}))
}
})
const result = await supabaseService.createOrdersByShop({
shipping_address: selectedAddress.value,
shopGroups: groups,
deliveryFee: deliveryFee.value,
discountAmount: discountAmount.value
})
uni.hideLoading()
if (result.success) {
try {
uni.removeStorageSync('checkout_items')
uni.removeStorageSync('checkout_type')
} catch(e) { console.error(e) }
const orderIds = result.orderIds
if (orderIds.length === 1) {
uni.navigateTo({
url: `/pages/mall/consumer/payment?orderId=${orderIds[0]}&amount=${actualAmount.value}`
})
} else {
uni.showToast({ title: `成功创建${orderIds.length}个订单`, icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/mall/consumer/orders' })
}, 1500)
}
} else {
throw new Error(result.error || '创建订单失败')
}
} catch (err: any) {
uni.hideLoading()
console.error('提交订单错误:', err)
uni.showToast({ title: err.message || '提交订单失败', icon: 'none' })
}
}
// 生成订单号
const generateOrderNo = (): string => {
const date = new Date()
// ...
const random = Math.random().toString().slice(2, 8)
return `ORD${Date.now()}${random}`
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.checkout-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
/* 顶部栏 */
.checkout-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
text-align: center;
}
.checkout-content {
flex: 1;
}
.address-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 20px 15px;
display: flex;
align-items: center;
}
.address-info { flex: 1; }
.address-header { display: flex; align-items: center; margin-bottom: 10px; }
.recipient { font-size: 16px; font-weight: bold; color: #333333; margin-right: 15px; }
.phone { font-size: 14px; color: #666666; margin-right: 10px; }
.default-tag { background-color: #ff4757; padding: 2px 8px; border-radius: 10px; }
.tag-text { color: #ffffff; font-size: 12px; }
.address-detail { font-size: 14px; color: #333333; line-height: 1.4; }
.no-address { flex: 1; display: flex; justify-content: space-between; align-items: center; }
.no-address-text { font-size: 16px; color: #999999; }
.no-address-arrow { color: #999999; font-size: 18px; }
.products-section { background-color: #ffffff; margin-bottom: 10px; padding: 0 15px; }
.debug-info { background-color: #f8f9fa; padding: 10px 15px; border-bottom: 1px solid #e5e5e5; margin-bottom: 10px; }
.debug-text { font-size: 12px; color: #666; display: block; text-align: center; }
.shop-group { background-color: #fff; margin: 10px 0; border-radius: 12px; padding: 10px; }
.shop-header { display: flex; flex-direction: row; align-items: center; padding-bottom: 10px; border-bottom: 1px solid #f0f0f0; }
.shop-icon { font-size: 18px; margin-right: 8px; }
.shop-name { font-size: 16px; font-weight: bold; color: #333; }
.shop-subtotal { display: flex; justify-content: flex-end; align-items: center; padding-top: 10px; margin-top: 5px; border-top: 1px dashed #f0f0f0; font-size: 14px; }
.subtotal-label { color: #666; margin-right: 10px; }
.subtotal-value { color: #333; }
.subtotal-text { color: #333; margin-right: 5px; }
.subtotal-price { color: #ff4757; font-weight: bold; font-size: 16px; }
.product-item { display: flex; padding: 15px 0; border-bottom: 1px solid #f5f5f5; }
.product-item:last-child { border-bottom: none; }
.product-image { width: 80px; height: 80px; border-radius: 5px; margin-right: 15px; }
.product-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.product-name { font-size: 14px; color: #333333; line-height: 1.4; margin-bottom: 5px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.product-spec { font-size: 12px; color: #999999; margin-bottom: 10px; }
.product-bottom { display: flex; justify-content: space-between; align-items: center; }
.product-price { font-size: 16px; color: #ff4757; font-weight: bold; }
.product-quantity { font-size: 14px; color: #666666; }
.delivery-section, .coupon-section, .remark-section, .price-section { background-color: #ffffff; margin-bottom: 10px; padding: 15px; }
.section-title { font-size: 16px; font-weight: bold; color: #333333; margin-bottom: 15px; }
.delivery-options { display: flex; flex-direction: column; gap: 10px; }
.delivery-option { display: flex; align-items: center; justify-content: space-between; padding: 15px; border: 1px solid #e5e5e5; border-radius: 8px; }
.delivery-option.selected { border-color: #007aff; background-color: #f0f8ff; }
.option-name { font-size: 14px; color: #333333; }
.option-price { font-size: 14px; color: #ff4757; font-weight: bold; }
.option-selected { color: #007aff; font-size: 16px; }
.coupon-info { display: flex; align-items: center; justify-content: space-between; padding: 15px; border: 1px solid #e5e5e5; border-radius: 8px; }
.coupon-selected { font-size: 14px; color: #007aff; }
.coupon-placeholder { font-size: 14px; color: #999999; }
.coupon-arrow { color: #999999; font-size: 16px; }
.remark-input { width: 100%; min-height: 40px; padding: 10px; border: 1px solid #e5e5e5; border-radius: 8px; font-size: 14px; color: #333333; }
.price-detail { padding: 15px; background-color: #f8f9fa; border-radius: 8px; }
.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; }
.bottom-bar { background-color: #ffffff; padding: 15px; border-top: 1px solid #e5e5e5; display: flex; align-items: center; justify-content: space-between; }
.price-summary { display: flex; align-items: baseline; }
.summary-label { font-size: 14px; color: #333333; margin-right: 5px; }
.summary-price { font-size: 20px; color: #ff4757; font-weight: bold; }
.submit-btn { background-color: #007aff; color: #ffffff; padding: 0 40px; height: 45px; border-radius: 22.5px; font-size: 16px; font-weight: bold; border: none; }
/* 弹窗样式 */
.address-popup-mask, .address-form-mask, .confirm-popup-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 9998; }
.address-popup-mask { display: flex; align-items: flex-end; justify-content: center; }
.address-form-mask, .confirm-popup-mask { display: flex; align-items: center; justify-content: center; }
.address-popup { background-color: #ffffff; width: 100%; max-height: 70vh; border-radius: 20px 20px 0 0; display: flex; flex-direction: column; }
.address-form-popup { background-color: #ffffff; width: 90%; max-width: 500px; max-height: 80vh; border-radius: 12px; display: flex; flex-direction: column; }
.confirm-popup { background-color: #ffffff; width: 80%; max-width: 320px; border-radius: 12px; overflow: hidden; }
.popup-header, .form-header { padding: 20px 15px; border-bottom: 1px solid #e5e5e5; display: flex; align-items: center; justify-content: space-between; }
.popup-title, .form-title { font-size: 18px; font-weight: bold; color: #333333; }
.popup-close, .form-close { font-size: 24px; color: #999999; padding: 5px; }
.address-list-container, .form-content { flex: 1; padding: 15px; max-height: 50vh; }
.popup-address-item { padding: 15px; margin-bottom: 10px; border: 1px solid #e5e5e5; border-radius: 8px; position: relative; }
.popup-address-header { display: flex; align-items: center; margin-bottom: 10px; }
.popup-address-name { font-size: 16px; font-weight: bold; color: #333333; margin-right: 15px; }
.popup-address-phone { font-size: 14px; color: #666666; margin-right: 10px; }
.popup-default-tag { background-color: #ff4757; padding: 2px 8px; border-radius: 10px; }
.popup-tag-text { color: #ffffff; font-size: 12px; }
.popup-address-detail { font-size: 14px; color: #333333; line-height: 1.4; display: block; }
.popup-selected-indicator { position: absolute; top: 15px; right: 15px; color: #007aff; font-size: 16px; }
.popup-empty-address { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; }
.popup-empty-icon { font-size: 60px; margin-bottom: 15px; }
.popup-empty-text { font-size: 16px; color: #999999; }
.popup-add-address-btn { background-color: #007aff; margin: 15px; padding: 15px; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
.popup-btn-icon { color: #ffffff; font-size: 20px; margin-right: 10px; }
.popup-btn-text { color: #ffffff; font-size: 16px; font-weight: bold; }
.form-item { margin-bottom: 20px; }
.form-label { font-size: 14px; color: #333333; margin-bottom: 8px; display: block; }
.form-input { width: 100%; padding: 12px; border: 1px solid #e5e5e5; border-radius: 8px; font-size: 14px; color: #333333; box-sizing: border-box; }
.form-input[readonly] { background-color: #f9f9f9; color: #666666; }
.region-inputs { display: flex; gap: 10px; }
.region-input { flex: 1; }
.form-textarea { width: 100%; min-height: 80px; padding: 12px; border: 1px solid #e5e5e5; border-radius: 8px; font-size: 14px; color: #333333; box-sizing: border-box; }
.smart-address-input { min-height: 60px; }
.smart-tip { font-size: 12px; color: #999999; margin-top: 5px; display: block; }
.checkbox-item { margin-top: 20px; }
.checkbox-wrapper { display: flex; align-items: center; }
.checkbox { width: 20px; height: 20px; border: 1px solid #e5e5e5; border-radius: 4px; margin-right: 10px; display: flex; align-items: center; justify-content: center; }
.checkbox.checked { background-color: #007aff; border-color: #007aff; }
.checkbox-check { color: #ffffff; font-size: 14px; }
.checkbox-label { font-size: 14px; color: #333333; }
.form-buttons { display: flex; padding: 15px; border-top: 1px solid #e5e5e5; gap: 10px; }
.form-cancel-btn { flex: 1; background-color: #f5f5f5; color: #333333; padding: 12px; border-radius: 8px; font-size: 16px; border: none; }
.form-submit-btn { flex: 1; background-color: #007aff; color: #ffffff; padding: 12px; border-radius: 8px; font-size: 16px; border: none; }
.confirm-header { padding: 20px 0 10px; text-align: center; }
.confirm-title { font-size: 18px; font-weight: bold; color: #333333; }
.confirm-content { padding: 0 20px 20px; text-align: center; }
.confirm-message { font-size: 16px; color: #666666; }
.confirm-buttons { display: flex; border-top: 1px solid #e5e5e5; }
.confirm-btn { flex: 1; height: 50px; line-height: 50px; text-align: center; font-size: 16px; background-color: #ffffff; border: none; border-radius: 0; }
.confirm-btn.cancel { color: #666666; border-right: 1px solid #e5e5e5; }
.confirm-btn.confirm { color: #007aff; font-weight: bold; }
</style>