小程序修改

This commit is contained in:
2026-07-12 19:44:36 +08:00
parent 3b4833b51a
commit e2dfb08de3
45 changed files with 2028 additions and 347 deletions
+222 -59
View File
@@ -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>