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

2176 lines
62 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" direction="vertical">
<!-- 收货地址 -->
<view class="section-card address-section" @click="selectAddress">
<view class="address-icon-wrapper">
<text class="location-icon">📍</text>
</view>
<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>
</view>
<view class="address-arrow-wrapper">
<text class="address-arrow"></text>
</view>
</view>
<!-- 商品列表 (按店铺分组) -->
<view class="section-card products-section">
<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">
<view class="product-name-row">
<text class="product-name">{{ item.product_name }}</text>
<text class="product-price">¥{{ item.price }}</text>
</view>
<view class="product-spec-row">
<text v-if="item.sku_specifications" class="product-spec">{{ formatSpecs(item.sku_specifications) }}</text>
<text class="product-quantity">×{{ item.quantity }}</text>
</view>
<!-- 商品小计移至图片右侧 -->
<view class="item-subtotal-row">
<text class="item-subtotal-label">小计:</text>
<text class="item-subtotal-price">¥{{ (item.price * item.quantity).toFixed(2) }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-else class="no-products">
<text class="no-products-text">暂无商品信息</text>
</view>
</view>
<!-- 配送方式 -->
<view class="section-card delivery-section">
<view class="delivery-row">
<text class="section-title">配送方式</text>
<view class="delivery-selector">
<view v-for="option in deliveryOptions"
:key="option.id"
:class="['delivery-pill', { selected: selectedDelivery === option.id }]"
@click="selectDelivery(option)">
<text class="pill-name">{{ option.name }}</text>
</view>
</view>
</view>
<view class="delivery-detail" v-if="selectedDelivery">
<text class="detail-desc">{{ deliveryOptions.find(opt => opt.id === selectedDelivery)?.description }}</text>
<text class="detail-price">费用: ¥{{ deliveryOptions.find(opt => opt.id === selectedDelivery)?.price.toFixed(2) }}</text>
</view>
</view>
<!-- 优惠券 -->
<view class="section-card coupon-section" @click="selectCoupon">
<view class="coupon-row">
<text class="section-title">优惠券</text>
<view class="coupon-right-content">
<text v-if="selectedCoupon != null" class="coupon-selected-name">{{ selectedCoupon.template?.name ?? '已选择优惠券' }}</text>
<text v-else class="coupon-placeholder">暂无可用优惠券</text>
<text class="arrow-icon"></text>
</view>
</view>
</view>
<!-- 买家留言 -->
<view class="section-card remark-section">
<view class="remark-row">
<text class="section-title">买家留言</text>
<input class="remark-input-compact"
v-model="remark"
placeholder="选填,给商家留言"
maxlength="100" />
</view>
</view>
<!-- 价格明细 -->
<view class="section-card price-section">
<view class="price-grid">
<view class="price-item-inline">
<text class="price-item-label">商品</text>
<text class="price-item-value">¥{{ totalAmount.toFixed(2) }}</text>
</view>
<view class="price-item-inline">
<text class="price-item-label">运费</text>
<text class="price-item-value">+¥{{ deliveryFee.toFixed(2) }}</text>
</view>
<view v-if="discountAmount > 0" class="price-item-inline">
<text class="price-item-label">优惠</text>
<text class="price-item-value discount-text">-¥{{ discountAmount.toFixed(2) }}</text>
</view>
</view>
</view>
<view class="safe-area-bottom"></view>
</scroll-view>
<!-- 底部结算栏 -->
<view class="footer-action-bar">
<view class="footer-left">
<text class="footer-total-label">合计:</text>
<text class="footer-currency">¥</text>
<text class="footer-price">{{ actualAmount.toFixed(2) }}</text>
</view>
<button class="footer-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" direction="vertical" :scroll-with-animation="true">
<!-- 登录提示 -->
<view v-if="isLoggedIn == false" 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 !== null && 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 == false && 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 != null && 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>
<view class="form-close-btn" @click="cancelNewAddress">
<text class="form-close-icon">✕</text>
</view>
</view>
<scroll-view class="form-content" direction="vertical">
<view class="form-section">
<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>
<view class="form-section">
<view class="form-item">
<view class="label-row">
<text class="form-label">智能填写</text>
<text class="smart-tag">识别姓名/电话/地址</text>
</view>
<textarea class="form-textarea smart-address-input"
v-model="smartAddressInput"
placeholder="粘贴收货信息文本,自动拆分字段"
@input="parseSmartAddress"
maxlength="200" />
</view>
</view>
<view class="form-section">
<view class="form-item">
<text class="form-label">所在地区</text>
<view class="region-inputs">
<input class="form-input region-input form-input-readonly" v-model="newAddress.province"
placeholder="省" readonly />
<input class="form-input region-input form-input-readonly" v-model="newAddress.city"
placeholder="市" readonly />
<input class="form-input region-input form-input-readonly" v-model="newAddress.district"
placeholder="区/县" readonly />
</view>
</view>
<view class="form-item">
<text class="form-label">详细地址</text>
<textarea class="form-textarea detail-textarea" v-model="newAddress.detail"
placeholder="如街道、楼栋、门牌号等"
maxlength="100" />
</view>
</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-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 ShopGroupType = {
shopId: string
shopName: string
merchant_id: string
items: Array<CheckoutItemType>
}
type CouponTemplateType = {
name: string
discount_value: number
min_order_amount: number
}
type UserCouponType = {
id: string
template: CouponTemplateType | null
}
type AddressItem = {
id: string
recipient_name: string
phone: string
province: string
city: string
district: string
detail: string
is_default: boolean
}
type NewAddressData = {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
}
// 添加新地址表单类型定义
type NewAddressForm = {
recipient_name: string
phone: string
province: string
city: string
district: string
detail: string
is_default: boolean
}
type MockAddress = {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
}
// 添加对象 keys 获取函数
function getObjectKeys(obj: object): string[] {
const keys: string[] = []
// UTS 兼容的对象属性获取方式
const tempObj = obj as Record<string, any>
// 使用 try-catch 安全获取对象属性
try {
// 假设我们知道一些常见的属性名
const commonKeys = ['id', 'name', 'value', 'label', 'key', 'recipient_name', 'phone', 'province', 'city', 'district', 'detail', 'is_default']
for (let i = 0; i < commonKeys.length; i++) {
const key = commonKeys[i]
// 替换 hasOwnProperty 检查
if (tempObj[key] !== null) { // 移除对 undefined 的检查
keys.push(key)
}
}
} catch (e) {
// 捕获异常,避免编译错误
}
return keys
}
const checkoutItems = ref<Array<CheckoutItemType>>([])
const selectedAddress = ref<AddressItem | null>(null)
const deliveryOptions = ref<Array<DeliveryOptionType>>([
{ id: 'express', name: '物流快递', price: 8.00, description: '普通快递配送' },
{ id: 'local', name: '同城配送', price: 15.00, description: '同城极速上门' }
])
const selectedDelivery = ref<string>('express')
const selectedCoupon = ref<UserCouponType | null>(null)
const remark = ref<string>('')
const showAddressPopup = ref<boolean>(false)
const addressList = ref<Array<AddressItem>>([])
const newAddress = ref<NewAddressForm>({
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 toUTSJSONObject = (value: any): UTSJSONObject => {
if (value instanceof UTSJSONObject) return value as UTSJSONObject
return JSON.parse(JSON.stringify(value ?? {})) as UTSJSONObject
}
// 计算属性 - 修复价格同步问题
// 按店铺分组商品
const shopGroups = computed((): Array<ShopGroupType> => {
const groups: Array<ShopGroupType> = []
checkoutItems.value.forEach((item) => {
const shopId = item.shop_id ?? 'unknown'
let target: ShopGroupType | null = null
for (let i = 0; i < groups.length; i++) {
if (groups[i].shopId == shopId) {
target = groups[i]
break
}
}
if (target == null) {
target = {
shopId: shopId,
shopName: item.shop_name ?? '商城优选',
merchant_id: item.merchant_id ?? item.shop_id ?? '',
items: []
}
groups.push(target)
}
target.items.push(item)
})
return groups
})
const getGroupTotal = (group: ShopGroupType): string => {
let sum = 0
group.items.forEach((item) => {
const price = item.price
const quantity = item.quantity
if (isNaN(price) == false && isNaN(quantity) == false) {
sum += (price * quantity)
}
})
return sum.toFixed(2)
}
const totalAmount = computed(() => {
console.log('计算商品总价checkoutItems:', checkoutItems.value)
if (checkoutItems.value.length == 0) {
console.log('商品列表为空返回0')
return 0
}
// 确保每个商品的价格和数量都是数字类型,并计算总和
const total = checkoutItems.value.reduce((sum, item) => {
// 确保item存在且包含必要的属性
if (item == null) return sum
const price = item.price
const quantity = 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(() => {
const coupon = selectedCoupon.value?.template
if (coupon == null) return 0
// 确保使用计算后的商品总价进行比较 (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: Array<CheckoutItemType>) => {
console.log('checkoutItems变化了:', newItems)
console.log('商品总价计算:', totalAmount.value)
}, { deep: true })
// 处理商品数据清洗
const processCheckoutItems = (items: any[]) => {
// 数据清洗:确保价格和数量是数字类型
const converted: Array<CheckoutItemType> = []
if (items != null && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const obj = toUTSJSONObject(items[i])
const id = obj.getString('id') ?? ''
const productId = obj.getString('product_id') ?? obj.getString('productId') ?? id
const skuId = obj.getString('sku_id') ?? obj.getString('skuId') ?? id
const productName = obj.getString('product_name') ?? obj.getString('name') ?? ''
const productImage = obj.getString('product_image') ?? obj.getString('image') ?? ''
let specs: any = {}
const skuSpecsAny = obj.get('sku_specifications')
if (skuSpecsAny != null) {
specs = skuSpecsAny
} else {
const specAny = obj.get('spec')
if (specAny != null) specs = ({ spec: specAny } as any)
}
let price = 0
const priceAny = obj.get('price')
if (priceAny != null) {
const parsed = parseFloat(priceAny.toString())
if (isNaN(parsed) == false) price = parsed
}
let quantity = 1
const quantityAny = obj.get('quantity')
if (quantityAny != null) {
const parsedQ = parseInt(quantityAny.toString())
if (isNaN(parsedQ) == false && parsedQ >= 1) quantity = parsedQ
}
const shopId = obj.getString('shop_id') ?? obj.getString('shopId') ?? 'unknown'
const shopName = obj.getString('shop_name') ?? obj.getString('shopName') ?? ''
const merchantId = obj.getString('merchant_id') ?? obj.getString('merchantId') ?? ''
converted.push({
id: id,
product_id: productId,
sku_id: skuId,
product_name: productName,
product_image: productImage,
sku_specifications: specs,
price: parseFloat(price.toFixed(2)),
quantity: quantity,
shop_id: shopId,
shop_name: shopName,
merchant_id: merchantId
} as CheckoutItemType)
}
}
checkoutItems.value = converted
// 调试:打印每个商品的价格
if (checkoutItems.value.length > 0) {
console.log('清洗后商品价格明细:')
checkoutItems.value.forEach((item: CheckoutItemType, index: number) => {
console.log(`商品${index}:`, item.product_name, '价格:', item.price, 'shop:', item.shop_id)
})
}
}
// 获取当前用户ID
function getCurrentUserId(): string {
const userId = supabaseService.getCurrentUserId()
return userId ?? ''
}
// 生命周期
onMounted(() => {
// 监听地址更新事件
uni.$on('addressUpdated', (updatedAddressList: Array<AddressItem>) => {
addressList.value = updatedAddressList
// 如果当前没有选中地址,尝试选择默认地址
if (selectedAddress.value == null && addressList.value.length > 0) {
let defaultAddress: AddressItem | null = null
for (let i = 0; i < addressList.value.length; i++) {
const addr = addressList.value[i]
if (addr.is_default) {
defaultAddress = addr
break
}
}
if (defaultAddress != null) selectedAddress.value = defaultAddress
}
})
})
// 组件卸载时移除事件监听
onUnmounted(() => {
uni.$off('addressUpdated')
uni.$off('checkoutPageShow')
// 离开页面时清除结算数据,防止下次进入时显示旧数据
uni.removeStorageSync('checkout_type')
uni.removeStorageSync('checkout_items')
})
// 加载默认地址
async function loadDefaultAddress(): Promise<void> {
try {
// 首先检查用户是否登录
const currentUserId = getCurrentUserId()
// 如果用户已登录尝试从Supabase加载地址数据
if (currentUserId != '') {
const supabaseAddresses = await supabaseService.getAddresses()
if (supabaseAddresses != null && supabaseAddresses.length > 0) {
// 查找默认地址
const defaultAddress = supabaseAddresses.find((addr: SupabaseUserAddress) => addr.is_default === true)
if (defaultAddress != null) {
// 转换地址格式以匹配selectedAddress的结构
const addr: AddressItem = {
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
}
selectedAddress.value = addr
} else {
// 如果没有默认地址,使用第一个地址
const firstAddress = supabaseAddresses[0]
const addr: AddressItem = {
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
}
selectedAddress.value = addr
}
// 同时更新本地存储缓存
const localAddresses: any[] = []
for (let i = 0; i < supabaseAddresses.length; i++) {
const addr = supabaseAddresses[i]
localAddresses.push({
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 == null) {
const storedAddresses = uni.getStorageSync('addresses')
const storedAddressesStr = storedAddresses != null ? storedAddresses.toString() : ''
if (storedAddressesStr != '') {
try {
const addresses = JSON.parse(storedAddressesStr) as any[]
if (addresses != null && addresses.length > 0) {
let picked: UTSJSONObject | null = null
for (let i = 0; i < addresses.length; i++) {
const obj = toUTSJSONObject(addresses[i])
const isDef = obj.getBoolean('isDefault') ?? obj.getBoolean('is_default') ?? false
if (isDef) {
picked = obj
break
}
}
if (picked == null) picked = toUTSJSONObject(addresses[0])
const addr: AddressItem = {
id: picked.getString('id') ?? '',
recipient_name: picked.getString('recipient_name') ?? picked.getString('name') ?? '',
phone: picked.getString('phone') ?? '',
province: picked.getString('province') ?? '',
city: picked.getString('city') ?? '',
district: picked.getString('district') ?? '',
detail: picked.getString('detail') ?? picked.getString('detail_address') ?? '',
is_default: picked.getBoolean('isDefault') ?? picked.getBoolean('is_default') ?? false
}
selectedAddress.value = addr
}
} catch (err) {
console.error('解析本地地址数据失败:', err)
}
}
}
// 如果仍然没有地址,使用模拟地址数据
if (selectedAddress.value == null) {
// 模拟地址数据
const mockAddresses: MockAddress[] = [
{
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))
// 使用第一个地址作为默认地址
const first = mockAddresses[0]
const addr: AddressItem = {
id: first.id,
recipient_name: first.name,
phone: first.phone,
province: first.province,
city: first.city,
district: first.district,
detail: first.detail,
is_default: first.isDefault
}
selectedAddress.value = addr
}
} catch (error) {
console.error('加载地址失败:', error)
}
}
// 用户登录状态
const isLoggedIn = computed((): boolean => {
const userId = getCurrentUserId()
return userId != ''
})
// 获取完整地址
const getFullAddress = (address: AddressItem): string => {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 加载地址列表
async function loadAddressList(): Promise<void> {
console.log('[loadAddressList] 开始加载地址列表')
try {
const currentUserId = getCurrentUserId()
console.log('[loadAddressList] currentUserId:', currentUserId)
if (currentUserId != '') {
const supabaseAddresses = await supabaseService.getAddresses()
console.log('[loadAddressList] supabaseAddresses 数量:', supabaseAddresses != null ? supabaseAddresses.length : 0)
if (supabaseAddresses != null && supabaseAddresses.length > 0) {
const list: AddressItem[] = []
const localAddresses: any[] = []
for (let i = 0; i < supabaseAddresses.length; i++) {
const addr = supabaseAddresses[i]
console.log('[loadAddressList] 地址', i, ':', addr.recipient_name, addr.phone, addr.detail_address)
list.push({
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
})
localAddresses.push({
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
})
}
addressList.value = list
console.log('[loadAddressList] addressList.value 设置完成, 数量:', addressList.value.length)
uni.setStorageSync('addresses', JSON.stringify(localAddresses))
}
}
if (addressList.value.length == 0) {
const storedAddresses = uni.getStorageSync('addresses')
const storedAddressesStr = storedAddresses != null ? storedAddresses.toString() : ''
if (storedAddressesStr != '') {
try {
const addresses = JSON.parse(storedAddressesStr) as any[]
if (addresses != null && addresses.length > 0) {
const list: AddressItem[] = []
for (let i = 0; i < addresses.length; i++) {
const obj = toUTSJSONObject(addresses[i])
list.push({
id: obj.getString('id') ?? '',
recipient_name: obj.getString('recipient_name') ?? obj.getString('name') ?? '',
phone: obj.getString('phone') ?? '',
province: obj.getString('province') ?? '',
city: obj.getString('city') ?? '',
district: obj.getString('district') ?? '',
detail: obj.getString('detail') ?? obj.getString('detail_address') ?? '',
is_default: obj.getBoolean('isDefault') ?? obj.getBoolean('is_default') ?? false
})
}
addressList.value = list
} else {
addressList.value = []
}
} catch (err) {
addressList.value = []
}
} else {
addressList.value = []
}
}
if (addressList.value.length == 0) {
const mockAddresses: MockAddress[] = [
{
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))
const list: AddressItem[] = []
for (let i = 0; i < mockAddresses.length; i++) {
const addr = mockAddresses[i]
list.push({
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
})
}
addressList.value = list
}
} catch (error) {
console.error('加载地址列表失败:', error)
}
}
// 从本地存储加载结算数据(例如从购物车进入)
function loadFromLocalStorage(): void {
const cartData = uni.getStorageSync('cart')
const cartDataStr = cartData != null ? cartData.toString() : ''
if (cartDataStr != '') {
try {
const cartItems = JSON.parse(cartDataStr) as any[]
const selectedCartItems: any[] = []
for (let i = 0; i < cartItems.length; i++) {
const obj = toUTSJSONObject(cartItems[i])
const selected = obj.getBoolean('selected') ?? false
if (selected) selectedCartItems.push(obj)
}
if (selectedCartItems.length > 0) {
processCheckoutItems(selectedCartItems)
}
} catch (e) {
console.error('解析购物车数据失败:', e)
}
}
loadDefaultAddress()
}
// 加载结算数据兼容旧版本现在主要在onLoad中处理
function loadCheckoutData(): void {
loadFromLocalStorage()
}
onLoad((options: any) => {
let dataLoaded = false
const checkoutTypeAny = uni.getStorageSync('checkout_type')
const checkoutType = checkoutTypeAny != null ? checkoutTypeAny.toString() : ''
if (checkoutType == 'buy_now' || checkoutType == 'cart') {
console.log(`检测到结算模式(${checkoutType})从Storage加载数据`)
const itemsStrAny = uni.getStorageSync('checkout_items')
const itemsStr = itemsStrAny != null ? itemsStrAny.toString() : ''
if (itemsStr != '') {
try {
const items = JSON.parse(itemsStr as string)
console.log('从Storage加载的商品数据:', items)
if (items != null && Array.isArray(items) && items.length > 0) {
processCheckoutItems(items)
dataLoaded = true
}
} catch (e) {
console.error('解析结算数据失败', e)
}
}
}
if (dataLoaded == false) {
console.log('未找到预结算数据,尝试从购物车本地存储加载')
loadFromLocalStorage()
}
loadDefaultAddress()
loadAddressList()
})
// 页面显示时触发
function onShow(): void {
const userId = getCurrentUserId()
if (userId != '') {
loadDefaultAddress()
loadAddressList()
}
}
uni.$on('checkoutPageShow', onShow)
// 选择地址
const handleSelectAddress = (address: AddressItem) => {
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: 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
}
if (save) {
const storedAddresses = uni.getStorageSync('addresses')
let addresses: any[] = []
const storedAddressesStr = storedAddresses != null ? storedAddresses.toString() : ''
if (storedAddressesStr != '') {
try {
addresses = JSON.parse(storedAddressesStr) as any[]
} catch (e) {
addresses = []
}
}
const normalized: any[] = []
for (let i = 0; i < addresses.length; i++) {
const obj = toUTSJSONObject(addresses[i])
const isDef = obj.getBoolean('isDefault') ?? obj.getBoolean('is_default') ?? false
normalized.push({
id: obj.getString('id') ?? '',
name: obj.getString('name') ?? obj.getString('recipient_name') ?? '',
phone: obj.getString('phone') ?? '',
province: obj.getString('province') ?? '',
city: obj.getString('city') ?? '',
district: obj.getString('district') ?? '',
detail: obj.getString('detail') ?? obj.getString('detail_address') ?? '',
isDefault: newAddressData.isDefault ? false : isDef as boolean,
label: obj.getString('label') ?? ''
})
}
if (normalized.length === 0 && newAddressData.isDefault == false) {
newAddressData.isDefault = true
}
normalized.unshift(newAddressData)
uni.setStorageSync('addresses', JSON.stringify(normalized))
const updatedList: AddressItem[] = []
for (let i = 0; i < normalized.length; i++) {
const obj = toUTSJSONObject(normalized[i])
updatedList.push({
id: obj.getString('id') ?? '',
recipient_name: obj.getString('recipient_name') ?? obj.getString('name') ?? '',
phone: obj.getString('phone') ?? '',
province: obj.getString('province') ?? '',
city: obj.getString('city') ?? '',
district: obj.getString('district') ?? '',
detail: obj.getString('detail') ?? obj.getString('detail_address') ?? '',
is_default: obj.getBoolean('isDefault') ?? obj.getBoolean('is_default') ?? false
})
}
uni.$emit('addressUpdated', updatedList)
}
const checkoutFormatAddress: AddressItem = {
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) {
for (let i = 0; i < addressList.value.length; i++) {
addressList.value[i].is_default = false
}
}
addressList.value.unshift(checkoutFormatAddress)
if (checkoutFormatAddress.is_default || selectedAddress.value == null) {
selectedAddress.value = checkoutFormatAddress
}
newAddress.value = {
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
} as NewAddressForm;
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 != null && phoneMatches.length > 0) {
newAddress.value.phone = phoneMatches[0] ?? ''
}
const nameRegex = /([\u4e00-\u9fa5]{2,4})/g
const nameMatches = input.match(nameRegex)
if (nameMatches != null && 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 != null) {
const [, province, city, district, detail] = match
if (province != null) newAddress.value.province = province.replace('省', '').trim()
if (city != null) newAddress.value.city = city.replace('市', '').trim()
if (district != null) newAddress.value.district = district.trim()
if (detail != null) newAddress.value.detail = detail.trim()
if (newAddress.value.detail == '' && district != null && detail != null) {
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()
if (newAddress.value.detail == '') {
newAddress.value.detail = 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
} as NewAddressForm;
smartAddressInput.value = ''
}
// 获取规格文本
function formatSpecs(specs: any): string {
if (specs == null) return ''
try {
const specsStr = JSON.stringify(specs)
if (specsStr == '{}' || specsStr == '[]' || specsStr == '""' || specsStr == '') return ''
// 使用 Record 类型替代 UTSJSONObject 的迭代器方法
const specsObj = JSON.parse(specsStr) as Record<string, any>
const parts: string[] = []
// 遍历已知可能的规格键名
const possibleKeys = ['颜色', '尺寸', '规格', '型号', '版本', '材质', '款式', 'color', 'size', 'spec', 'version', 'style']
// 先尝试已知键名
for (let i = 0; i < possibleKeys.length; i++) {
const key = possibleKeys[i]
const value = specsObj[key]
if (value != null && value.toString() != '') {
parts.push(`${key}: ${value.toString()}`)
}
}
// 如果已知键名没找到,尝试遍历对象的所有属性
if (parts.length === 0) {
// 使用 JSON.stringify 后正则匹配键值对
const keyValueRegex = /"([^"]+)":\s*"([^"]+)"/g
let match: RegExpExecArray | null = null
while (true) {
match = keyValueRegex.exec(specsStr)
if (match == null) break
const key = match[1]
const value = match[2]
if (key != null && value != null && value != '') {
parts.push(`${key}: ${value}`)
}
}
}
if (parts.length === 0) return ''
return parts.join('; ')
} catch (e) {
return ''
}
}
// 选择配送方式
const selectDelivery = (option: DeliveryOptionType) => {
selectedDelivery.value = option.id
}
// 选择优惠券
const selectCoupon = () => {
uni.navigateTo({
url: '/pages/mall/consumer/coupons',
success: (res: any) => {
// 移除事件通道相关代码,避免使用不支持的 API
// 注释掉事件通道逻辑,因为当前环境不支持 createEventChannel
// const eventChannel = res.eventChannel || uni.createEventChannel()
// if (eventChannel && eventChannel.emit) {
// eventChannel.emit('setSelectMode', { selectMode: true })
// }
}
})
uni.$on('couponSelected', (coupon: any) => {
selectedCoupon.value = coupon as UserCouponType
uni.$off('couponSelected')
})
}
// 提交订单
const submitOrder = async () => {
if (selectedAddress.value == null) {
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 == null || userId == '') {
uni.hideLoading()
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
console.log('[submitOrder] 开始创建订单, userId:', userId)
console.log('[submitOrder] shopGroups数量:', shopGroups.value.length)
const groups: any[] = []
for (let i = 0; i < shopGroups.value.length; i++) {
const group = shopGroups.value[i]
console.log(`[submitOrder] 处理店铺组 ${i}:`, {
shopId: group.shopId,
shopName: group.shopName,
merchant_id: group.merchant_id,
itemsCount: group.items.length
})
const items: any[] = []
for (let j = 0; j < group.items.length; j++) {
const item = group.items[j]
items.push({
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 finalMerchantId = (group.merchant_id != null && group.merchant_id != '') ? group.merchant_id : group.shopId
console.log(`[submitOrder] 店铺组 ${i} 最终使用的 merchant_id:`, finalMerchantId)
groups.push({
merchant_id: finalMerchantId,
shopId: group.shopId,
shopName: group.shopName,
items: items
})
}
console.log('[submitOrder] 准备传递的 groups 数量:', groups.length)
const result = await supabaseService.createOrdersByShop({
shipping_address: selectedAddress.value !== null ? toUTSJSONObject(selectedAddress.value!) : new UTSJSONObject(),
shopGroups: groups,
deliveryFee: deliveryFee.value,
discountAmount: discountAmount.value
})
uni.hideLoading()
console.log('[submitOrder] 创建结果 success:', result.success)
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 {
const errMsg = (result.error != null && result.error !== '') ? result.error : '创建订单失败'
console.error('[submitOrder] 订单创建失败:', errMsg)
uni.showToast({ title: errMsg, icon: 'none' })
}
} catch (err: any) {
uni.hideLoading()
console.error('[submitOrder] 提交订单错误:', err)
const errMsg = (err.message != null && err.message !== '') ? (err.message as string) : '提交订单失败'
uni.showToast({ title: errMsg, 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()
}
// 选择地址
const selectAddress = () => {
showAddressPopup.value = true;
}
// 添加登录跳转方法
const goToLogin = () => {
uni.navigateTo({
url: '/pages/login/login' // 根据实际登录页面路径调整
})
}
</script>
<style scoped>
.checkout-page {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #f8f8f8;
overflow: hidden;
align-items: center; /* PC端居中显示 */
}
/* 顶部栏 */
.checkout-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
width: 100%;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
text-align: center;
}
.checkout-content {
flex: 1;
width: 100%;
max-width: 800px; /* 限制PC端内容宽度 */
min-height: 0;
background-color: #f8f8f8;
}
/* 卡片容器 */
.no-products {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px 0;
}
.no-products-text { font-size: 14px; color: #999999; }
.section-card {
background-color: #ffffff;
margin: 12px;
padding: 18px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
/* 自适应适配 */
@media screen and (min-width: 768px) {
.section-card {
margin: 16px 0;
}
.delivery-options-grid {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
}
.delivery-card {
flex: 1;
min-width: 280px;
margin-bottom: 0 !important;
}
/* 底部结算栏在大屏居中并限宽 */
.footer-action-bar {
max-width: 800px;
left: 50% !important;
right: auto !important;
transform: translateX(-50%);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 16px rgba(0,0,0,0.05);
}
}
.address-section {
margin-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
padding: 16px;
position: relative;
background-color: #ffffff;
}
.address-icon-wrapper {
margin-right: 12px;
display: flex;
align-items: center;
}
.location-icon {
font-size: 24px;
color: #ff5000;
}
.address-info {
flex: 1;
display: flex;
flex-direction: column;
}
.address-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 4px;
}
.recipient { font-size: 17px; font-weight: bold; color: #333333; margin-right: 12px; }
.phone { font-size: 14px; color: #666666; margin-right: 8px; }
.default-tag { background-color: #fff0eb; border: 0.5px solid #ff5000; padding: 0 6px; border-radius: 4px; }
.tag-text { color: #ff5000; font-size: 11px; }
.address-detail {
font-size: 13px;
color: #666;
line-height: 1.5;
lines: 2;
text-overflow: ellipsis;
overflow: hidden;
}
.address-arrow-wrapper {
margin-left: 8px;
}
.address-arrow {
color: #ccc;
font-size: 20px;
}
.no-address { flex: 1; display: flex; align-items: center; }
.no-address-text { font-size: 16px; color: #999999; }
.products-section { padding: 0; }
.debug-info { padding: 10px 15px; border-bottom: 1px solid #f5f5f5; margin-bottom: 10px; }
.debug-text { font-size: 12px; color: #999; text-align: center; }
.shop-group { background-color: #fff; padding: 0; }
.shop-header { display: flex; flex-direction: row; align-items: center; padding: 5px 0 12px; }
.shop-icon { font-size: 17px; margin-right: 6px; }
.shop-name { font-size: 15px; font-weight: bold; color: #333; }
.shop-subtotal { display: flex; justify-content: flex-end; align-items: center; padding: 12px 0 5px; margin-top: 5px; border-top: 1px dashed #f0f0f0; }
.subtotal-label { color: #888; margin-right: 8px; font-size: 13px; }
.subtotal-value { color: #666; font-size: 13px; }
.subtotal-text { color: #333; margin-right: 5px; font-size: 14px; }
.subtotal-price { color: #ff5000; font-weight: bold; font-size: 16px; }
.product-item { display: flex; flex-direction: row; padding: 12px 0; }
.product-image { width: 85px; height: 85px; border-radius: 8px; margin-right: 12px; background-color: #f8f8f8; }
.product-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.product-name-row { display: flex; flex-direction: row; justify-content: space-between; align-items: flex-start; }
.product-name { flex: 1; font-size: 14px; color: #333333; line-height: 1.4; lines: 2; text-overflow: ellipsis; overflow: hidden; margin-right: 12px; }
.product-price { font-size: 15px; color: #333; font-weight: normal; }
.product-spec-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.product-spec { font-size: 12px; color: #999999; background-color: #f7f7f7; padding: 2px 6px; border-radius: 4px; }
.product-quantity { font-size: 12px; color: #999999; }
/* 配送方式重构 */
.delivery-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.delivery-selector {
display: flex;
flex-direction: row;
gap: 8px;
}
.delivery-pill {
padding: 4px 12px;
background-color: #f5f5f5;
border-radius: 16px;
border: 1px solid transparent;
}
.delivery-pill.selected {
background-color: #fff9f6;
border-color: #ff5000;
}
.pill-name {
font-size: 12px;
color: #666;
}
.delivery-pill.selected .pill-name {
color: #ff5000;
}
.delivery-detail {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: 8px;
border-top: 0.5px solid #f9f9f9;
}
.detail-desc {
font-size: 11px;
color: #999;
}
.detail-price {
font-size: 11px;
color: #ff5000;
}
/* 优惠券重构 */
.coupon-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.coupon-right-content {
display: flex;
flex-direction: row;
align-items: center;
}
/* 留言重构 */
.remark-row {
display: flex;
flex-direction: row;
align-items: center;
}
.remark-input-compact {
flex: 1;
margin-left: 15px;
font-size: 14px;
color: #333;
height: 30px;
}
/* 价格明细横向排列 */
.price-grid {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.price-item-inline {
display: flex;
flex-direction: row;
align-items: baseline;
}
.price-item-label {
font-size: 12px;
color: #999;
margin-right: 4px;
}
.price-item-value {
font-size: 14px;
color: #333;
font-weight: 500;
}
.item-subtotal-row {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: 4px;
}
.item-subtotal-label {
font-size: 12px;
color: #999;
margin-right: 4px;
}
.item-subtotal-price {
font-size: 14px;
color: #ff5000;
font-weight: bold;
}
/* 配送方式网格 */
.delivery-options-grid {
display: flex;
flex-direction: column;
}
.delivery-card {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 15px;
border: 1px solid #f0f0f0;
border-radius: 10px;
margin-bottom: 10px;
background-color: #fafafa;
}
.delivery-card.selected {
border-color: #ff5000;
background-color: #fff9f6;
}
.option-main {
flex: 1;
}
.option-name {
font-size: 15px;
color: #333;
font-weight: 500;
margin-bottom: 4px;
display: block;
}
.option-desc {
font-size: 12px;
color: #999;
display: block;
}
.option-side {
display: flex;
align-items: center;
}
.option-price {
font-size: 14px;
color: #333;
margin-right: 10px;
}
.select-icon {
width: 18px;
height: 18px;
background-color: #ff5000;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.check-mark {
color: #ffffff;
font-size: 12px;
margin-top: -1px;
}
/* 优惠券样式重构 */
.coupon-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.coupon-left {
display: flex;
align-items: center;
}
.coupon-tag {
background-color: #ff5000;
color: #fff;
font-size: 10px;
padding: 1px 4px;
border-radius: 2px;
margin-left: 8px;
}
.coupon-right {
display: flex;
align-items: center;
}
.coupon-selected-name {
font-size: 14px;
color: #ff5000;
}
.coupon-placeholder {
font-size: 14px;
color: #999;
}
.arrow-icon {
font-size: 18px;
color: #ccc;
margin-left: 5px;
}
/* 留言输入 */
.remark-input-new {
width: 100%;
background-color: #f9f9f9;
border-radius: 8px;
padding: 12px;
font-size: 14px;
min-height: 48px;
}
/* 价格明细列表 */
.price-list {
margin-top: 5px;
}
.price-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
}
.price-item-label {
font-size: 14px;
color: #666;
}
.price-item-value {
font-size: 14px;
color: #333;
}
.discount-text {
color: #ff5000;
}
.section-title { font-size: 15px; font-weight: bold; color: #333333; }
/* 底部操作栏 */
.footer-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background-color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 16px;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #f0f0f0;
z-index: 100;
}
.footer-left {
display: flex;
flex-direction: row;
align-items: baseline;
}
.footer-total-label {
font-size: 14px;
color: #333;
margin-right: 4px;
}
.footer-currency {
font-size: 14px;
color: #ff5000;
font-weight: bold;
}
.footer-price {
font-size: 22px;
color: #ff5000;
font-weight: bold;
}
.footer-submit-btn {
background-color: #ff5000;
color: #ffffff;
padding: 0 28px;
height: 42px;
line-height: 42px;
border-radius: 21px;
font-size: 16px;
font-weight: bold;
border: none;
margin: 0;
}
.safe-area-bottom {
height: 100px; /* 留出底部操作栏和安全区的空间 */
width: 100%;
}
/* 弹窗样式 */
.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; z-index: 10000; }
.address-popup {
background-color: #ffffff;
width: 100%;
height: 450px;
border-radius: 20px 20px 0 0;
display: flex;
flex-direction: column;
position: relative;
z-index: 9999;
}
.address-form-popup {
background-color: #f8f8f8;
width: 92%;
max-width: 500px;
height: 600px;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
z-index: 10001;
}
.popup-header {
padding: 16px;
border-bottom: 0.5px solid #eee;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
}
.popup-title { font-size: 17px; font-weight: bold; color: #333333; }
.popup-close { position: absolute; right: 16px; font-size: 20px; color: #999999; padding: 4px; }
.address-list-container {
flex: 1;
width: 100%;
padding: 12px;
box-sizing: border-box;
}
.popup-address-item {
padding: 16px;
margin-bottom: 12px;
background-color: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
position: relative;
}
.popup-address-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.popup-address-name { font-size: 15px; font-weight: bold; color: #333333; margin-right: 12px; }
.popup-address-phone { font-size: 14px; color: #666666; margin-right: 8px; }
.popup-default-tag { background-color: #fff0eb; padding: 1px 6px; border-radius: 4px; }
.popup-tag-text { color: #ff5000; font-size: 10px; }
.popup-address-detail { font-size: 13px; color: #666; line-height: 1.4; }
.popup-selected-indicator {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: #ff5000;
font-weight: bold;
font-size: 18px;
}
.popup-empty-address { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; }
.popup-empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.3; }
.popup-empty-text { font-size: 14px; color: #999; }
.popup-add-address-btn {
background-color: #ff5000;
margin: 12px 16px 30px;
height: 44px;
border-radius: 22px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.popup-btn-icon { color: #ffffff; font-size: 20px; margin-right: 6px; font-weight: normal; }
.popup-btn-text { color: #ffffff; font-size: 15px; font-weight: bold; }
.address-form-popup {
background-color: #f8f8f8;
width: 92%;
max-width: 500px;
height: 600px; /* 改用具体的像素高度Android 端的 scroll-view 计算更稳健 */
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
z-index: 10001;
}
.form-header {
padding: 16px;
background-color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
position: relative;
border-bottom: 0.5px solid #eee;
flex-shrink: 0;
}
.form-title { font-size: 17px; font-weight: bold; color: #333333; }
.form-close-btn { position: absolute; right: 12px; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; }
.form-close-icon { font-size: 18px; color: #999; }
.form-content {
flex: 1;
width: 100%;
/* 在 Android 下scroll-view 如果不给明确的高度且处于 flex 容器,必须通过 flex-grow 撑开 */
flex-grow: 1;
flex-shrink: 1;
background-color: #ffffff;
padding: 12px;
}
.form-section {
background-color: #ffffff;
border-radius: 12px;
padding: 0 12px;
margin-bottom: 12px;
}
.form-item {
padding: 16px 0;
border-bottom: 0.5px solid #f5f5f5;
}
.form-item:last-child {
border-bottom: none;
}
.form-label {
font-size: 14px;
color: #333;
margin-bottom: 10px;
display: flex;
}
.label-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.smart-tag {
font-size: 11px;
color: #ff5000;
background-color: #fff0eb;
padding: 2px 6px;
border-radius: 4px;
}
.form-input {
width: 100%;
height: 32px;
font-size: 15px;
color: #333;
}
.form-input-readonly { color: #888; }
.region-inputs {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.region-input { flex: 1; text-align: center; }
.form-textarea {
width: 100%;
background-color: #f9f9f9;
border-radius: 8px;
padding: 10px;
font-size: 14px;
color: #333;
box-sizing: border-box;
}
.smart-address-input { height: 80px; }
.detail-textarea { height: 60px; }
.checkbox-item {
background-color: #ffffff;
border-radius: 12px;
padding: 16px;
}
.checkbox-wrapper { display: flex; flex-direction: row; align-items: center; }
.checkbox { width: 18px; height: 18px; border: 1.5px solid #ddd; border-radius: 9px; margin-right: 10px; display: flex; align-items: center; justify-content: center; }
.checkbox.checked { background-color: #ff5000; border-color: #ff5000; }
.checkbox-check { color: #ffffff; font-size: 12px; }
.checkbox-label { font-size: 14px; color: #333; }
.form-buttons {
padding: 12px 16px 30px;
background-color: #ffffff;
flex-shrink: 0;
}
.form-submit-btn {
width: 100%;
background-color: #ff5000;
color: #ffffff;
height: 44px;
line-height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: bold;
border: none;
}
/* 确认保存弹窗 */
.confirm-popup-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 9998; display: flex; align-items: center; justify-content: center; }
.confirm-popup { background-color: #ffffff; width: 80%; max-width: 320px; border-radius: 12px; overflow: hidden; }
.confirm-header { padding: 24px 0 12px; text-align: center; }
.confirm-title { font-size: 17px; font-weight: bold; color: #333; }
.confirm-content { padding: 0 24px 24px; text-align: center; }
.confirm-message { font-size: 14px; color: #666; line-height: 1.5; }
.confirm-buttons { display: flex; flex-direction: row; border-top: 0.5px solid #eee; }
.confirm-btn { flex: 1; height: 48px; line-height: 48px; text-align: center; font-size: 16px; background-color: #ffffff; border: none; border-radius: 0; }
.confirm-btn.cancel { color: #666; border-right: 0.5px solid #eee; }
.confirm-btn.confirm { color: #ff5000; font-weight: bold; }
</style>