Files
dukang/apps/mini-user/src/pages/order-confirm/index.tsx
T
jacy 2a208d5dea
CI / verify (pull_request) Has been cancelled
小程序端界面修改
2026-07-23 15:23:23 +08:00

363 lines
12 KiB
TypeScript
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.
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number | boolean;
};
type PreviewProduct = {
id: string;
name: string;
spec?: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
productAmount: number;
freightPayType: 'COD' | null;
payAmount: number;
benefitAmount: number;
city?: { localMinQty: number; crossMinQty: number };
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
};
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function OrderConfirmPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => {
request<Address[]>('/user/addresses')
.then((list) => {
setAddresses(list);
const fromUrl = checkoutCtx.addressId;
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
setAddressId(fromUrl);
return;
}
const def = list.find((a) => a.isDefault === 1 || a.isDefault === true) || list[0];
if (def) setAddressId(String(def.id));
})
.catch(() => setAddresses([]));
}, [checkoutCtx.addressId]);
useEffect(() => {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
if (!cancelled) {
setPreview(data);
setMsg(data.quantityOk === false ? (data.quantityMessage || '') : '');
}
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
[addresses, addressId],
);
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!addressId && !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < 1) return;
setQuantity(next);
}
async function doSubmit() {
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: {
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
},
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
});
}
async function submit() {
if (!canSubmit) {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!quantityOk) {
setMsg(
isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty} 瓶`,
);
}
return;
}
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
const phoneBound =
!!profile.phoneVerified ||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
if (!phoneBound) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !addressId
? '请选择地址'
: !quantityOk
? `至少购买 ${minQty} 瓶`
: '提交订单';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="确认订单" />
<View className="sub-page-body">
<View
className="order-card"
onClick={() =>
Taro.navigateTo({
url: buildAddressListUrl({
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
})
}
>
<Text className="order-card-title">收货地址</Text>
{selectedAddress ? (
<View>
<View style={{ display: 'flex', gap: '8px', marginBottom: 4 }}>
<Text className="order-card-title" style={{ fontSize: 15 }}>{selectedAddress.receiverName}</Text>
<Text className="u-muted">{maskPhone(selectedAddress.phone)}</Text>
</View>
<Text className="u-muted">{formatAddress(selectedAddress)}</Text>
</View>
) : (
<Text className="u-muted">点击选择收货地址</Text>
)}
</View>
{isCross ? (
<View className="order-card">
<Text className="u-muted">
该地址超出同城配送范围,将由总部物流发货,运费到付
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}
</Text>
</View>
) : null}
{preview ? (
<>
<View className="order-card">
<Text className="order-card-title">商品信息</Text>
<View className="order-product-row">
<View className="order-product-thumb">
{productImage ? (
<Image
className="order-product-thumb-img"
src={productImage}
mode="aspectFill"
/>
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{preview.product.name}</Text>
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
</View>
</View>
<View className="order-qty-row">
<Text>购买数量</Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity + 1)}
>
<Text></Text>
</View>
</View>
</View>
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View>
<View className="order-card">
<Text className="order-card-title">费用明细</Text>
<View className="order-row">
<Text className="order-row-label">商品金额</Text>
<Text className="order-row-value">¥{preview.productAmount.toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label">好客权益</Text>
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label">运费</Text>
<Text className="order-row-value">{isCross ? '到付' : '免运费'}</Text>
</View>
</View>
</>
) : null}
{previewLoading && !preview && productId ? (
<View className="u-empty">加载订单信息…</View>
) : null}
{!previewLoading && !preview && productId ? (
<View className="u-empty">无法加载商品信息</View>
) : null}
{msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
{msg}
</Text>
) : null}
</View>
<View className="order-confirm-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label">应付合计</Text>
<Text className="order-confirm-total-value">
¥{preview ? preview.payAmount.toFixed(2) : '—'}
</Text>
</View>
<View
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{submitLabel}</Text>
</View>
</View>
</PageShell>
);
}