Files
dukang/apps/h5-user/src/pages/OrderDetailPage.tsx
T
jacy 89fd333702 feat(analytics): persona logging upgrade and Sentry system config
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:30:02 +08:00

518 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import AppToast from '../components/AppToast';
import { request } from '../lib/api';
import { buildOrderAddressSelectUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { handleShareButtonClick } from '../lib/wechat-share';
import ContactCustomerSheet from '../components/ContactCustomerSheet';
import { usePageView } from '../lib/usePageView';
type OrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
type OrderDelivery = {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
providerOrderNo?: string;
};
type TrackNode = {
trackInfo?: string;
createdAt?: string;
};
type OrderPayment = {
status?: string;
};
type Order = {
id: string;
orderNo: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
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, company?: string) {
if (company) return company;
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
if (provider === 'LOGISTICS') 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();
usePageView('order_detail_view', { orderId: id });
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [showCs, setShowCs] = useState(false);
const [copyHint, setCopyHint] = useState('');
const [shareToast, setShareToast] = useState('');
const [confirming, setConfirming] = useState(false);
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT';
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
async function loadTrack() {
if (!id) return;
try {
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
setTrackNodes(data.nodes ?? []);
} catch {
setTrackNodes([]);
}
}
async function loadOrder() {
if (!id) return;
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
setOrder(data);
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
void loadTrack();
}
}
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 canInvoice = order?.status === 'COMPLETED';
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);
}
}
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={() => handleShareButtonClick(setShareToast)}>
<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>
<AppToast message={shareToast} />
<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>
{isProxy && order?.proxyPartnerName ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
代下单 · 由合伙人 {order.proxyPartnerName} 代下
</p>
) : null}
</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, order.delivery?.logisticsCompany)}
</span>
</div>
{order.delivery?.trackingNo && (
<div className="order-detail-kv">
<span>运单号</span>
<span>{order.delivery.trackingNo}</span>
</div>
)}
{order.delivery?.manualQueryUrl && (
<div className="order-detail-kv">
<span>物流查询</span>
<a
href={order.delivery.manualQueryUrl}
target="_blank"
rel="noreferrer"
className="order-detail-link"
>
查看物流
</a>
</div>
)}
</div>
</section>
{trackNodes.length > 0 && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
<span className="material-symbols-outlined">timeline</span>
物流动态
</h3>
<div className="order-detail-kv-list order-detail-kv-list--bordered">
{trackNodes.map((node, index) => (
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
<span>{node.trackInfo || '—'}</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"
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
>
申请售后
</button>
)}
{canInvoice && (
<button
type="button"
className="order-detail-action-outline"
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
>
申请发票
</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>
);
}