443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import AppImage from '@dukang/shared-ui/AppImage';
|
|
import { request } from '../lib/api';
|
|
import { buildOrderAddressSelectUrl } from '../lib/navigation';
|
|
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
|
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
|
|
|
type OrderItem = {
|
|
productName: string;
|
|
productSpec: string;
|
|
productImage: string;
|
|
unitPrice: number;
|
|
quantity: number;
|
|
};
|
|
|
|
type OrderDelivery = {
|
|
provider?: string;
|
|
};
|
|
|
|
type OrderPayment = {
|
|
status?: string;
|
|
};
|
|
|
|
type Order = {
|
|
id: string;
|
|
orderNo: string;
|
|
orderType?: string;
|
|
status: string;
|
|
originOrderId?: string | null;
|
|
remark?: string | null;
|
|
receiverName: string;
|
|
receiverPhone: string;
|
|
receiverProvince?: string;
|
|
receiverCity?: string;
|
|
receiverDistrict?: string;
|
|
receiverAddress: string;
|
|
productAmount?: number;
|
|
freightAmount?: number;
|
|
payAmount: number;
|
|
benefitAmount: number;
|
|
createdAt: string;
|
|
items?: OrderItem[];
|
|
delivery?: OrderDelivery | null;
|
|
payment?: OrderPayment | null;
|
|
};
|
|
|
|
const PROGRESS_STEPS = [
|
|
{ label: '下单成功' },
|
|
{ label: '出库中' },
|
|
{ label: '配送中' },
|
|
{ label: '待签收' },
|
|
{ label: '完成' },
|
|
] as const;
|
|
|
|
const STATUS_BANNER: Record<string, { title: string; subtitle: string }> = {
|
|
PENDING_PAY: { title: '待付款', subtitle: '请尽快完成支付' },
|
|
PENDING_SHIP: { title: '待发货', subtitle: '商家正在备货,请耐心等待' },
|
|
OUT_WAREHOUSE: { title: '出库中', subtitle: '商品正在出库打包' },
|
|
SHIPPING: { title: '配送中', subtitle: '您的佳酿正在赶往您的餐桌,请保持电话畅通' },
|
|
PENDING_RECEIVE: { title: '待签收', subtitle: '包裹已送达,请确认签收' },
|
|
COMPLETED: { title: '已完成', subtitle: '感谢您的购买,期待再次光临' },
|
|
RESHIP: { title: '补发中', subtitle: '包裹正在揽收,请耐心等待' },
|
|
};
|
|
|
|
const EDITABLE_STATUSES = new Set(['PENDING_PAY', 'PENDING_SHIP', 'OUT_WAREHOUSE']);
|
|
|
|
function maskPhone(phone: string) {
|
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
|
}
|
|
|
|
function formatMoney(amount: number) {
|
|
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
function formatDateTime(value: string) {
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return value;
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
function progressActiveIndex(status: string) {
|
|
switch (status) {
|
|
case 'PENDING_PAY':
|
|
return 0;
|
|
case 'PENDING_SHIP':
|
|
return 1;
|
|
case 'OUT_WAREHOUSE':
|
|
return 1;
|
|
case 'SHIPPING':
|
|
return 2;
|
|
case 'PENDING_RECEIVE':
|
|
return 3;
|
|
case 'COMPLETED':
|
|
return 4;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function deliveryProviderLabel(provider?: string) {
|
|
if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送';
|
|
return provider;
|
|
}
|
|
|
|
function fullReceiverAddress(order: Order) {
|
|
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
|
.filter(Boolean)
|
|
.join('');
|
|
if (region && order.receiverAddress.startsWith(region)) {
|
|
return order.receiverAddress;
|
|
}
|
|
return `${region}${order.receiverAddress}`;
|
|
}
|
|
|
|
export default function OrderDetailPage() {
|
|
const { id } = useParams();
|
|
const [params] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const [order, setOrder] = useState<Order | null>(null);
|
|
const [showCs, setShowCs] = useState(false);
|
|
const [copyHint, setCopyHint] = useState('');
|
|
const [confirming, setConfirming] = useState(false);
|
|
|
|
const isReship = order?.orderType === 'RESHIPMENT' || params.get('type') === 'reship';
|
|
|
|
async function loadOrder() {
|
|
if (!id) return;
|
|
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
|
|
setOrder(data);
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadOrder().catch(() => {});
|
|
}, [id]);
|
|
|
|
const banner = useMemo(() => {
|
|
if (!order) return { title: '', subtitle: '' };
|
|
if (isReship) return STATUS_BANNER.RESHIP;
|
|
return STATUS_BANNER[order.status] ?? { title: order.status, subtitle: '' };
|
|
}, [order, isReship]);
|
|
|
|
const activeProgress = order ? progressActiveIndex(order.status) : 0;
|
|
const item = order?.items?.[0];
|
|
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
|
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
|
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
|
const canPay = order?.status === 'PENDING_PAY' && !isReship;
|
|
const canRefund =
|
|
order &&
|
|
!canPay &&
|
|
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
|
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
|
const freightTotal = Number(order?.freightAmount ?? 0);
|
|
|
|
async function copyOrderNo() {
|
|
if (!order) return;
|
|
try {
|
|
await navigator.clipboard.writeText(order.orderNo);
|
|
setCopyHint('已复制');
|
|
window.setTimeout(() => setCopyHint(''), 2000);
|
|
} catch {
|
|
setCopyHint('复制失败');
|
|
}
|
|
}
|
|
|
|
async function confirmReceive() {
|
|
if (!id || !canConfirmReceive) return;
|
|
setConfirming(true);
|
|
try {
|
|
const updated = await request<Order>('USER_H5', `/trade/orders/${id}/confirm-receive`, {
|
|
method: 'POST',
|
|
});
|
|
setOrder(updated);
|
|
} catch (e) {
|
|
window.alert(e instanceof Error ? e.message : '确认收货失败');
|
|
} finally {
|
|
setConfirming(false);
|
|
}
|
|
}
|
|
|
|
async function requestRefund() {
|
|
if (!id || !canRefund) return;
|
|
setConfirming(true);
|
|
try {
|
|
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ remark: '用户申请退款' }),
|
|
});
|
|
await loadOrder();
|
|
} catch (e) {
|
|
window.alert(e instanceof Error ? e.message : '申请退款失败');
|
|
} finally {
|
|
setConfirming(false);
|
|
}
|
|
}
|
|
|
|
if (!order) return <div className="empty">加载中...</div>;
|
|
|
|
return (
|
|
<div className="order-detail-page order-detail-page--stitch">
|
|
<header className="order-detail-topbar">
|
|
<button type="button" className="order-detail-topbar-btn" aria-label="返回" onClick={() => navigate(-1)}>
|
|
<span className="material-symbols-outlined">arrow_back_ios</span>
|
|
</button>
|
|
<h1 className="order-detail-topbar-title">杜康好客</h1>
|
|
<div className="order-detail-topbar-actions">
|
|
<button type="button" className="order-detail-topbar-btn" aria-label="分享" onClick={() => {}}>
|
|
<span className="material-symbols-outlined">share</span>
|
|
</button>
|
|
<button type="button" className="order-detail-topbar-btn" aria-label="更多" onClick={() => {}}>
|
|
<span className="material-symbols-outlined">more_horiz</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="order-detail-main order-detail-main--stitch">
|
|
<section className="order-detail-status-card">
|
|
<div className="order-detail-status-deco" aria-hidden>
|
|
<span className="material-symbols-outlined">local_shipping</span>
|
|
</div>
|
|
<div className="order-detail-status-body">
|
|
<div className="order-detail-status-head">
|
|
<span className="material-symbols-outlined filled">local_shipping</span>
|
|
<h2>{banner.title}</h2>
|
|
</div>
|
|
<p>{banner.subtitle}</p>
|
|
</div>
|
|
</section>
|
|
|
|
{!isReship && (
|
|
<section className="order-detail-card order-detail-progress-card">
|
|
<div className="order-detail-progress">
|
|
{PROGRESS_STEPS.map((step, index) => {
|
|
const done = index < activeProgress;
|
|
const active = index === activeProgress;
|
|
const pending = index > activeProgress;
|
|
return (
|
|
<div
|
|
key={step.label}
|
|
className={`order-detail-progress-step${active ? ' active' : ''}${done ? ' done' : ''}${pending ? ' pending' : ''}`}
|
|
>
|
|
{index < PROGRESS_STEPS.length - 1 && (
|
|
<span className={`order-detail-progress-line${done ? ' done' : ''}`} aria-hidden />
|
|
)}
|
|
<div className="order-detail-progress-dot">
|
|
{done && <span className="material-symbols-outlined">check</span>}
|
|
{active && <span className="order-detail-progress-pulse" />}
|
|
{pending && <span className="order-detail-progress-idle" />}
|
|
</div>
|
|
<span className="order-detail-progress-label">{step.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{isReship && (
|
|
<section className="order-detail-card order-detail-reship-info">
|
|
<div className="order-detail-kv">
|
|
<span>补发原因</span>
|
|
<span>{order.remark || '商品破损'}</span>
|
|
</div>
|
|
<div className="order-detail-kv">
|
|
<span>关联原订单</span>
|
|
{order.originOrderId ? (
|
|
<Link to={`/orders/${order.originOrderId}`} className="order-detail-link">
|
|
查看原订单
|
|
</Link>
|
|
) : (
|
|
<span>—</span>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{item && (
|
|
<section className="order-detail-card">
|
|
<div className="order-detail-product">
|
|
<div className="order-detail-product-thumb">
|
|
<AppImage src={productImage} alt={item.productName} wrapperClassName="app-image--fill" />
|
|
</div>
|
|
<div className="order-detail-product-info">
|
|
<div>
|
|
<h3 className="order-detail-product-name">{item.productName}</h3>
|
|
<p className="order-detail-product-spec">规格:{item.productSpec}</p>
|
|
</div>
|
|
<div className="order-detail-product-meta">
|
|
<span className="order-detail-product-price">
|
|
<small>¥</small>
|
|
{formatMoney(isReship ? 0 : Number(item.unitPrice)).replace('.00', '')}
|
|
</span>
|
|
<span className="order-detail-product-qty">x{item.quantity}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{Number(order.benefitAmount) > 0 && !isReship && (
|
|
<div className="order-detail-benefit-panel">
|
|
<div className="order-detail-benefit-panel-left">
|
|
<div className="order-detail-benefit-icon">
|
|
<span className="material-symbols-outlined filled">confirmation_number</span>
|
|
</div>
|
|
<div>
|
|
<div className="order-detail-benefit-amount">
|
|
<span>¥{Math.round(Number(order.benefitAmount))}</span>
|
|
<span>好客权益</span>
|
|
</div>
|
|
<p className="order-detail-benefit-sub">全城100+餐厅通用</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" className="order-detail-benefit-use" onClick={() => navigate('/redeem')}>
|
|
去使用
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
<section className="order-detail-card">
|
|
<div className="order-detail-card-head">
|
|
<h3>
|
|
<span className="material-symbols-outlined">location_on</span>
|
|
收货信息
|
|
</h3>
|
|
{canEditAddress && id && (
|
|
<button
|
|
type="button"
|
|
className="order-detail-edit-btn"
|
|
onClick={() => navigate(buildOrderAddressSelectUrl(id))}
|
|
>
|
|
<span className="material-symbols-outlined">edit</span>
|
|
修改
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="order-detail-kv-list">
|
|
<div className="order-detail-kv">
|
|
<span>收货人</span>
|
|
<span>
|
|
{order.receiverName} {maskPhone(order.receiverPhone)}
|
|
</span>
|
|
</div>
|
|
<div className="order-detail-kv order-detail-kv--address">
|
|
<span>收货地址</span>
|
|
<span>{fullReceiverAddress(order)}</span>
|
|
</div>
|
|
<div className="order-detail-kv">
|
|
<span>配送方式</span>
|
|
<span>{deliveryProviderLabel(order.delivery?.provider)}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{!isReship && (
|
|
<section className="order-detail-card">
|
|
<h3 className="order-detail-card-title">
|
|
<span className="material-symbols-outlined">info</span>
|
|
订单信息
|
|
</h3>
|
|
<div className="order-detail-kv-list order-detail-kv-list--bordered">
|
|
<div className="order-detail-kv">
|
|
<span>订单编号</span>
|
|
<span className="order-detail-kv-inline">
|
|
<span className="order-detail-order-no">{order.orderNo}</span>
|
|
<button type="button" className="order-detail-copy-btn" onClick={copyOrderNo}>
|
|
{copyHint || '复制'}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<div className="order-detail-kv">
|
|
<span>创建时间</span>
|
|
<span>{formatDateTime(order.createdAt)}</span>
|
|
</div>
|
|
<div className="order-detail-kv">
|
|
<span>支付方式</span>
|
|
<span>{order.payment?.status === 'SUCCESS' ? '微信支付' : '—'}</span>
|
|
</div>
|
|
</div>
|
|
<div className="order-detail-summary">
|
|
<div className="order-detail-kv order-detail-kv--muted">
|
|
<span>商品总额</span>
|
|
<span>¥{formatMoney(productTotal)}</span>
|
|
</div>
|
|
<div className="order-detail-kv order-detail-kv--muted">
|
|
<span>运费</span>
|
|
<span>¥{formatMoney(freightTotal)}</span>
|
|
</div>
|
|
<div className="order-detail-kv order-detail-kv--total">
|
|
<span>实付金额</span>
|
|
<span className="order-detail-pay-total">¥{formatMoney(Number(order.payAmount))}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
<footer className="order-detail-actionbar">
|
|
<button type="button" className="order-detail-action-outline" onClick={() => setShowCs(true)}>
|
|
<span className="material-symbols-outlined">headset_mic</span>
|
|
联系客服
|
|
</button>
|
|
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
|
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
|
|
申请退款
|
|
</button>
|
|
)}
|
|
{canPay && (
|
|
<button
|
|
type="button"
|
|
className="order-detail-action-primary"
|
|
onClick={() => navigate(`/pay?orderId=${order.id}`)}
|
|
>
|
|
<span className="material-symbols-outlined">payments</span>
|
|
去付款 ¥{formatMoney(Number(order.payAmount))}
|
|
</button>
|
|
)}
|
|
{canConfirmReceive && (
|
|
<button
|
|
type="button"
|
|
className="order-detail-action-primary"
|
|
disabled={confirming}
|
|
onClick={confirmReceive}
|
|
>
|
|
<span className="material-symbols-outlined">inventory</span>
|
|
{confirming ? '提交中...' : '确认收货'}
|
|
</button>
|
|
)}
|
|
</footer>
|
|
|
|
{showCs && (
|
|
<ContactCustomerSheet
|
|
orderId={order.id}
|
|
orderNo={order.orderNo}
|
|
onClose={() => setShowCs(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|