Files
cheflinkuser/src/pages-store/pages/order/checkout.vue
T
2026-06-05 15:03:32 +08:00

3292 lines
97 KiB
Vue
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.
<script setup lang="ts">
import { useConfigStore, useUserStore } from "@/store";
import { dayjs } from "@/plugin/index";
import Config from '@/config/index'
import ChooseImage from '@/components/choose-image/choose-image.vue'
import CheckoutSkeleton from "./components/checkout-skeleton.vue";
import ChangePhone from "./components/change-phone.vue";
import PriceDetail from "./components/price-detail.vue";
import VisitMethod from "@/components/visit-method/index.vue";
import {ref} from "vue";
import {
appMerchantCartListByMerchantIdPost,
appMerchantCartCalculateSavingsPost,
appMerchantDetailMerchantIdGet,
appUserAddressListPost,
type MerchantCartVo,
type MerchantVo,
appMerchantOrderCalculatePriceCartPost,
appUserCardSelectDefaultPost, appMerchantOrderCreateOrderCartPost, appMerchantOrderPayOrderPost,
appMerchantOrderCreateOrderCartBatchPost,
appMerchantOrderCalculatePriceCartBatchPost,
appMerchantCartListMerchantPost,
appMerchantOrderPayOrderBatchPost,
appMerchantOrderZipPayVoucherPost
} from "@/service";
import useEventEmit from "@/hooks/useEventEmit";
import {EventEnum} from "@/constant/enums";
import { getDistanceInMiles, parseMerchantCartPayload } from "@/utils/utils";
import {
buildReservationTimeUrl,
buildDayAppointmentSlot,
findNearestDeliveryScheduleDate,
loadCheckoutMerchantAppointments,
type MerchantAppointmentSlot,
} from "@/utils/deliverySchedule";
const { t } = useI18n();
const configStore = useConfigStore();
const userStore = useUserStore();
const appDisplayName = computed(() => {
const name = String((Config as any)?.appName ?? "").trim();
return name || "CHEFLINK";
});
// 送达偏好
const visitMethodRef = ref();
// value 与 visit-method 组件保持一致:0-亲自送达 1-放门口
const visitMethod = ref({
value: 1,
label: t("components.visit.putItAtTheDoor"),
});
function chooseVisitMethod() {
visitMethodRef.value?.onOpen(visitMethod.value.value);
}
function handleVisitMethodConfirm(data: { value: number; label: string }) {
visitMethod.value = data;
}
// 联系方式(这里默认值取用户信息的手机号区号)
const contact = ref({
phone: userStore.userInfo.phone,
areaCode: "+1",
});
const changePhoneRef = ref<InstanceType<typeof ChangePhone>>();
function openChangePhone() {
if (changePhoneRef.value) {
changePhoneRef.value.onOpen(contact.value);
}
}
function confirmPhone(data: { phone: string; areaCode: string }) {
contact.value = data;
formData.value.phone = data.phone;
}
// 配送时间 1 即刻配送 2 预约配送
// 当前业务仅支持预约配送,默认值设置为 2
const deliveryTimeType = ref(2);
// 配送时间配置
const deliveryMinutes = ref(0); // 预计送达时间(分钟)
// 自取时间配置
const selfPickupMinutes = ref(0); // 自取时间(分钟)
const deliveryWindowMinutes = ref(30); // 配送时间窗口(分钟)
// 预约配送日期配送
const userSelectedDeliveryTimeDate = ref(null);
// 预约自取时间
const userSelectedSelfPickupTimeDate = ref(null);
// 显示用的配送时间(即刻配送=当前时间加预计时间)(预约配送=预约时间+预计时间分钟)
const showDeliveryTime = computed(() => {
// 配送
if (deliveryTimeType.value === 1) {
// 即刻配送:当前时间 + 预计送达时间
let time = 0
if(deliveryMethod.value === 0) {
time = deliveryMinutes.value
} else {
time = selfPickupMinutes.value
}
const deliveryStart = dayjs().add(Number(time), "minute");
const deliveryEnd = deliveryStart.add(
deliveryWindowMinutes.value,
"minute"
);
return `${deliveryStart.format("HH:mm")}-${deliveryEnd.format("HH:mm")}`;
}
if (deliveryTimeType.value === 2) {
return deliveryMethod.value === 0 ? userSelectedDeliveryTimeDate : '123';
}
return "";
});
const merchantAppointmentMap = ref<Record<string, MerchantAppointmentSlot>>({});
const merchantAppointmentDisplay = ref<Record<string, string>>({});
const selectingMerchantId = ref<string | null>(null);
function ensureDefaultMerchantAppointment(merchant: {
id?: string | number;
deliveryScheduleTimes?: string;
}) {
const key = String(merchant.id ?? "");
if (!key || merchantAppointmentMap.value[key]?.startTime) return;
const nearest = findNearestDeliveryScheduleDate(
merchant.deliveryScheduleTimes
);
if (!nearest) return;
const slot = buildDayAppointmentSlot(nearest);
merchantAppointmentMap.value[key] = slot;
syncAppointmentDisplay(key, { date: nearest, timeSlot: "" });
}
function syncAppointmentDisplay(merchantId: string, data: any) {
const label =
dayjs(data.date).format("MM-DD") +
(data.timeSlot ? ` ${data.timeSlot}` : "");
merchantAppointmentDisplay.value[merchantId] = label;
if (deliveryMethod.value === 0) {
userSelectedDeliveryTimeDate.value = label;
} else {
userSelectedSelfPickupTimeDate.value = label;
}
}
async function openReservationForMerchant(merchant: {
id?: string | number;
businessHours?: string;
deliveryScheduleTimes?: string;
}) {
if (!merchant?.id) return;
selectingMerchantId.value = String(merchant.id);
let schedule = merchant.deliveryScheduleTimes;
let hours = merchant.businessHours;
if (!schedule || !hours) {
try {
const res: any = await appMerchantDetailMerchantIdGet({
params: { merchantId: merchant.id as any },
});
schedule = res.data?.deliveryScheduleTimes ?? schedule;
hours = res.data?.businessHours ?? hours;
const mid = String(merchant.id);
const found = cartDataList.value.find(
(m: any) => String(m.id) === mid
) as any;
if (found) {
found.deliveryScheduleTimes = schedule;
found.businessHours = hours;
}
if (String(storeId.value) === mid) {
storeDetail.value = {
...storeDetail.value,
deliveryScheduleTimes: schedule,
businessHours: hours,
} as MerchantVo;
}
} catch {
/* ignore */
}
}
uni.navigateTo({
url: buildReservationTimeUrl({
merchantId: merchant.id,
deliveryScheduleTimes: schedule,
businessHours: hours,
}),
});
}
// 切换配送时间点击事件(目前仅支持预约配送)
function toggleDeliveryTimeType(
_type: number,
merchant?: { id?: string | number; businessHours?: string; deliveryScheduleTimes?: string }
) {
deliveryTimeType.value = 2;
if (orderType.value === "batch" && merchant) {
void openReservationForMerchant(merchant);
return;
}
void openReservationForMerchant({
id: storeId.value,
businessHours: (storeDetail.value as any)?.businessHours,
deliveryScheduleTimes: storeDetail.value?.deliveryScheduleTimes,
});
}
function getMerchantPillText(merchantId: string | number) {
const key = String(merchantId);
if (merchantAppointmentDisplay.value[key]) {
return `${merchantAppointmentDisplay.value[key]} >`;
}
const ap = merchantAppointmentMap.value[key];
if (ap?.startTime) {
const d = dayjs(Number(ap.startTime));
if (d.isValid()) {
return `${d.format("dddd")}, ${d.format("MM/DD")} >`;
}
}
return t("pages-store.checkout.chooseTime");
}
const diyTime = ref<MerchantAppointmentSlot | Record<string, never>>({});
useEventEmit(EventEnum.CHOOSE_APPOINTMENT_TIME, (data: any) => {
if (!data?.startTime || !data?.endTime) return;
const merchantId = String(
data.merchantId || selectingMerchantId.value || storeId.value || ""
);
if (!merchantId) return;
merchantAppointmentMap.value[merchantId] = {
date: data.date,
timeSlot: data.timeSlot,
startTime: data.startTime,
endTime: data.endTime,
};
diyTime.value = merchantAppointmentMap.value[merchantId];
deliveryTimeType.value = 2;
syncAppointmentDisplay(merchantId, data);
selectingMerchantId.value = null;
});
// 设置配送时间(分钟)
const setDeliveryMinutes = (minutes: number) => {
deliveryMinutes.value = minutes;
};
// 设置配送时间窗口(分钟)
const setDeliveryWindowMinutes = (minutes: number) => {
deliveryWindowMinutes.value = minutes;
};
const deliveryMethod = ref(0);
// 配送方式
const showDeliverySwitch = ref(false)
const deliveryMethodOptions = [
t("pages-store.store.delivery"),
t("pages-store.store.pickup"),
];
function handleClickSegmented(index: number) {
console.log("切换配送方式:", index);
if(+storeDetail.value.deliveryService !== 1 && index === 0) {
uni.showToast({
title: t('pages-store.store.toast.deliveryService'),
icon: 'none'
})
return
}
if(+storeDetail.value.selfPickup !== 1 && index === 1) {
uni.showToast({
title: t('pages-store.store.toast.selfPickup'),
icon: 'none'
})
return
}
if(index !== deliveryMethod.value) {
deliveryMethod.value = index
// 重置配送类型为预约配送
deliveryTimeType.value = 2
void appMerchantOrderCalculatePriceCart()
}
}
// 折叠面板
const collapseValue = ref([""]);
// 单个订单的小费(与设计稿一致:默认 $2)
const selectedTipIndex = ref(2);
const diyTipValue = ref('')
// 批量订单:每个店铺的小费索引映射 merchantId -> tipIndex
const merchantTipIndexMap = ref<Record<string, number>>({})
// 批量订单:每个店铺的自定义小费值映射 merchantId -> diyTipValue
const merchantDiyTipValueMap = ref<Record<string, string>>({})
const tipOptions = ref([
{ label: "$ 2", value: 2 },
{ label: "$ 3", value: 3 },
{ label: "$ 4", value: 4 },
]);
// 单个订单的小费选择
function selectedTipChange(item: any) {
if(item.value === selectedTipIndex.value) return
diyTipValue.value = ''
selectedTipIndex.value = item.value
void appMerchantOrderCalculatePriceCart()
}
// 批量订单:选择某个店铺的小费
function selectedTipChangeForMerchant(merchantId: string, item: any) {
if(item.value === merchantTipIndexMap.value[merchantId]) return
merchantDiyTipValueMap.value[merchantId] = ''
merchantTipIndexMap.value[merchantId] = item.value
void appMerchantOrderCalculatePriceCart()
}
// 单个订单的自定义小费确认(金额,单位:美元)
function handleConfirmTip() {
// 如果未填写其他小费金额,失去焦点时默认填充为 0
const val = String(diyTipValue.value || '').trim()
if (!val) {
diyTipValue.value = '0'
}
// 使用自定义小费时,选中"其他"这一项
if (selectedTipIndex.value !== 0) {
selectedTipIndex.value = 0
}
void appMerchantOrderCalculatePriceCart()
}
// 批量订单:确认某个店铺的自定义小费(金额,单位:美元)
function handleConfirmTipForMerchant(merchantId: string) {
const val = String(merchantDiyTipValueMap.value[merchantId] || '').trim()
if (!val) {
merchantDiyTipValueMap.value[merchantId] = '0'
}
// 使用自定义小费时,选中"其他"这一项
if (merchantTipIndexMap.value[merchantId] !== 0) {
merchantTipIndexMap.value[merchantId] = 0
}
void appMerchantOrderCalculatePriceCart()
}
// 是否选择了配送周卡
const isWeeklyDelivery = ref(false);
// 切换配送周卡
const toggleWeeklyDelivery = (value: boolean) => {
if(value === isWeeklyDelivery.value) return
isWeeklyDelivery.value = value;
void appMerchantOrderCalculatePriceCart()
};
// 价格明细
const priceDetailRef = ref<InstanceType<typeof PriceDetail>>();
// 打开价格明细
const openPriceDetail = () => {
priceDetailRef.value?.onOpen(priceData.value, cartSavingsData.value?.savings || 0);
};
function navigateTo(url: string) {
uni.navigateTo({ url })
}
function chooseCardForCheckout() {
payMethodSelected.value = 1
uni.navigateTo({
url: '/pages-user/pages/select-credit-card/index',
events: {
selectedCard: function(data: any) {
if (data) {
payMethodOptions.value.cardId = data.cardId || ''
payMethodOptions.value.cardNumber = data.cardNumber || ''
}
},
},
})
}
// 支付参数
const payMethodOptions = ref({
orderId: '',
cardId: '',
cardNumber: '',
payMethod: 1, // 支付方式 1信用卡 2余额
payPassword: '',
})
const payMethodPopupVisible = ref(false)
const payMethodSelected = ref(1)
const zellePopupVisible = ref(false)
const zelleOrderIdForVoucher = ref('')
const voucherChooseRef = ref<InstanceType<typeof ChooseImage>>()
const voucherSubmitting = ref(false)
const zellePayPath = Config.zellePayPath;
function openPayMethodPopup() {
payMethodSelected.value = Number(payMethodOptions.value.payMethod || 1)
payMethodPopupVisible.value = true
}
function confirmPayMethodForSettle() {
payMethodOptions.value.payMethod = Number(payMethodSelected.value || 1)
payMethodPopupVisible.value = false
handleGoSettle()
}
function openUploadVoucher() {
if (!zelleOrderIdForVoucher.value) {
uni.showToast({ title: t('pages-store.order.voucherUploadFailed'), icon: 'none' })
return
}
voucherChooseRef.value?.init()
}
function normalizeVoucherUrl(payload: unknown): string {
if (Array.isArray(payload)) {
const first = payload[0]
return typeof first === 'string' ? first : ''
}
return typeof payload === 'string' ? payload : ''
}
async function onVoucherImageUploaded(urls: unknown) {
const zipPayVoucher = normalizeVoucherUrl(urls)
if (!zipPayVoucher || !zelleOrderIdForVoucher.value) {
uni.showToast({ title: t('pages-store.order.voucherUploadFailed'), icon: 'none' })
return
}
if (voucherSubmitting.value) return
voucherSubmitting.value = true
try {
await appMerchantOrderZipPayVoucherPost({
body: {
orderId: zelleOrderIdForVoucher.value,
zipPayVoucher,
}
})
uni.showToast({ title: t('pages-store.order.voucherSubmitSuccess'), icon: 'none' })
zellePopupVisible.value = false
setTimeout(() => {
if (zelleOrderIdForVoucher.value) {
uni.reLaunch({
url: `/pages-store/pages/order/index?id=${zelleOrderIdForVoucher.value}`
})
}
}, 500)
} catch {
uni.showToast({ title: t('pages-store.order.voucherSubmitFailed'), icon: 'none' })
} finally {
voucherSubmitting.value = false
}
}
useEventEmit(EventEnum.CHOOSE_PAYMENT_METHOD, (data) => {
if(data) {
if(data.payMethod === 1) {
payMethodOptions.value.cardId = data.cardId
payMethodOptions.value.cardNumber = data.cardNumber
payMethodOptions.value.payMethod = 1
} else {
payMethodOptions.value.payMethod = 2
}
}
})
const storeId = ref('')
const orderType = ref('') // 订单类型:batch-批量下单,normal-普通下单
const batchCartIds = ref([]) // 批量下单的购物车ID列表
const formData = ref<CreateOrderCartBo>({
orderRemark: '',
phone: '',
receiveMethod: 0,
startScheduledTime: 0,
endScheduledTime: 0,
tipDiscount: 0,
weeklyDeliveryFee: 0,
})
// 是否需要餐具
const needTableware = ref(false)
async function safeAwait<T>(label: string, task: () => Promise<T>): Promise<T | undefined> {
try {
return await task()
} catch (e) {
console.error(`[checkout] ${label} failed`, e)
return undefined
}
}
onLoad(async (options: any)=> {
loading.value = true
try {
// 判断是批量下单还是普通下单
if(options.type == 'batch' && options.cartIds) {
// 批量下单模式
orderType.value = 'batch'
batchCartIds.value = options.cartIds.split(',')
console.log("下单类型",options.type);
// 默认取用户信息中的手机号作为收货手机号
formData.value.phone = userStore.userInfo.phone || ''
contact.value.areaCode = userStore.userInfo.areaCode || ''
// 严格按顺序执行
await safeAwait('getAddressList', getAddressList)
await safeAwait('getBatchCartInfo', getBatchCartInfo)
merchantAppointmentMap.value = loadCheckoutMerchantAppointments()
cartDataList.value.forEach((m: any) => {
const key = String(m.id);
const ap = merchantAppointmentMap.value[key];
if (ap?.startTime) {
syncAppointmentDisplay(key, {
date: ap.date ? new Date(ap.date as any) : new Date(Number(ap.startTime)),
timeSlot: ap.timeSlot,
});
} else {
ensureDefaultMerchantAppointment(m);
}
});
await safeAwait('appUserCardSelectDefault', appUserCardSelectDefault)
} else if(options.storeId) {
// 普通下单模式
orderType.value = 'normal'
storeId.value = options.storeId as string
formData.value.orderRemark = options.orderRemark as string
needTableware.value = options.needTableware === 'true'
// 默认取用户信息中的手机号作为收货手机号
formData.value.phone = userStore.userInfo.phone || ''
contact.value.areaCode = userStore.userInfo.areaCode || ''
// 严格按顺序执行
await safeAwait('getAddressList', getAddressList)
await safeAwait('getCartInfo', getCartInfo)
await safeAwait('getStoreDetail', getStoreDetail)
await safeAwait('appUserCardSelectDefault', appUserCardSelectDefault)
}
} finally {
loading.value = false
}
})
onShow(async ()=> {
// 刷新地址,并在必要时重新计算价格
await safeAwait('getAddressList', getAddressList)
const hasCart =
orderType.value === 'batch'
? (batchCartIds.value?.length || 0) > 0
: (cartDataList.value?.length || 0) > 0
if (hasCart) {
await safeAwait('appMerchantOrderCalculatePriceCart', appMerchantOrderCalculatePriceCart)
}
})
// 页面加载状态
const loading = ref(true);
onMounted(() => {
userStore.getUserInfo()
const currentHour = dayjs().hour();
if (currentHour >= 11 && currentHour <= 14) {
setDeliveryMinutes(60);
} else if (currentHour >= 17 && currentHour <= 20) {
// 晚餐高峰期,配送时间延长
setDeliveryMinutes(50);
} else {
// 非高峰期,正常配送时间
setDeliveryMinutes(40);
}
});
const cartDataList = ref<MerchantCartVo[]>([])
async function getCartInfo() {
const res: any = await appMerchantCartListByMerchantIdPost({
params: {
merchantId: storeId.value,
}
})
console.log('购物车列表', res)
cartDataList.value = parseMerchantCartPayload(res?.data).items as MerchantCartVo[]
// 购物车有菜品,查询菜品会员折扣价 + 计算价格(严格串行)
if(cartDataList.value.length > 0) {
await appMerchantCartCalculateSavings()
await appMerchantOrderCalculatePriceCart()
}
}
// 批量下单:查询购物车详情
async function getBatchCartInfo() {
try {
// 根据购物车ID列表,按店铺分组查询
const cartIds = batchCartIds.value
console.log('批量购物车ID列表', cartIds)
// 1. 先获取所有店铺列表
const merchantRes = await appMerchantCartListMerchantPost({});
console.log('批量模式-购物车店铺列表', merchantRes);
if (!merchantRes.data || merchantRes.data.length === 0) {
cartDataList.value = [];
return;
}
// 2. 对每个店铺查询完整的商品信息
const merchantPromises = merchantRes.data.map(async (merchant: any) => {
try {
const cartRes = await appMerchantCartListByMerchantIdPost({
params: {
merchantId: merchant.id
}
});
console.log(`批量模式-店铺 ${merchant.merchantName} 的商品列表`, cartRes);
const payload = parseMerchantCartPayload(cartRes?.data);
const filteredItems = payload.items.filter((item: any) =>
cartIds.includes(String(item.id))
);
// 如果该店铺有选中的商品,才返回
if (filteredItems.length > 0) {
return {
...merchant,
deliveryScheduleTimes:
payload.deliveryScheduleTimes ?? merchant.deliveryScheduleTimes,
merchantCartVoList: filteredItems
};
}
return null;
} catch (error) {
console.error(`获取店铺 ${merchant.id} 商品失败`, error);
return null;
}
});
// 3. 等待所有店铺的商品信息加载完成,并过滤掉 null
const results = await Promise.all(merchantPromises);
cartDataList.value = results.filter(item => item !== null);
console.log('批量模式-最终购物车数据', cartDataList.value);
cartDataList.value.forEach((m: any) => {
const id = String(m.id);
if (merchantTipIndexMap.value[id] === undefined) {
merchantTipIndexMap.value[id] = 2;
}
ensureDefaultMerchantAppointment(m);
});
// 批量模式:查询菜品会员折扣价 + 计算价格(严格串行)
if(cartIds.length > 0) {
await appMerchantCartCalculateSavings()
await appMerchantOrderCalculatePriceCart()
}
} catch (error) {
console.error('获取批量购物车详情失败', error)
cartDataList.value = [];
}
}
// 查询菜品会员折扣价
const cartSavingsData = ref({})
async function appMerchantCartCalculateSavings() {
const cartIds = orderType.value == 'batch' ? batchCartIds.value : cartDataList.value.map(item => item.id)
const res: any = await appMerchantCartCalculateSavingsPost({
body: cartIds
})
console.log('菜品会员折扣价', res)
cartSavingsData.value = res.data
}
// 获取商家详情信息
// 商家是否支持配送
const storeIsDeliveryService = computed(()=> {
if(!storeDetail.value) return false
return +storeDetail.value.deliveryService === 1
})
// 商家是否支持自提
const storeIsSelfPickup = computed(()=> {
if(!storeDetail.value) return false
return +storeDetail.value.selfPickup === 1
})
const storeDetail = ref<MerchantVo>({})
// 用户距离商家的距离信息
const storeDistance = ref(null)
async function getStoreDetail() {
const res: any = await appMerchantDetailMerchantIdGet({
params: {
merchantId: storeId.value,
}
})
console.log('商家详情', res)
storeDetail.value = res.data as MerchantVo
// 配送时间(是否支持配送)
if(+storeDetail.value.deliveryService === 1) {
// 配送时间
deliveryMinutes.value = res.data.deliveryTime || 0
}
if(+storeDetail.value.selfPickup === 1) {
// 自提时间
selfPickupMinutes.value = res.data.pickupTime
}
// 商户的经纬度存在,并且用户的经纬度也存在
if(res.data.latitude && res.data.longitude && userStore.userLocation.latitude && userStore.userLocation.longitude) {
let distance = getDistanceInMiles(res.data.latitude, res.data.longitude, userStore.userLocation.latitude, userStore.userLocation.longitude)
console.log('距离商家距离', distance)
storeDistance.value = distance
}
// 判断配送和自取的开通状态
const hasDelivery = +storeDetail.value.deliveryService === 1
const hasPickup = +storeDetail.value.selfPickup === 1
if (!hasDelivery && !hasPickup) {
// 两个都没开通,不显示切换组件
showDeliverySwitch.value = false
} else if (!hasDelivery && hasPickup) {
// 只开通自取,默认选中自取
deliveryMethod.value = 1
showDeliverySwitch.value = true
} else if (hasDelivery && !hasPickup) {
// 只开通配送,默认选中配送
deliveryMethod.value = 0
showDeliverySwitch.value = true
} else {
// 两个都开通,默认选中配送
deliveryMethod.value = 0
showDeliverySwitch.value = true
}
ensureDefaultMerchantAppointment({
id: storeId.value,
deliveryScheduleTimes: storeDetail.value?.deliveryScheduleTimes,
});
}
const priceData = ref({})
async function appMerchantOrderCalculatePriceCart() {
// 优先使用新选择的地址id
const targetAddressId = selectedAddressId.value || currentAddressId.value
// if(!targetAddressId) return
const cartIds = orderType.value == 'batch' ? batchCartIds.value : cartDataList.value.map(item => item.id)
// 批量模式使用批量计算价格接口
if(orderType.value == 'batch') {
// 构建 merchantCouponMap: { merchantId: couponId }
const couponMap: Record<string, number> = {}
Object.keys(merchantCouponMap.value).forEach(merchantId => {
if(merchantCouponMap.value[merchantId]?.id) {
couponMap[merchantId] = merchantCouponMap.value[merchantId].id
}
})
// 构建 merchantTipMap: { merchantId: tipAmount } (小费金额,单位:美元)
const tipMap: Record<string, number> = {}
cartDataList.value.forEach((merchant: any) => {
const merchantId = String(merchant.id)
if(deliveryMethod.value === 1) {
// 自取订单不需要小费
tipMap[merchantId] = 0
} else {
// 配送订单需要小费
const diyTip = merchantDiyTipValueMap.value[merchantId]
const tipIndex = merchantTipIndexMap.value[merchantId] || 0
if(diyTip) {
// 自定义小费金额(美元)
tipMap[merchantId] = Number(diyTip) || 0
} else if(tipIndex > 0) {
// 选择固定金额选项
tipMap[merchantId] = tipIndex
} else {
tipMap[merchantId] = 0
}
}
})
const startMap: Record<string, number> = {}
const endMap: Record<string, number> = {}
cartDataList.value.forEach((m: any) => {
const ap = merchantAppointmentMap.value[String(m.id)]
if (ap?.startTime && ap?.endTime) {
startMap[String(m.id)] = ap.startTime
endMap[String(m.id)] = ap.endTime
}
})
const res: any = await appMerchantOrderCalculatePriceCartBatchPost({
body: {
addressId: targetAddressId,
cartIds: cartIds,
receiveMethod: deliveryMethod.value === 0 ? 1 : 2,
merchantCouponMap: couponMap,
merchantTipMap: tipMap,
merchantStartScheduledTimeMap: startMap,
merchantEndScheduledTimeMap: endMap,
phone: formData.value.phone,
areaCode: contact.value.areaCode,
}
})
console.log('批量购物车下单价格', res)
priceData.value = res.data
} else {
// 普通模式使用单店铺计算价格接口
// 计算小费金额(美元)
let tipAmount = 0
if(deliveryMethod.value === 0) {
// 配送订单需要小费
if(diyTipValue.value) {
tipAmount = Number(diyTipValue.value) || 0
} else if(selectedTipIndex.value > 0) {
tipAmount = selectedTipIndex.value
}
}
const res: any = await appMerchantOrderCalculatePriceCartPost({
body: {
addressId: targetAddressId,
cartIds: cartIds,
receiveMethod: deliveryMethod.value === 0 ? 1 : 2,
couponId: couponInfo.value ? couponInfo.value.id : '',
tipDiscount: tipAmount, // 小费金额(美元),自取订单不需要小费
// weeklyDeliveryFee: isWeeklyDelivery.value ? 1 : 2, // 是否支付周配送费(1-是 2-否)
phone: formData.value.phone,
areaCode: contact.value.areaCode,
}
})
console.log('购物车下单价格', res)
priceData.value = res.data
}
}
// 获取用户地址列表
const addressesList = ref([])
// 当前选中的地址id
const currentAddressId = ref('')
// 新选择的地址id(优先使用)
const selectedAddressId = ref('')
// 当前选中的地址信息
const addressInfo = computed(()=> {
if(addressesList.value.length === 0) return {}
// 优先使用新选择的地址id,如果没有则使用默认的地址id
const targetId = selectedAddressId.value || currentAddressId.value
return addressesList.value.find(item => String(item.id) === String(targetId)) || {};
});
async function getAddressList() {
const res: any = await appUserAddressListPost({
params: {
pageNum: 1,
pageSize: 100,
}
})
console.log('获取用户地址列表', res)
addressesList.value = res.rows
if(addressesList.value.length > 0) {
currentAddressId.value = addressesList.value[0].id
// 结算页配送偏好默认「放门口」,不按地址簿 deliveryType 回显
}
}
function chooseAddress() {
// 优先使用新选择的地址id
const targetAddressId = selectedAddressId.value || currentAddressId.value
uni.navigateTo({
url: '/pages-store/pages/order/choose-address?id=' + targetAddressId,
events: {
// 为指定事件添加一个监听器,获取被打开页面传送到当前页面的数据
acceptDataFromOpenedPage: function(data) {
console.log('获取被打开页面传送到当前页面的数据', data)
// 将新选择的地址id存储到selectedAddressId中
selectedAddressId.value = data.data
// 配送偏好默认「放门口」,切换地址时保持用户在本页已选或默认值,不回写地址簿
// 重新计算价格
void appMerchantOrderCalculatePriceCart()
},
},
})
}
function appUserCardSelectDefault() {
return appUserCardSelectDefaultPost({}).then((res: any)=> {
console.log('查询用户默认信用卡', res)
payMethodOptions.value.cardId = res.data?.cardId || ''
payMethodOptions.value.cardNumber = res.data?.cardNumber || ''
return res
})
}
// 是否显示周配送费选项
const isShowWeeklyDelivery = computed(()=> {
let time = userStore?.userInfo?.weeklyDeliveryExpire;
// 如果值为null,返回true
if (time === null) return true;
// 如果值不存在(如undefined),也返回true(根据你的需求可调整)
if (!time) return true;
// 检测是否到期:已过期返回true,未过期返回false
return dayjs().isAfter(dayjs(Number(time)));
})
const passwordInputRef = ref(null)
const resOrderId = ref('')
const resOrderIds = ref([]) // 批量下单返回的订单ID列表
function handleGoSettle() {
// 优先使用新选择的地址id
const targetAddressId = selectedAddressId.value || currentAddressId.value
if(!targetAddressId) {
uni.showToast({
title: t('pages-store.checkout.pleaseSelectAddress'),
icon: 'none'
})
return
}
if(payMethodOptions.value.payMethod === 1 && !payMethodOptions.value.cardId) {
uni.showToast({
title: t('pages-store.checkout.pleaseSelectCreditCard'),
icon: 'none'
})
return
}
if (deliveryTimeType.value === 2) {
if (orderType.value === "batch") {
for (const m of cartDataList.value as any[]) {
const ap = merchantAppointmentMap.value[String(m.id)];
if (!ap?.startTime || !ap?.endTime) {
uni.showToast({
title: t("pages-store.checkout.chooseTimeForStore", {
name: m.merchantName || "",
}),
icon: "none",
});
return;
}
}
} else {
const ap = merchantAppointmentMap.value[String(storeId.value)];
if (!ap?.startTime || !ap?.endTime) {
uni.showToast({
title: t("pages-store.checkout.chooseTime"),
icon: "none",
});
return;
}
}
}
// 批量下单
if(orderType.value == 'batch') {
if(resOrderIds.value.length === 0) {
// 构建 merchantCouponMap: { merchantId: couponId }
const couponMap: Record<string, number> = {}
Object.keys(merchantCouponMap.value).forEach(merchantId => {
if(merchantCouponMap.value[merchantId]?.id) {
couponMap[merchantId] = merchantCouponMap.value[merchantId].id
}
})
// 构建 merchantTipMap: { merchantId: tipAmount } (小费金额,单位:美元)
const tipMap: Record<string, number> = {}
cartDataList.value.forEach((merchant: any) => {
const merchantId = String(merchant.id)
if(deliveryMethod.value === 1) {
// 自取订单不需要小费
tipMap[merchantId] = 0
} else {
// 配送订单需要小费
const diyTip = merchantDiyTipValueMap.value[merchantId]
const tipIndex = merchantTipIndexMap.value[merchantId] || 0
if(diyTip) {
// 自定义小费金额(美元)
tipMap[merchantId] = Number(diyTip) || 0
} else if(tipIndex > 0) {
// 选择固定金额选项
tipMap[merchantId] = tipIndex
} else {
tipMap[merchantId] = 0
}
}
})
let data = {
addressId: targetAddressId,
phone: formData.value.phone,
areaCode: contact.value.areaCode,
cartIds: batchCartIds.value,
merchantCouponMap: couponMap,
merchantTipMap: tipMap,
deliveryMethod: visitMethod.value.label, // 派送方式(如放门口或者交到顾客手中)
deliveryType: deliveryTimeType.value, // 1-立即交付 2-预约交付
orderRemark: formData.value.orderRemark,
receiveMethod: deliveryMethod.value === 0 ? 1 : 2, // 收货方式(1-派送 2-自取)
startScheduledTime: '', //
endScheduledTime: '', //
needTableware: needTableware.value ? 1 : 2, // 餐具 1是 2否
}
if (deliveryTimeType.value === 1) {
const result = getTimeStamps(showDeliveryTime.value);
data.startScheduledTime = result.startTimestamp
data.endScheduledTime = result.endTimestamp
} else {
const startMap: Record<string, number> = {}
const endMap: Record<string, number> = {}
cartDataList.value.forEach((m: any) => {
const ap = merchantAppointmentMap.value[String(m.id)]
if (ap?.startTime && ap?.endTime) {
startMap[String(m.id)] = ap.startTime
endMap[String(m.id)] = ap.endTime
}
})
data.merchantStartScheduledTimeMap = startMap
data.merchantEndScheduledTimeMap = endMap
}
console.log('批量下单参数', data)
appMerchantOrderCreateOrderCartBatchPost({
body: data
}).then(res=> {
console.log('批量下单成功', res)
resOrderIds.value = res.data.orderIds || []
// 如果是余额支付,弹出支付密码弹窗
if(payMethodOptions.value.payMethod === 2) {
passwordInputRef.value?.showPasswordInput()
} else if (payMethodOptions.value.payMethod === 3) {
zelleOrderIdForVoucher.value = resOrderIds.value?.[0] ? String(resOrderIds.value[0]) : ''
zellePopupVisible.value = true
} else {
appMerchantOrderPayOrderBatch()
}
})
} else {
// 如果是余额支付,弹出支付密码弹窗
if(payMethodOptions.value.payMethod === 2) {
passwordInputRef.value?.showPasswordInput()
} else if (payMethodOptions.value.payMethod === 3) {
zelleOrderIdForVoucher.value = resOrderIds.value?.[0] ? String(resOrderIds.value[0]) : ''
zellePopupVisible.value = true
} else {
appMerchantOrderPayOrderBatch()
}
}
} else {
// 普通下单
if(!resOrderId.value) {
// 计算小费金额(美元)
let tipAmount = 0
if(deliveryMethod.value === 0) {
// 配送订单需要小费
if(diyTipValue.value) {
tipAmount = Number(diyTipValue.value) || 0
} else if(selectedTipIndex.value > 0) {
tipAmount = selectedTipIndex.value
}
}
let data = {
addressId: targetAddressId,
phone: formData.value.phone,
areaCode: contact.value.areaCode,
cartIds: cartDataList.value.map(item => item.id),
couponId: couponInfo.value ? couponInfo.value.id : '',
deliveryMethod: visitMethod.value.label, // 派送方式(如放门口或者交到顾客手中)
deliveryType: deliveryTimeType.value, // 1-立即交付 2-预约交付
orderRemark: formData.value.orderRemark,
receiveMethod: deliveryMethod.value === 0 ? 1 : 2, // 收货方式(1-派送 2-自取)
startScheduledTime: '', //
endScheduledTime: '', //
tipDiscount: tipAmount, // 小费金额(美元),自取订单不需要小费
// weeklyDeliveryFee: isWeeklyDelivery.value ? 1 : 2, // 是否支付周配送费(1-是 2-否)
needTableware: needTableware.value ? 1 : 2, // 餐具 1是 2否
}
// 如果是预约派送
if(deliveryTimeType.value === 1) {
const result = getTimeStamps(showDeliveryTime.value);
data.startScheduledTime = result.startTimestamp
data.endScheduledTime = result.endTimestamp
} else {
const ap = merchantAppointmentMap.value[String(storeId.value)]
data.startScheduledTime = ap?.startTime ?? (diyTime.value as any).startTime
data.endScheduledTime = ap?.endTime ?? (diyTime.value as any).endTime
}
console.log('下单参数', data)
appMerchantOrderCreateOrderCartPost({
body: data
}).then(res=> {
console.log('下单成功', res)
resOrderId.value = res.data || ''
// 如果是余额支付,弹出支付密码弹窗
if(payMethodOptions.value.payMethod === 2) {
passwordInputRef.value?.showPasswordInput()
} else if (payMethodOptions.value.payMethod === 3) {
zelleOrderIdForVoucher.value = String(resOrderId.value || '')
zellePopupVisible.value = true
} else {
appMerchantOrderPayOrder()
}
})
} else {
// 如果是余额支付,弹出支付密码弹窗
if(payMethodOptions.value.payMethod === 2) {
passwordInputRef.value?.showPasswordInput()
} else if (payMethodOptions.value.payMethod === 3) {
zelleOrderIdForVoucher.value = String(resOrderId.value || '')
zellePopupVisible.value = true
} else {
appMerchantOrderPayOrder()
}
}
}
}
function payPawSuccess(password: string) {
payMethodOptions.value.payPassword = password
if(orderType.value === 'batch') {
appMerchantOrderPayOrderBatch()
} else {
appMerchantOrderPayOrder()
}
}
function appMerchantOrderPayOrder() {
appMerchantOrderPayOrderPost({
body: {
...payMethodOptions.value,
orderId: resOrderId.value
}
}).then(res=> {
console.log('支付结果', res)
uni.showToast({
title: t('pages-store.checkout.paymentSuccess'),
icon: 'none'
})
setTimeout(()=> {
// 支付成功后跳转到当前订单详情页
if (resOrderId.value) {
uni.reLaunch({
url: `/pages-store/pages/order/index?id=${resOrderId.value}`
})
} else {
uni.navigateBack({
delta: 2,
})
}
}, 500)
})
}
// 批量订单支付
function appMerchantOrderPayOrderBatch() {
// 使用批量支付接口
appMerchantOrderPayOrderBatchPost({
body: {
orderIds: resOrderIds.value,
cardId: payMethodOptions.value.cardId,
payMethod: payMethodOptions.value.payMethod,
payPassword: payMethodOptions.value.payPassword
}
}).then(res=> {
console.log('批量支付结果', res)
uni.showToast({
title: t('pages-store.checkout.paymentSuccess'),
icon: 'none'
})
setTimeout(()=> {
// 批量支付后,优先跳转到第一笔订单详情;若无订单ID则返回
const firstOrderId = resOrderIds.value && resOrderIds.value.length > 0 ? resOrderIds.value[0] : ''
if (firstOrderId) {
uni.redirectTo({
url: `/pages-store/pages/order/index?id=${firstOrderId}`
})
} else {
uni.navigateBack({
delta: 2,
})
}
}, 500)
}).catch(err => {
console.error('批量支付失败', err)
uni.showToast({
title: '支付失败',
icon: 'none'
})
})
}
function getTimeStamps(timeStr: string) {
// 验证输入是否为空
if (!timeStr || typeof timeStr !== 'string') {
throw new Error('输入必须是有效的时间字符串,格式如 "HH:mm-HH:mm"');
}
// 验证格式是否正确
const timeFormatRegex = /^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/;
if (!timeFormatRegex.test(timeStr)) {
throw new Error('时间格式不正确,请使用 "HH:mm-HH:mm" 格式,例如 "17:57-18:27"');
}
// 分割时间字符串
const [startTimeStr, endTimeStr] = timeStr.split('-');
// 获取当前日期
const today = dayjs().format('YYYY-MM-DD');
// 解析开始和结束时间
const startTime = dayjs(`${today} ${startTimeStr}`);
const endTime = dayjs(`${today} ${endTimeStr}`);
// 验证时间是否有效
if (!startTime.isValid()) {
throw new Error(`开始时间 ${startTimeStr} 无效`);
}
if (!endTime.isValid()) {
throw new Error(`结束时间 ${endTimeStr} 无效`);
}
// 验证结束时间是否晚于开始时间
if (endTime.isBefore(startTime)) {
throw new Error('结束时间不能早于开始时间');
}
// 返回时间戳(毫秒)
return {
startTimestamp: startTime.valueOf(),
endTimestamp: endTime.valueOf()
};
}
/**
* CreateOrderCartBo
*/
export interface CreateOrderCartBo {
/**
* 配送地址id
*/
addressId?: number;
/**
* 区号
*/
areaCode?: string;
/**
* 购物车id列表
*/
cartIds?: number[];
/**
* 优惠券id
*/
couponId?: number;
/**
* 派送方式(如放门口或者交到顾客手中)
*/
deliveryMethod?: string;
/**
* 交付时间类型(1-立即交付 2-预约交付)
*/
deliveryType?: number;
/**
* 订单备注
*/
orderRemark?: string;
/**
* 手机号
*/
phone: string;
/**
* 收货方式(1-派送 2-自取)
*/
receiveMethod: number;
/**
* 预约时间- 开始
*/
startScheduledTime?: number;
/**
* 预约时间- 结束
*/
endScheduledTime?: number;
/**
* 小费比例
*/
tipDiscount?: number;
/**
* 是否支付周配送费(1-是 2-否)
*/
weeklyDeliveryFee?: number;
[property: string]: any;
}
// 单个订单的优惠券信息
const couponInfo = ref(null)
// 批量订单:每个店铺的优惠券映射 merchantId -> couponInfo
const merchantCouponMap = ref<Record<string, any>>({})
// 批量订单:每个店铺的小费映射 merchantId -> tipAmount (小费金额,单位:美元)
const merchantTipMap = ref<Record<string, number>>({})
// 批量订单:每个店铺的小费比例映射 merchantId -> tipPercent (小费比例,0-100)
const merchantTipPercentMap = ref<Record<string, number>>({})
function navigateToCoupon(merchantId?: string) {
const targetMerchantId = merchantId || storeId.value
uni.navigateTo({
url: '/pages-user/pages/coupon/list?id=' + targetMerchantId,
events: {
// 为指定事件添加一个监听器,获取被打开页面传送到当前页面的数据
selectedCoupon: function(data) {
console.log(data)
if(data) {
if(orderType.value === 'batch' && merchantId) {
// 批量模式:存储到对应店铺的优惠券映射
merchantCouponMap.value[merchantId] = data
} else {
// 单个订单模式
couponInfo.value = data
}
// 重新计算价格
void appMerchantOrderCalculatePriceCart()
}
},
},
})
}
const isUserMemberCheckout = computed(() => {
const vo = userStore.userInfo.userMembershipVo;
if (!vo) return false;
const exp = vo.expireTime;
if (exp) return dayjs().isBefore(dayjs(Number(exp)));
return false;
});
const cartTotalPieceCount = computed(() => {
if (orderType.value === 'batch') {
let n = 0;
cartDataList.value.forEach((m: any) => {
(m.merchantCartVoList || []).forEach((item: any) => {
n += Number(item.count) || 0;
});
});
return n || batchCartIds.value.length;
}
return cartDataList.value.reduce(
(sum, item: any) => sum + (Number(item.count) || 0),
0,
);
});
const serviceFeeAmount = computed(() => {
const p: any = priceData.value;
if (!p) return 0;
const original =
Number(p.actualAmount ?? p.totalActualAmount ?? 0) || 0;
const ratio = Number(p.merchantVo?.platformServiceFeeRatio) || 0;
return Math.round(original * ratio * 100) / 100;
});
const deliveryPillText = computed(() => {
if (orderType.value === "batch") {
return t("pages-store.checkout.chooseTime");
}
return getMerchantPillText(storeId.value);
});
function safeToNumber(val: unknown, fallback = 0) {
const n = Number(val);
return Number.isFinite(n) ? n : fallback;
}
function safeMoneyStr(val: unknown, fallback = 0) {
return safeToNumber(val, fallback).toFixed(2);
}
const displayDeliveryFeeStr = computed(() => {
const p: any = priceData.value;
if (!p) return '0.00';
if (orderType.value === 'batch') {
return safeMoneyStr(p.totalDeliveryFee, 0);
}
return safeMoneyStr(p.deliveryFee, 0);
});
const displayGoodsAmountStr = computed(() => {
const p: any = priceData.value;
if (!p) return '0.00';
if (orderType.value === 'batch') {
return safeMoneyStr(p.totalActualAmount, 0);
}
return safeMoneyStr(p.actualAmount, 0);
});
const displayTaxStr = computed(() => {
const p: any = priceData.value;
if (!p) return '0.00';
if (orderType.value === 'batch') {
return safeMoneyStr(p.totalTax, 0);
}
return safeMoneyStr(p.tax, 0);
});
const displayTipStr = computed(() => {
const p: any = priceData.value;
if (!p) return '0.00';
if (orderType.value === 'batch') {
return safeMoneyStr(p.totalTip, 0);
}
return safeMoneyStr(p.tip, 0);
});
const displayPaidStr = computed(() => {
const p: any = priceData.value;
if (!p) return '0.00';
if (orderType.value === 'batch') {
return safeMoneyStr(p.totalPaidAmount, 0);
}
return safeMoneyStr(p.paidAmount, 0);
});
const scheduledServiceEndLabel = computed(() => {
const slot = (diyTime.value as any)?.timeSlot;
if (slot && String(slot).includes(' - ')) {
return String(slot).split(' - ')[1]?.trim() || '';
}
return '';
});
const localDeliverySubtitleText = computed(() =>
t('pages-store.checkout.localDeliverySubtitle', { name: appDisplayName.value })
);
const itemsGoodsTotalWithPriceText = computed(() =>
t('pages-store.checkout.itemsGoodsTotalWithPrice', {
count: cartTotalPieceCount.value,
amount: displayGoodsAmountStr.value,
})
);
const scheduledDeliveryWindowText = computed(() =>
t('pages-store.checkout.scheduledDeliveryWindow', {
time: scheduledServiceEndLabel.value,
})
);
const deliverBeforeText = computed(() =>
t('pages-store.checkout.deliverBefore', {
time: deliveryMethod.value === 0
? String(userSelectedDeliveryTimeDate.value ?? '')
: String(userSelectedSelfPickupTimeDate.value ?? ''),
})
);
const memberThanksText = computed(() =>
t('pages-store.checkout.memberThanks', { name: appDisplayName.value })
);
const subtotalWithPiecesText = computed(() =>
t('pages-store.checkout.subtotalWithPieces', {
count: cartTotalPieceCount.value,
})
);
const subtotalOneLineText = computed(() =>
t('pages-store.checkout.subtotalOneLine', {
amount: displayPaidStr.value,
})
);
const showAppointmentEntry = computed(() => {
if (deliveryMethod.value === 0) {
return storeIsDeliveryService.value || orderType.value === 'batch';
}
return storeIsSelfPickup.value || orderType.value === 'batch';
});
/** 商家标价配送费(用于划线价,实际运费可能因活动/距离更低) */
const listDeliveryFeeAmount = computed(() => {
const p: any = priceData.value;
const raw = p?.merchantVo?.deliveryFee;
if (raw === undefined || raw === null || raw === '') return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
});
const actualDeliveryFeeNum = computed(() => {
const p: any = priceData.value;
if (!p) return 0;
if (orderType.value === 'batch') {
return Number(p.totalDeliveryFee ?? 0);
}
return Number(p.deliveryFee ?? 0);
});
const showListDeliveryStrike = computed(() => {
const list = listDeliveryFeeAmount.value;
if (list == null) return false;
return list > actualDeliveryFeeNum.value + 0.005;
});
const listDeliveryFeeStr = computed(() => {
const list = listDeliveryFeeAmount.value;
return list != null ? list.toFixed(2) : '';
});
function handleClose(merchantId?: string) {
if(orderType.value === 'batch' && merchantId) {
// 批量模式:清除对应店铺的优惠券
delete merchantCouponMap.value[merchantId]
} else {
// 单个订单模式
couponInfo.value = null
}
// 重新计算价格
void appMerchantOrderCalculatePriceCart()
}
</script>
<template>
<view class="checkout-root">
<navbar
:title="t('pages-store.checkout.title')"
circle-back
custom-class="checkout-page-navbar"
/>
<view
class="animate-in fade-in animate-ease-out animate-duration-300"
v-show="loading"
>
<!-- 骨架屏 -->
<CheckoutSkeleton
/></view>
<view
class="checkout-page animate-in fade-in animate-ease-in animate-duration-300"
v-if="!loading"
>
<view class="checkout-page-stack">
<!-- 配送信息主行地址+ 底部分栏偏好 | 电话 -->
<view class="checkout-block">
<view class="checkout-section-label">{{ t('pages-store.checkout.deliveryInfo') }}</view>
<view class="checkout-card checkout-gutter checkout-card--stacked">
<template v-if="deliveryMethod === 0">
<view @click="chooseAddress" class="checkout-delivery-main">
<view class="flex-1 min-w-0 pr-16rpx">
<template v-if="addressesList.length > 0 && addressInfo && addressInfo.formattedAddress">
<text class="checkout-card-primary block line-clamp-2">{{ addressInfo.formattedAddress }}</text>
<text v-if="addressInfo.displayName" class="checkout-card-secondary block mt-10rpx">{{ addressInfo.displayName }}</text>
</template>
<text v-else class="checkout-card-placeholder">{{ t('pages-store.checkout.fillAddressHint') }}</text>
</view>
<image src="@img/chef/142.png" mode="aspectFill" class="checkout-chevron" />
</view>
<view class="checkout-delivery-extras">
<view @click.stop="chooseVisitMethod" class="checkout-delivery-extras-cell">
<text class="checkout-delivery-extras-label">{{ t('pages-store.checkout.deliveryPreference') }}</text>
<text class="checkout-delivery-extras-value line-clamp-1">{{ visitMethod.label }}</text>
</view>
<view class="checkout-delivery-extras-vline" />
<view @click.stop="openChangePhone" class="checkout-delivery-extras-cell">
<text class="checkout-delivery-extras-label">{{ t('pages-store.checkout.contactPhone') }}</text>
<text class="checkout-delivery-extras-value line-clamp-1">{{ contact.areaCode }} {{ formData.phone }}</text>
</view>
</view>
</template>
<template v-else>
<view class="checkout-pickup-info">
<text class="checkout-pickup-name">{{ storeDetail?.merchantName }}</text>
<text class="checkout-pickup-addr line-clamp-2">{{ storeDetail?.merchantAddress }}</text>
</view>
<view class="checkout-delivery-extras checkout-delivery-extras--full">
<view class="checkout-delivery-extras-cell checkout-delivery-extras-cell--grow">
<text class="checkout-delivery-extras-label">{{ t("pages-store.checkout.distance") }}</text>
<text class="checkout-delivery-extras-value">
<template v-if="storeDistance">{{ storeDistance }} {{ t('common.mile') }}</template>
<template v-else>{{ t('pages-store.checkout.enableLocationForDistance') }}</template>
</text>
</view>
</view>
</template>
</view>
</view>
<!-- 确认订单单店 -->
<view v-if="orderType === 'normal' && cartDataList.length > 0" class="checkout-block">
<view class="checkout-section-label">{{ t('pages-store.checkout.confirmOrder') }}</view>
<view class="checkout-card checkout-card--flush checkout-gutter overflow-hidden">
<view class="checkout-confirm-inner">
<view class="checkout-confirm-head">
<view class="checkout-icon-circle">
<view
v-if="deliveryMethod === 0"
class="i-carbon:delivery text-40rpx text-#333"
/>
<view
v-else
class="i-carbon:store text-40rpx text-#333"
/>
</view>
<view class="checkout-confirm-head-text">
<text class="checkout-card-title block">{{
deliveryMethod === 0 ? t('pages-store.checkout.localDelivery') : t('pages-store.checkout.pickupTitle')
}}</text>
<text class="checkout-card-subtitle block">{{
deliveryMethod === 0
? localDeliverySubtitleText
: t('pages-store.checkout.pickupSubtitle')
}}</text>
</view>
</view>
<view v-if="showAppointmentEntry" class="checkout-confirm-band">
<view
@click.stop="toggleDeliveryTimeType(2)"
class="checkout-date-pill"
>
{{ deliveryPillText }}
</view>
<view class="checkout-confirm-band-right">
<text class="checkout-confirm-band-line1">
{{ itemsGoodsTotalWithPriceText }}
</text>
<view v-if="deliveryMethod === 0" class="checkout-confirm-band-line2">
<text>{{ t('pages-store.checkout.shippingFee') }}</text>
<text v-if="showListDeliveryStrike" class="checkout-price-strike"> ${{ listDeliveryFeeStr }}</text>
<text v-if="showListDeliveryStrike"> </text>
<text class="checkout-confirm-meta-fee">${{ displayDeliveryFeeStr }}</text>
</view>
</view>
</view>
</view>
<scroll-view
v-if="deliveryMethod === 0"
scroll-x
class="checkout-schedule-scroll checkout-schedule-bleed"
:show-scrollbar="false"
>
<view class="checkout-schedule-inner">
<view class="checkout-schedule-card checkout-schedule-card--on">
<view class="checkout-schedule-card-body">
<view class="checkout-radio-dot" />
<view class="flex-1 min-w-0">
<text class="checkout-schedule-title">{{ t('pages-store.checkout.scheduledDeliveryTitle') }}</text>
<text v-if="scheduledServiceEndLabel" class="checkout-schedule-mid">{{
scheduledDeliveryWindowText
}}</text>
<text class="checkout-schedule-hint">{{
t('pages-store.checkout.scheduledDeliveryHint')
}}</text>
</view>
</view>
<text class="checkout-schedule-addon">{{ t('pages-store.checkout.scheduledDeliveryAddon') }}</text>
</view>
<view class="checkout-schedule-card checkout-schedule-card--off">
<view class="checkout-schedule-card-body">
<view class="checkout-radio-ring" />
<view class="flex-1 min-w-0">
<text class="checkout-schedule-title checkout-schedule-title--muted">{{ t('pages-store.checkout.scheduledDeliveryStandardTitle') }}</text>
<text class="checkout-schedule-hint">{{ t('pages-store.checkout.scheduledDeliveryStandardDesc') }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<wd-collapse v-model="collapseValue" custom-class="checkout-order-collapse">
<wd-collapse-item name="item3">
<template #title="{ expanded }">
<scroll-view scroll-x class="checkout-thumb-scroll checkout-thumb-strip" :show-scrollbar="false">
<view class="flex items-center gap-16rpx py-20rpx pr-28rpx">
<view
v-for="cItem in cartDataList"
:key="cItem.id"
class="relative shrink-0 w-100rpx h-100rpx rounded-12rpx overflow-hidden"
>
<image
:src="(cItem.merchantDishVo?.dishImage || '').split(',')[0]"
mode="aspectFill"
class="w-full h-full"
/>
<view
v-if="Number(cItem.count) > 1"
class="checkout-qty-badge"
>x{{ cItem.count }}</view>
</view>
<image
:class="[expanded ? 'rotate--90' : 'rotate-90']"
src="@img/chef/142.png"
mode="aspectFill"
class="w-32rpx h-32rpx shrink-0 transition-all duration-300 ml-8rpx"
/>
</view>
</scroll-view>
</template>
<view
v-for="(item, index) in cartDataList"
:key="item.id"
class="checkout-order-line"
>
<view class="flex items-center flex-1 min-w-0">
<view
class="w-48rpx h-48rpx rounded-8rpx bg-#F2F2F2 center mr-24rpx shrink-0 text-24rpx"
>{{ index + 1 }}</view>
<view class="min-w-0 flex-1">
<view class="text-30rpx lh-36rpx text-#333 font-500 line-clamp-2"
>{{ item.merchantDishVo?.dishName }}</view
>
<view v-if="item.sideDishList?.length > 0"
class="text-24rpx lh-30rpx text-#7D7D7D font-400 mt-8rpx"
>
<view
v-for="(dish, dIdx) in item.sideDishList"
:key="dIdx"
class="inline"
>
<text class="mr-6rpx">{{ dish.merchantSideDishItemVo?.name }}</text>
<text class="text-#00A76D mx-6rpx">+${{ dish.merchantSideDishItemVo?.price }}</text>
</view>
</view>
</view>
</view>
<view class="text-30rpx text-#333 font-500 shrink-0 ml-16rpx">${{ item.merchantDishVo?.discountPrice }}</view>
</view>
</wd-collapse-item>
</wd-collapse>
<view
v-if="showAppointmentEntry && (userSelectedDeliveryTimeDate || userSelectedSelfPickupTimeDate)"
class="checkout-deliver-hint"
>
{{ deliverBeforeText }}
</view>
</view>
</view>
<!-- 批量模式多店铺列表 -->
<view class="checkout-block" v-if="orderType === 'batch' && cartDataList.length > 0">
<view class="checkout-gutter">
<view
v-for="merchant in cartDataList"
:key="merchant.id"
class="mb-24rpx checkout-card checkout-card--merchant overflow-hidden"
>
<!-- 店铺头部 -->
<view class="checkout-merchant-head">
<image
class="w-60rpx h-60rpx rounded-50% mr-20rpx"
:src="merchant.logo"
mode="aspectFill"
/>
<view class="flex-1 min-w-0">
<view class="text-28rpx lh-28rpx text-#333 font-500"
>{{ merchant.merchantName }}</view
>
<view
class="text-24rpx lh-24rpx text-#7D7D7D font-400 mt-8rpx"
>{{ merchant.merchantCartVoList.length }} {{ t('pages-user.cart.items') }}</view
>
</view>
</view>
<view
v-if="showAppointmentEntry"
class="checkout-merchant-schedule-row"
>
<view
class="checkout-date-pill"
@click.stop="toggleDeliveryTimeType(2, merchant)"
>
{{ getMerchantPillText(merchant.id) }}
</view>
</view>
<!-- 商品列表 -->
<view class="checkout-merchant-body">
<view
v-for="(item, itemIndex) in merchant.merchantCartVoList"
:key="item.id"
class="checkout-merchant-item"
:class="itemIndex < merchant.merchantCartVoList.length - 1 ? 'is-divider' : ''"
>
<!-- 商品图片 -->
<image
:src="item.merchantDishVo?.dishImage?.split(',')[0]"
mode="aspectFill"
class="w-100rpx h-100rpx shrink-0 rounded-12rpx"
></image>
<!-- 商品信息 -->
<view class="ml-16rpx flex-1">
<view class="text-[#333333] text-26rpx lh-36rpx font-500 line-clamp-2">{{
item.merchantDishVo?.dishName
}}</view>
<!-- 配菜信息 -->
<view class="text-[#7D7D7D] text-22rpx lh-26rpx mt-8rpx" v-if="item.sideDishList?.length > 0">
<view
v-for="(dish, dIdx) in item.sideDishList"
:key="dIdx"
class="inline"
>
<text class="mr-6rpx">
{{ dish?.merchantSideDishItemVo?.name || '' }}
</text>
<text v-if="dish?.merchantSideDishItemVo?.price" class="text-#00A76D mr-10rpx">+${{ dish?.merchantSideDishItemVo?.price }}</text>
</view>
</view>
<!-- 价格和数量 -->
<view class="flex items-center justify-between mt-12rpx">
<view class="flex items-center">
<text class="text-[#333333] text-28rpx font-500"
>${{ item.merchantDishVo?.discountPrice }}</text>
<text
v-if="item.merchantDishVo?.originalPrice && item.merchantDishVo?.originalPrice !== item.merchantDishVo?.discountPrice"
class="text-[#7D7D7D] text-22rpx line-through ml-8rpx"
>${{ item.merchantDishVo?.originalPrice }}</text>
</view>
<view class="text-[#7D7D7D] text-22rpx">
x{{ item.count }}
</view>
</view>
</view>
</view>
</view>
<!-- 批量模式每个店铺的优惠券和小费选择 -->
<view v-if="merchant.id" class="checkout-merchant-footer">
<!-- 优惠券 -->
<view @click="navigateToCoupon(String(merchant.id))" class="flex-center-sb py-24rpx gap-20rpx">
<view class="flex-center-sb flex-1">
<view class="flex items-center">
<image
src="@img/chef/105.png"
mode="aspectFill"
class="w-36rpx h-36rpx relative z-1"
/>
<view class="ml-20rpx text-26rpx lh-26rpx font-500 text-#333">
{{ merchantCouponMap[String(merchant.id)] ? merchantCouponMap[String(merchant.id)].snapshotNameZh : t('pages-store.checkout.couponInputPlaceholder') }}
</view>
</view>
<view v-if="merchantCouponMap[String(merchant.id)]" @click.stop="handleClose(String(merchant.id))" class="i-carbon:close-filled text-32rpx text-#7D7D7D"></view>
</view>
<image
src="@img/chef/142.png"
mode="aspectFill"
class="w-24rpx h-24rpx shrink-0"
/>
</view>
<!-- 小费仅配送时显示 -->
<view v-if="deliveryMethod === 0" class="checkout-merchant-tip-block">
<text class="checkout-driver-tip-desc">{{
t('pages-store.checkout.driverTipNote')
}}</text>
<view class="flex items-center mb-20rpx">
<image
src="@img/chef/1335.png"
mode="aspectFill"
class="w-36rpx h-36rpx relative z-1"
/>
<view class="ml-20rpx text-26rpx lh-26rpx font-500 text-#333">
{{ t('pages-store.checkout.driverTip') }}
</view>
</view>
<view class="checkout-tip-grid checkout-tip-grid--merchant">
<view
v-for="(item, index) in tipOptions"
:key="index"
@click="selectedTipChangeForMerchant(String(merchant.id), item)"
:class="[
merchantTipIndexMap[String(merchant.id)] === item.value
? 'checkout-tip-pill checkout-tip-pill--on'
: 'checkout-tip-pill',
]"
class="checkout-tip-cell checkout-tip-cell--merchant"
>
{{ item.label }}
</view>
<view
@click="selectedTipChangeForMerchant(String(merchant.id), { value: 0 })"
:class="[
merchantTipIndexMap[String(merchant.id)] === 0
? 'checkout-tip-pill checkout-tip-pill--on'
: 'checkout-tip-pill',
]"
class="checkout-tip-cell checkout-tip-cell--merchant checkout-tip-cell--input"
>
<view class="px-10rpx center h-full w-full">
<wd-input
no-border
use-prefix-slot
@blur="handleConfirmTipForMerchant(String(merchant.id))"
custom-class="!center !text-24rpx !bg-transparent flex-1 !text-center"
placeholderStyle="font-size: 24rpx;color: #333; text-align: center;"
:placeholder="t('pages-store.checkout.other')"
v-model="merchantDiyTipValueMap[String(merchant.id)]"
@confirm="handleConfirmTipForMerchant(String(merchant.id))"
>
<template #suffix>
<text v-if="merchantDiyTipValueMap[String(merchant.id)] && merchantDiyTipValueMap[String(merchant.id)].length > 0">$</text>
</template>
</wd-input>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 批量模式且没有数据时的提示 -->
<view class="checkout-block" v-if="orderType === 'batch' && cartDataList.length === 0">
<view class="checkout-section-label">{{ t('pages-store.checkout.confirmOrder') }}</view>
<view class="checkout-card checkout-gutter py-48rpx">
<text class="block text-center text-28rpx text-#6d6d6d">已选择 {{ batchCartIds.length }} 件商品</text>
</view>
</view>
<!-- 使用优惠单店 -->
<view class="checkout-block" v-if="orderType === 'normal'">
<view class="checkout-section-label">{{ t('pages-store.checkout.useDiscount') }}</view>
<view
@click="navigateToCoupon()"
class="checkout-card checkout-gutter checkout-pay-row"
>
<view class="flex items-center flex-1 min-w-0 pr-16rpx">
<text class="flex-1 checkout-card-primary">{{
couponInfo ? couponInfo.snapshotNameZh : t('pages-store.checkout.couponInputPlaceholder')
}}</text>
<view
v-if="couponInfo"
@click.stop="handleClose()"
class="i-carbon:close-filled text-36rpx text-#7D7D7D mr-12rpx"
></view>
</view>
<image src="@img/chef/142.png" mode="aspectFill" class="checkout-chevron" />
</view>
</view>
<!-- 司机小费单店仅配送 -->
<view class="checkout-block" v-if="orderType === 'normal' && deliveryMethod === 0">
<view class="checkout-section-label">{{ t('pages-store.checkout.driverTip') }}</view>
<view class="checkout-card checkout-gutter checkout-tip-card">
<text class="checkout-driver-tip-desc">{{
t('pages-store.checkout.driverTipNote')
}}</text>
<view class="checkout-tip-grid">
<view
v-for="(item, index) in tipOptions"
:key="index"
@click="selectedTipChange(item)"
:class="[
selectedTipIndex === item.value
? 'checkout-tip-pill checkout-tip-pill--on'
: 'checkout-tip-pill',
]"
class="checkout-tip-cell"
>
{{ item.label }}
</view>
<view
@click="selectedTipChange({ value: 0 })"
:class="[
selectedTipIndex === 0
? 'checkout-tip-pill checkout-tip-pill--on'
: 'checkout-tip-pill',
]"
class="checkout-tip-cell checkout-tip-cell--input"
>
<view class="px-10rpx center w-full h-full">
<wd-input
no-border
use-prefix-slot
@blur="handleConfirmTip"
custom-class="!center !text-26rpx !bg-transparent flex-1 !text-center"
placeholderStyle="font-size: 26rpx;color: #333; text-align: center;"
:placeholder="t('pages-store.checkout.other')"
v-model="diyTipValue"
@confirm="handleConfirmTip"
>
<template #suffix>
<text v-if="diyTipValue.length > 0">$</text>
</template>
</wd-input>
</view>
</view>
</view>
</view>
</view>
<!-- 订单明细 -->
<view class="checkout-block">
<view class="checkout-section-label">{{ t('pages-store.checkout.orderBreakdown') }}</view>
<view class="checkout-card checkout-breakdown checkout-gutter">
<view v-if="isUserMemberCheckout" class="checkout-member-line">
<text class="checkout-member-text">{{ memberThanksText }}</text>
<text class="checkout-member-icon"></text>
</view>
<view class="checkout-breakdown-row">
<text class="checkout-breakdown-label">{{ subtotalWithPiecesText }}</text>
<text class="checkout-breakdown-value">${{ displayGoodsAmountStr }}</text>
</view>
<view class="checkout-breakdown-row">
<text class="checkout-breakdown-label">{{ t('pages-store.checkout.priceDetail.taxation') }}</text>
<text class="checkout-breakdown-value">${{ displayTaxStr }}</text>
</view>
<view class="checkout-breakdown-row checkout-breakdown-row--service">
<view class="checkout-breakdown-label-wrap">
<text class="checkout-breakdown-label" @click="openPriceDetail">{{ t('pages-store.checkout.serviceFee') }}</text>
<image
src="@img/chef/1336.png"
class="checkout-breakdown-info-icon"
mode="aspectFit"
@click.stop="openPriceDetail"
/>
</view>
<text v-if="serviceFeeAmount > 0" class="checkout-breakdown-value">${{ serviceFeeAmount.toFixed(2) }}</text>
<view
v-else-if="serviceFeeAmount === 0"
class="checkout-breakdown-value-placeholder checkout-breakdown-service-fee-free"
>
<text class="checkout-price-strike checkout-breakdown-strike-fee">$0.00</text>
<view class="checkout-tag-free">{{ t('pages-store.checkout.freeTag') }}</view>
</view>
</view>
<view
v-if="deliveryMethod === 0"
class="checkout-breakdown-row"
>
<text class="checkout-breakdown-label">{{ t('pages-store.checkout.shippingFee') }}</text>
<view class="checkout-breakdown-value-wrap">
<text v-if="showListDeliveryStrike" class="checkout-price-strike">${{ listDeliveryFeeStr }}</text>
<text v-if="showListDeliveryStrike" class="checkout-price-gap"> </text>
<text class="checkout-breakdown-value checkout-price-now">${{ displayDeliveryFeeStr }}</text>
</view>
</view>
<view
v-if="deliveryMethod === 0"
class="checkout-breakdown-row"
>
<text class="checkout-breakdown-label">{{ t('pages-store.checkout.driverTip') }}</text>
<text class="checkout-breakdown-value">${{ displayTipStr }}</text>
</view>
<view class="checkout-breakdown-row checkout-breakdown-row--emphasis">
<text class="checkout-breakdown-label">{{ t('pages-store.checkout.orderTotalLine') }}</text>
<text class="checkout-breakdown-value">${{ displayPaidStr }}</text>
</view>
<view class="checkout-breakdown-divider"></view>
<view class="checkout-breakdown-row checkout-breakdown-row--total">
<text class="checkout-breakdown-total-label">{{ subtotalWithPiecesText }}</text>
<text class="checkout-breakdown-total-value">${{ displayPaidStr }}</text>
</view>
</view>
</view>
</view>
<!-- /checkout-page-stack -->
<view class="checkout-page-bottom-spacer"></view>
<!-- 底部支付浮窗 + 毛玻璃H5/App 全效果小程序降级为半透明白底 -->
<view class="checkout-bottom-dock">
<view
v-if="cartSavingsData && cartSavingsData.savings > 0"
@click="navigateTo('/pages-user/pages/member/index')"
class="checkout-savings-banner checkout-savings-banner--float"
>
<image
src="@img/chef/1289.png"
class="mr-10rpx w-32rpx h-32rpx shrink-0"
></image>
<text class="checkout-savings-banner-text">
{{ t('pages-store.store.use') }} {{ Config.appName }} {{ t('pages-store.store.tips3') }} ${{ cartSavingsData?.savings }} {{ t('pages-store.store.discount') }}
</text>
</view>
<view class="checkout-bottom-bar">
<view class="checkout-bottom-bar-glass"></view>
<view class="checkout-bottom-bar-inner">
<view class="checkout-bottom-bar-price">
<text class="checkout-bottom-bar-one-line">{{ subtotalOneLineText }}</text>
</view>
<wd-button
@click="openPayMethodPopup"
custom-class="checkout-pay-btn"
>{{ t('pages-store.checkout.pay') }}
</wd-button>
</view>
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
</view>
</view>
<!-- 修改配送偏好 -->
<visit-method ref="visitMethodRef" @confirm="handleVisitMethodConfirm" />
<!-- 修改手机号 -->
<change-phone ref="changePhoneRef" @confirm="confirmPhone" />
<!-- 价格明细 -->
<price-detail ref="priceDetailRef" />
<password-container @success="payPawSuccess" ref="passwordInputRef" />
<choose-image ref="voucherChooseRef" :count="1" @change="onVoucherImageUploaded" />
<wd-popup v-model="payMethodPopupVisible" position="bottom" @close="payMethodPopupVisible = false">
<view class="checkout-paymethod-popup">
<view class="checkout-paymethod-popup-title">{{ t('pages-store.checkout.payMethodSection') }}</view>
<view
class="checkout-paymethod-option"
:class="{ 'is-active': payMethodSelected === 1 }"
@click="payMethodSelected = 1"
>
<view class="checkout-paymethod-option-left">
<view class="checkout-paymethod-dot">
<view class="checkout-paymethod-dot-inner"></view>
</view>
<view class="checkout-paymethod-text-wrap">
<view class="checkout-paymethod-name">{{ t('pages-user.choosePaymethod.creditCard') }}</view>
<view class="checkout-paymethod-desc">{{ payMethodOptions.cardId ? payMethodOptions.cardNumber : t('pages-user.member.creditCard') }}</view>
</view>
</view>
<view
class="checkout-paymethod-replace-btn"
@click.stop="chooseCardForCheckout"
>
{{ payMethodOptions.cardId ? t('pages-user.choosePaymethod.replace') : t('pages-user.member.creditCard') }}
</view>
</view>
<view
class="checkout-paymethod-option"
:class="{ 'is-active': payMethodSelected === 2 }"
@click="payMethodSelected = 2"
>
<view class="checkout-paymethod-option-left">
<view class="checkout-paymethod-dot">
<view class="checkout-paymethod-dot-inner"></view>
</view>
<view class="checkout-paymethod-text-wrap">
<view class="checkout-paymethod-name">{{ t('pages-user.choosePaymethod.wallet') }}</view>
<view class="checkout-paymethod-desc">Balance</view>
</view>
</view>
</view>
<view
class="checkout-paymethod-option"
:class="{ 'is-active': payMethodSelected === 3 }"
@click="payMethodSelected = 3"
>
<view class="checkout-paymethod-option-left">
<view class="checkout-paymethod-dot">
<view class="checkout-paymethod-dot-inner"></view>
</view>
<view class="checkout-paymethod-text-wrap">
<view class="checkout-paymethod-name">Zelle</view>
<view class="checkout-paymethod-desc">Scan QR and upload voucher</view>
</view>
</view>
</view>
<wd-button @click="confirmPayMethodForSettle" custom-class="checkout-paymethod-confirm-btn" block>
{{ t('common.confirm') }}
</wd-button>
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
</view>
</wd-popup>
<wd-popup v-model="zellePopupVisible" custom-style="background: transparent;" @close="zellePopupVisible = false">
<view class="w-620rpx rounded-24rpx bg-white p-28rpx">
<view class="text-32rpx lh-32rpx text-#333 font-600 text-center mb-24rpx">Zelle</view>
<image v-if="zellePayPath" :src="zellePayPath" mode="aspectFit" class="w-100% h-520rpx rounded-16rpx bg-#F7F7F7" />
<view v-else class="h-220rpx rounded-16rpx bg-#F7F7F7 center text-#999 text-24rpx">
zellePayPath is empty
</view>
<wd-button
:loading="voucherSubmitting"
:disabled="voucherSubmitting"
@click="openUploadVoucher"
custom-class="!h-92rpx !rounded-40rpx !text-30rpx !mt-26rpx !bg-#14181B"
block
>
{{ t('pages-store.order.uploadPaidVoucher') }}
</wd-button>
</view>
</wd-popup>
</view>
</view>
</template>
<style>
page {
background-color: #f8f8f8;
}
</style>
<style scoped lang="scss">
/* 设计稿:浅灰底 #F8F8F8、主色 #000、次级字 #6D6D6D、卡片白底圆角约 12~20px、内边距 12~16px */
$checkout-bg: #f8f8f8;
$checkout-text: #000000;
$checkout-text-secondary: #6d6d6d;
$checkout-text-tertiary: #9e9e9e;
$checkout-card-bg: #ffffff;
$checkout-card-radius: 24rpx;
$checkout-border: #ebebeb;
$checkout-gutter: 32rpx;
.checkout-page {
padding-top: 16rpx;
padding-bottom: 32rpx;
min-height: 100vh;
box-sizing: border-box;
background: $checkout-bg;
}
.checkout-page-stack {
display: flex;
flex-direction: column;
gap: 40rpx;
padding-bottom: 8rpx;
}
.checkout-block {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.checkout-block > .checkout-section-label {
margin-bottom: 0;
}
.checkout-gutter {
margin-left: $checkout-gutter;
margin-right: $checkout-gutter;
}
.checkout-section-label {
padding: 0 $checkout-gutter;
margin-bottom: 20rpx;
font-size: 32rpx;
font-weight: 700;
color: $checkout-text;
letter-spacing: 0.02em;
line-height: 1.3;
}
.checkout-section-label--spaced {
margin-top: 40rpx;
}
.checkout-card {
background: $checkout-card-bg;
border-radius: $checkout-card-radius;
border: 1rpx solid $checkout-border;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.03);
}
.checkout-card--flush {
border: 1rpx solid $checkout-border;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.03);
}
.checkout-card--merchant {
border: 1rpx solid $checkout-border;
}
.checkout-card--stacked {
padding: 0;
overflow: hidden;
}
.checkout-delivery-main {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 32rpx 28rpx;
min-height: 100rpx;
box-sizing: border-box;
}
.checkout-delivery-extras {
display: flex;
flex-direction: row;
align-items: stretch;
border-top: 1rpx solid $checkout-border;
min-height: 100rpx;
}
.checkout-delivery-extras-cell {
flex: 1;
min-width: 0;
padding: 20rpx 16rpx 24rpx 24rpx;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8rpx;
box-sizing: border-box;
}
.checkout-delivery-extras-cell:first-of-type {
padding-right: 12rpx;
}
.checkout-delivery-extras-cell:last-of-type {
padding-left: 12rpx;
padding-right: 24rpx;
}
.checkout-delivery-extras-vline {
width: 1rpx;
flex-shrink: 0;
background: $checkout-border;
align-self: stretch;
margin: 16rpx 0;
}
.checkout-delivery-extras-label {
font-size: 22rpx;
color: $checkout-text-tertiary;
line-height: 1.3;
}
.checkout-delivery-extras-value {
font-size: 26rpx;
font-weight: 500;
color: $checkout-text;
line-height: 1.35;
}
.checkout-delivery-extras--full {
border-top: 1rpx solid $checkout-border;
}
.checkout-delivery-extras--full .checkout-delivery-extras-cell--grow {
flex: 1;
padding: 20rpx 24rpx 24rpx;
}
.checkout-pickup-info {
padding: 32rpx 28rpx 24rpx;
box-sizing: border-box;
}
.checkout-pickup-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $checkout-text;
line-height: 1.4;
}
.checkout-pickup-addr {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
color: $checkout-text-secondary;
line-height: 1.45;
}
.checkout-pay-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 32rpx 28rpx;
min-height: 100rpx;
box-sizing: border-box;
}
.checkout-paymethod-popup {
padding: 36rpx 30rpx 20rpx;
background: #fff;
border-radius: 28rpx 28rpx 0 0;
}
.checkout-paymethod-popup-title {
font-size: 34rpx;
line-height: 1.3;
font-weight: 700;
color: #14181b;
text-align: center;
margin-bottom: 30rpx;
}
.checkout-paymethod-option {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 128rpx;
padding: 0 24rpx;
border: 2rpx solid #ececec;
border-radius: 22rpx;
background: #fff;
margin-bottom: 18rpx;
transition: all .2s ease;
}
.checkout-paymethod-option.is-active {
border-color: #14181b;
background: #f7f8fa;
box-shadow: 0 6rpx 20rpx rgba(20, 24, 27, 0.08);
}
.checkout-paymethod-option-left {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.checkout-paymethod-dot {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
border: 2rpx solid #d0d0d0;
display: flex;
align-items: center;
justify-content: center;
margin-right: 18rpx;
flex-shrink: 0;
}
.checkout-paymethod-dot-inner {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: transparent;
}
.checkout-paymethod-option.is-active .checkout-paymethod-dot {
border-color: #14181b;
}
.checkout-paymethod-option.is-active .checkout-paymethod-dot-inner {
background: #14181b;
}
.checkout-paymethod-text-wrap {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.checkout-paymethod-name {
font-size: 30rpx;
line-height: 1.25;
font-weight: 600;
color: #1f1f1f;
}
.checkout-paymethod-desc {
font-size: 24rpx;
line-height: 1.3;
color: #8b8b8b;
}
.checkout-paymethod-replace-btn {
margin-left: 16rpx;
height: 50rpx;
padding: 0 20rpx;
border-radius: 25rpx;
background: #ffffff;
border: 1rpx solid #e6e6e6;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #333333;
flex-shrink: 0;
}
.checkout-paymethod-option:last-of-type {
margin-bottom: 0;
}
.checkout-paymethod-confirm-btn {
margin-top: 28rpx !important;
height: 96rpx !important;
border-radius: 46rpx !important;
font-size: 30rpx !important;
font-weight: 600 !important;
background: #14181b !important;
}
.checkout-confirm-head {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 20rpx;
}
.checkout-confirm-head-text {
flex: 1;
min-width: 0;
}
.checkout-card-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 28rpx;
gap: 20rpx;
min-height: 100rpx;
box-sizing: border-box;
}
.checkout-card-row.is-divider {
border-top: 1rpx solid $checkout-border;
}
.checkout-card-primary {
font-size: 30rpx;
font-weight: 500;
color: $checkout-text;
line-height: 1.45;
}
.checkout-card-secondary {
font-size: 26rpx;
font-weight: 400;
color: $checkout-text-secondary;
line-height: 1.4;
}
.checkout-card-placeholder {
font-size: 30rpx;
font-weight: 400;
color: $checkout-text-secondary;
line-height: 1.45;
}
.checkout-field-line {
align-items: center;
}
.checkout-field-line--sub {
min-height: auto;
padding-top: 24rpx;
padding-bottom: 24rpx;
}
.checkout-field-sub-label {
display: block;
font-size: 24rpx;
color: $checkout-text-tertiary;
margin-bottom: 8rpx;
}
.checkout-field-sub-value {
display: block;
font-size: 28rpx;
font-weight: 500;
color: $checkout-text;
line-height: 1.35;
}
.checkout-chevron {
width: 28rpx;
height: 28rpx;
flex-shrink: 0;
opacity: 0.45;
}
.checkout-chevron--sub {
width: 24rpx;
height: 24rpx;
}
.checkout-price-strike {
text-decoration: line-through;
color: $checkout-text-tertiary;
font-weight: 400;
}
.checkout-price-now {
font-weight: 700;
}
.checkout-price-gap {
display: inline;
font-size: 20rpx;
}
.checkout-batch-shipping-line {
margin-top: 12rpx;
font-size: 24rpx;
color: $checkout-text-secondary;
line-height: 1.4;
}
.checkout-batch-shipping-now {
font-weight: 700;
color: $checkout-text;
}
.checkout-card-title {
font-size: 32rpx;
font-weight: 700;
color: $checkout-text;
line-height: 1.35;
}
.checkout-card-subtitle {
font-size: 26rpx;
font-weight: 400;
color: $checkout-text-secondary;
line-height: 1.45;
margin-top: 8rpx;
}
.checkout-confirm-inner {
padding: 32rpx 28rpx 12rpx;
}
.checkout-icon-circle {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #ececec;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.checkout-confirm-band {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 24rpx;
width: 100%;
margin-top: 28rpx;
box-sizing: border-box;
}
.checkout-confirm-band--compact {
margin-top: 0;
}
.checkout-confirm-band-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
gap: 12rpx;
}
.checkout-confirm-band-line1 {
font-size: 28rpx;
font-weight: 400;
color: $checkout-text;
line-height: 1.45;
}
.checkout-confirm-band-line2 {
font-size: 24rpx;
color: $checkout-text-secondary;
line-height: 1.35;
}
.checkout-confirm-meta-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
margin-top: 28rpx;
}
.checkout-date-pill {
display: inline-flex;
align-items: center;
padding: 18rpx 32rpx;
font-size: 26rpx;
color: #fff;
background: #000;
border-radius: 999rpx;
font-weight: 600;
line-height: 1.2;
flex-shrink: 0;
}
.checkout-confirm-meta-right {
flex: 1;
min-width: 200rpx;
text-align: right;
}
.checkout-confirm-meta-primary {
font-size: 28rpx;
font-weight: 400;
color: $checkout-text;
line-height: 1.45;
}
.checkout-confirm-meta-strong {
font-weight: 700;
}
.checkout-confirm-meta-gap {
font-size: 20rpx;
}
.checkout-confirm-meta-sub {
margin-top: 12rpx;
font-size: 24rpx;
color: $checkout-text-secondary;
line-height: 1.35;
}
.checkout-confirm-meta-fee {
font-weight: 700;
color: $checkout-text;
}
.checkout-schedule-scroll {
width: 100%;
white-space: nowrap;
margin-top: 28rpx;
}
.checkout-schedule-scroll.checkout-schedule-bleed {
margin-top: 8rpx;
}
.checkout-schedule-inner {
display: inline-flex;
flex-direction: row;
align-items: stretch;
padding: 0 0 8rpx 0;
gap: 20rpx;
}
.checkout-schedule-bleed .checkout-schedule-inner {
padding: 12rpx 28rpx 12rpx;
box-sizing: border-box;
}
.checkout-schedule-card {
display: inline-flex;
flex-direction: column;
justify-content: space-between;
min-width: 460rpx;
max-width: 560rpx;
padding: 24rpx 24rpx 20rpx;
border-radius: 20rpx;
vertical-align: top;
box-sizing: border-box;
}
.checkout-schedule-card-body {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 16rpx;
}
.checkout-schedule-card--on {
border: 2rpx solid #000;
background: #f0f0f0;
}
.checkout-schedule-card--off {
border: 2rpx solid #e5e5e5;
background: #fff;
}
.checkout-radio-dot {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #000;
flex-shrink: 0;
margin-top: 4rpx;
box-sizing: border-box;
position: relative;
}
.checkout-radio-dot::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background: #000;
}
.checkout-radio-ring {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #c4c4c4;
flex-shrink: 0;
margin-top: 4rpx;
box-sizing: border-box;
}
.checkout-schedule-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: $checkout-text;
line-height: 1.35;
}
.checkout-schedule-title--muted {
font-weight: 600;
color: #444;
}
.checkout-schedule-mid {
display: block;
font-size: 26rpx;
color: #444;
margin-top: 12rpx;
line-height: 1.35;
}
.checkout-schedule-hint {
display: block;
font-size: 22rpx;
color: $checkout-text-tertiary;
margin-top: 12rpx;
line-height: 1.45;
}
.checkout-schedule-addon {
display: block;
margin-top: 20rpx;
text-align: right;
font-size: 28rpx;
font-weight: 700;
color: $checkout-text;
letter-spacing: 0.02em;
}
.checkout-thumb-scroll {
width: 100%;
white-space: nowrap;
}
.checkout-thumb-strip {
background: #f5f5f5;
padding: 24rpx 28rpx;
box-sizing: border-box;
}
.checkout-deliver-hint {
padding: 16rpx 28rpx 28rpx;
font-size: 24rpx;
color: $checkout-text-secondary;
line-height: 1.45;
}
.checkout-order-line {
width: 100%;
min-height: 120rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx;
box-sizing: border-box;
border-top: 1rpx solid $checkout-border;
background: #fff;
}
.checkout-qty-badge {
position: absolute;
right: 8rpx;
bottom: 8rpx;
min-width: 36rpx;
padding: 4rpx 10rpx;
font-size: 22rpx;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.82);
border-radius: 8rpx;
text-align: center;
}
.checkout-batch-pill-card {
padding: 28rpx 28rpx;
box-sizing: border-box;
}
.checkout-merchant-head {
padding: 28rpx 24rpx;
display: flex;
align-items: center;
background: #f3f3f3;
border-bottom: 1rpx solid $checkout-border;
}
.checkout-merchant-schedule-row {
padding: 20rpx 24rpx;
border-bottom: 1rpx solid $checkout-border;
}
.checkout-confirm-band-right--full {
width: 100%;
text-align: right;
}
.checkout-merchant-body {
padding: 8rpx 24rpx 16rpx;
}
.checkout-merchant-item {
display: flex;
align-items: flex-start;
padding: 24rpx 0;
}
.checkout-merchant-item.is-divider {
border-bottom: 1rpx solid $checkout-border;
}
.checkout-merchant-footer {
padding: 8rpx 24rpx 28rpx;
border-top: 1rpx solid $checkout-border;
margin-top: 8rpx;
}
.checkout-merchant-tip-block {
border-top: 1rpx solid $checkout-border;
padding-top: 24rpx;
}
.checkout-page-bottom-spacer {
/* 浮窗底栏 + 左右下边距 + 可选省钱条 */
height: 300rpx;
}
.checkout-driver-tip-desc {
display: block;
font-size: 26rpx;
color: $checkout-text-secondary;
line-height: 1.55;
margin-bottom: 28rpx;
}
.checkout-tip-card {
padding: 28rpx 28rpx 32rpx;
box-sizing: border-box;
}
.checkout-tip-card .checkout-driver-tip-desc {
margin-bottom: 24rpx;
}
.checkout-tip-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
width: 100%;
box-sizing: border-box;
}
.checkout-tip-grid--merchant {
gap: 12rpx;
}
.checkout-tip-cell {
min-width: 0;
min-height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.checkout-tip-cell--merchant {
min-height: 64rpx;
}
.checkout-tip-cell--input {
padding: 0 4rpx;
}
.checkout-tip-grid .checkout-tip-pill {
width: 100%;
}
.checkout-tip-pill {
border-radius: 20rpx;
border: 2rpx solid #000;
background: #fff;
color: $checkout-text;
font-weight: 500;
box-sizing: border-box;
}
.checkout-tip-pill--on {
background: #000;
color: #fff;
border-color: #000;
}
.checkout-breakdown {
padding: 32rpx 28rpx 36rpx;
box-sizing: border-box;
}
.checkout-member-line {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 28rpx;
}
.checkout-member-icon {
color: #e8954a;
font-size: 32rpx;
line-height: 1;
flex-shrink: 0;
margin-top: 2rpx;
}
.checkout-member-text {
flex: 1;
font-size: 28rpx;
font-weight: 500;
color: #ce7138;
line-height: 1.45;
}
.checkout-breakdown-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28rpx;
font-size: 28rpx;
line-height: 1.35;
}
.checkout-breakdown-row:last-of-type {
margin-bottom: 0;
}
.checkout-breakdown-row--service .checkout-breakdown-label-wrap {
flex: 1;
min-width: 0;
}
.checkout-breakdown-strike-fee {
font-size: 26rpx;
margin-left: 4rpx;
}
.checkout-breakdown-value-wrap {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
text-align: right;
}
.checkout-breakdown-value-placeholder {
min-width: 20rpx;
}
/* 服务费 $0.00 + 免费标签同一行(勿用 text 包 view,小程序易换行) */
.checkout-breakdown-service-fee-free {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
max-width: 100%;
gap: 8rpx;
white-space: nowrap;
}
.checkout-breakdown-service-fee-free .checkout-breakdown-strike-fee {
margin-left: 0;
flex-shrink: 0;
}
.checkout-breakdown-service-fee-free .checkout-tag-free {
flex-shrink: 0;
}
.checkout-breakdown-label {
color: $checkout-text;
font-weight: 400;
}
.checkout-breakdown-value {
color: $checkout-text;
font-weight: 500;
}
.checkout-breakdown-label-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
}
.checkout-breakdown-info-icon {
width: 28rpx;
height: 28rpx;
opacity: 0.55;
}
.checkout-tag-free {
font-size: 20rpx;
font-weight: 600;
color: #b8860b;
background: #fff4d4;
padding: 6rpx 14rpx;
border-radius: 8rpx;
line-height: 1.2;
}
.checkout-breakdown-row--emphasis {
margin-top: 8rpx;
margin-bottom: 28rpx;
// font-weight: 600;
}
.checkout-breakdown-divider {
height: 1rpx;
background: $checkout-border;
margin: 28rpx 0;
}
.checkout-breakdown-row--total {
margin-bottom: 0;
align-items: baseline;
}
.checkout-breakdown-total-label {
font-size: 30rpx;
font-weight: 700;
color: $checkout-text;
}
.checkout-breakdown-total-value {
font-size: 34rpx;
font-weight: 700;
color: $checkout-text;
}
.checkout-bottom-dock {
position: fixed;
z-index: 9;
left: 0;
right: 0;
bottom: 0;
padding: 0 24rpx 20rpx;
box-sizing: border-box;
pointer-events: none;
}
.checkout-bottom-dock > * {
pointer-events: auto;
}
.checkout-savings-banner {
min-height: 76rpx;
padding: 0 40rpx 0 36rpx;
background: #ce7138;
display: flex;
align-items: center;
}
.checkout-savings-banner--float {
margin-bottom: 16rpx;
border-radius: 28rpx;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.14);
}
.checkout-savings-banner-text {
color: #fff;
font-size: 24rpx;
line-height: 1.4;
}
.checkout-bottom-bar {
position: relative;
border-radius: 78rpx;
overflow: hidden;
box-shadow:
0 16rpx 48rpx rgba(0, 0, 0, 0.12),
0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
/* 毛玻璃层:半透明白 + 背景模糊 */
.checkout-bottom-bar-glass {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: rgba(255, 255, 255, 0.62);
border: 1rpx solid rgba(255, 255, 255, 0.92);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.75);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
}
.checkout-bottom-bar-inner {
position: relative;
z-index: 1;
display: flex;
align-items: center;
// gap: 28rpx;
border-radius: 48rpx;
padding: 24rpx 28rpx 20rpx;
box-sizing: border-box;
}
.checkout-bottom-bar > view:last-child {
position: relative;
z-index: 1;
}
.checkout-bottom-bar-price {
flex: 1;
min-width: 0;
}
.checkout-bottom-bar-price-label {
display: block;
font-size: 26rpx;
color: $checkout-text-secondary;
margin-bottom: 8rpx;
line-height: 1.2;
}
.checkout-bottom-bar-price-num {
font-size: 40rpx;
font-weight: 700;
color: $checkout-text;
line-height: 1.15;
letter-spacing: -0.02em;
}
.checkout-bottom-bar-one-line {
font-size: 30rpx;
font-weight: 600;
color: $checkout-text;
line-height: 1.35;
letter-spacing: 0.01em;
}
:deep(.checkout-pay-btn) {
flex-shrink: 0;
min-width: 260rpx !important;
height: 96rpx !important;
padding: 0 48rpx !important;
font-size: 32rpx !important;
font-weight: 600 !important;
color: #fff !important;
background: #000 !important;
border: none !important;
border-radius: 48rpx !important;
line-height: 96rpx !important;
}
:deep(.checkout-page-navbar) {
background: $checkout-bg !important;
}
:deep(.checkout-page-navbar .wd-navbar__title) {
font-size: 34rpx !important;
font-weight: 700 !important;
color: $checkout-text !important;
}
:deep(.checkout-order-collapse .wd-collapse-item__header) {
padding: 0 !important;
background: transparent !important;
}
:deep(.wd-collapse-item) {
&::after {
display: none;
background-color: transparent !important;
}
}
:deep(.wd-collapse-item__header.is-expanded) {
&::after {
background-color: transparent !important;
}
}
:deep(.wd-collapse-item__body) {
padding: 0 !important;
}
</style>