小程序修改
This commit is contained in:
@@ -3,126 +3,289 @@ import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
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;
|
||||
benefitDisplay?: number;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
productAmount: number;
|
||||
freightPayType: 'COD' | null;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
city?: { localMinQty: number; crossMinQty: number };
|
||||
};
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function OrderConfirmPage() {
|
||||
const router = useRouter();
|
||||
const productId = router.params.productId ?? '';
|
||||
const initialQty = Math.max(2, Number(router.params.qty || 2));
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [qty, setQty] = useState(initialQty);
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const productId = checkoutCtx.productId ?? '';
|
||||
const forceCross = checkoutCtx.cross === true;
|
||||
const [quantity, setQuantity] = useState(Math.max(2, 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('');
|
||||
|
||||
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;
|
||||
request<Product>(`/catalog/products/${productId}`)
|
||||
.then(setProduct)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [productId]);
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
||||
productId,
|
||||
quantity,
|
||||
};
|
||||
if (addressId) body.addressId = addressId;
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
return Number(product.price) * qty;
|
||||
}, [product, qty]);
|
||||
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
setMsg('');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
|
||||
const benefit = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
return Number(product.benefitDisplay ?? product.price) * qty;
|
||||
}, [product, qty]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity, addressId]);
|
||||
|
||||
function changeQty(delta: number) {
|
||||
setQty((q) => Math.max(2, q + delta));
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((a) => String(a.id) === addressId),
|
||||
[addresses, addressId],
|
||||
);
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < minQty) {
|
||||
setMsg(
|
||||
!isCross
|
||||
? `同城配送至少购买 ${minQty} 瓶`
|
||||
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!productId) return;
|
||||
Taro.navigateTo({
|
||||
url: `/pages/pay/index?productId=${productId}&qty=${qty}&amount=${total.toFixed(2)}`,
|
||||
async function doSubmit() {
|
||||
const order = await request<{ id: string }>('/trade/orders', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
productId,
|
||||
quantity,
|
||||
addressId,
|
||||
},
|
||||
});
|
||||
Taro.redirectTo({
|
||||
url: buildPayUrl({
|
||||
orderId: order.id,
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
addressId,
|
||||
cross: forceCross,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
return;
|
||||
}
|
||||
|
||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
|
||||
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) : '';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="确认订单" />
|
||||
<View className="sub-page-body">
|
||||
<View
|
||||
className="order-card"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/addresses/index' })}
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: buildAddressListUrl({
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
addressId,
|
||||
cross: forceCross,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="order-card-title">收货地址</Text>
|
||||
<Text className="u-muted">点击选择收货地址(同城起购 2 瓶)</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>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
{product ? (
|
||||
<View>
|
||||
{isCross ? (
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">
|
||||
该地址超出同城配送范围,将由总部物流发货,运费到付。
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{getProductMainImage(product) ? (
|
||||
{productImage ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={getProductMainImage(product)}
|
||||
src={productImage}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{product.name}</Text>
|
||||
<Text className="order-product-price">¥{Number(product.price).toFixed(2)}</Text>
|
||||
<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" onClick={() => changeQty(-1)}>
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{qty}</Text>
|
||||
<View className="order-qty-btn" onClick={() => changeQty(1)}>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity + 1)}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="u-empty">加载中…</View>
|
||||
)}
|
||||
</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">¥{total.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{benefit.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">到付</Text>
|
||||
</View>
|
||||
</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 }}>{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">¥{total.toFixed(2)}</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{preview ? preview.payAmount.toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={submit}>
|
||||
<Text>提交订单</Text>
|
||||
<View
|
||||
className="order-confirm-submit"
|
||||
onClick={() => !loading && void submit()}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
Reference in New Issue
Block a user