This commit is contained in:
2026-06-30 10:33:56 +08:00
commit 6e047dc0a5
607 changed files with 65966 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { getProductMainImage } from '../lib/product-images';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number;
};
type PreviewProduct = {
id: string;
name: string;
spec: string;
subtitle?: string;
price: 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 maskPhone(phone: string) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function OrderConfirmPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const productId = params.get('productId') || '';
const forceCross = params.get('cross') === '1';
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(params.get('addressId') || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
request<Address[]>('USER_H5', '/user/addresses').then((list) => {
setAddresses(list);
const fromUrl = params.get('addressId');
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
setAddressId(fromUrl);
return;
}
const def = list.find((a) => a.isDefault === 1) || list[0];
if (def) setAddressId(String(def.id));
});
}, [params]);
useEffect(() => {
if (!productId || !addressId) return;
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
method: 'POST',
body: JSON.stringify({ productId, quantity, addressId }),
})
.then(setPreview)
.catch((e) => setMsg(e instanceof Error ? e.message : String(e)));
}, [productId, quantity, addressId]);
function updateQuantity(next: number) {
const delivery = forceCross || preview?.deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL';
const localMin = preview?.city?.localMinQty ?? 2;
const crossMin = preview?.city?.crossMinQty ?? 6;
const min = delivery === 'LOCAL' ? localMin : crossMin;
if (next < min) {
setMsg(
delivery === 'LOCAL'
? `同城配送至少购买 ${min}`
: `跨城配送至少购买 ${min} 瓶(1箱)`,
);
return;
}
setMsg('');
setQuantity(next);
const qs = new URLSearchParams(params);
qs.set('qty', String(next));
navigate({ search: qs.toString() }, { replace: true });
}
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);
const productIndex = productId ? Math.max(0, Number(productId) - 1) : 0;
const productImage =
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
setLoading(true);
setMsg('');
try {
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST',
body: JSON.stringify({ productId, quantity, addressId }),
});
const qs = new URLSearchParams();
qs.set('orderId', order.id);
qs.set('productId', productId);
qs.set('qty', String(quantity));
qs.set('addressId', addressId);
if (forceCross) qs.set('cross', '1');
navigate(`/pay?${qs.toString()}`);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
return (
<div className="order-confirm-page">
<SubPageHeader
title="确认订单"
onBack={() => navigate(buildProductDetailUrl(productId))}
/>
<main className="order-confirm-main sub-page-body">
<button
type="button"
className="order-confirm-card order-confirm-address"
onClick={() => {
const qs = new URLSearchParams();
if (productId) qs.set('productId', productId);
qs.set('qty', String(quantity));
if (forceCross) qs.set('cross', '1');
if (addressId) qs.set('addressId', addressId);
qs.set('select', '1');
navigate(`/addresses?${qs.toString()}`);
}}
>
<span className="material-symbols-outlined order-confirm-pin fill-icon">location_on</span>
{selectedAddress ? (
<div className="order-confirm-address-body">
<div className="order-confirm-address-row">
<span className="order-confirm-address-name">{selectedAddress.receiverName}</span>
<span className="order-confirm-address-phone">{maskPhone(selectedAddress.phone)}</span>
</div>
<p className="order-confirm-address-detail">{formatAddress(selectedAddress)}</p>
</div>
) : (
<span className="order-confirm-address-placeholder"></span>
)}
<span className="material-symbols-outlined order-confirm-chevron">chevron_right</span>
</button>
{isCross && (
<div className="order-confirm-card order-confirm-warning">
<span className="material-symbols-outlined order-confirm-warning-icon">warning</span>
<p className="order-confirm-warning-text">
</p>
</div>
)}
{preview && (
<>
<section className="order-confirm-card order-confirm-product">
<div className="order-confirm-product-thumb">
<AppImage
src={productImage}
alt={preview.product.name}
wrapperClassName="app-image--fill"
/>
</div>
<div className="order-confirm-product-info">
<div>
<h3 className="order-confirm-product-name">{preview.product.name}</h3>
<p className="order-confirm-product-spec">
{preview.product.spec || preview.product.subtitle}
</p>
</div>
<div className="order-confirm-product-meta">
<span className="order-confirm-product-price">¥{preview.product.price}</span>
<div className="order-confirm-qty-stepper">
<button
type="button"
className="order-confirm-qty-btn"
disabled={quantity <= minQty}
aria-label="减少数量"
onClick={() => updateQuantity(quantity - 1)}
>
<span className="material-symbols-outlined">remove</span>
</button>
<span className="order-confirm-qty-value">{quantity}</span>
<button
type="button"
className="order-confirm-qty-btn order-confirm-qty-btn--plus"
aria-label="增加数量"
onClick={() => updateQuantity(quantity + 1)}
>
<span className="material-symbols-outlined">add</span>
</button>
</div>
</div>
</div>
</section>
<section className="order-confirm-card order-confirm-benefit coupon-notch">
<div className="order-confirm-benefit-inner">
<span className="order-confirm-benefit-badge"></span>
<span className="order-confirm-benefit-text">
¥{preview.benefitAmount}
</span>
</div>
</section>
<section className="order-confirm-card order-confirm-row-card">
<span className="order-confirm-row-label"></span>
<div className="order-confirm-delivery-value">
<p className="order-confirm-row-value">
{isCross ? '物流配送' : '小飞侠配送'}
</p>
{!isCross && (
<p className="order-confirm-delivery-hint">24</p>
)}
</div>
</section>
<section className="order-confirm-card order-confirm-summary">
<div className="order-confirm-summary-line">
<span></span>
<span>¥{preview.productAmount}</span>
</div>
<div className="order-confirm-summary-line">
<span></span>
<span className={isCross ? 'order-confirm-freight-cod' : ''}>
{isCross ? '到付' : '免运费'}
</span>
</div>
<div className="order-confirm-summary-total">
<span></span>
<span className="order-confirm-total-amount">¥{preview.payAmount}</span>
</div>
</section>
</>
)}
{!preview && productId && addressId && (
<div className="order-confirm-loading">...</div>
)}
{msg && <p className="order-confirm-msg">{msg}</p>}
</main>
<footer className="order-confirm-footer">
<div className="order-confirm-footer-inner">
<div className="order-confirm-pay-label">
<span className="order-confirm-pay-prefix">:</span>
<span className="order-confirm-pay-amount">
¥{preview?.payAmount ?? '—'}
</span>
</div>
<button
type="button"
className="order-confirm-pay-btn"
disabled={loading || !preview}
onClick={submit}
>
{loading ? '支付中...' : '微信支付'}
</button>
</div>
</footer>
</div>
);
}