422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Checkbox,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
QRCode,
|
|
Radio,
|
|
Select,
|
|
Space,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type {
|
|
HqProxyOrderCreateRequest,
|
|
PartnerProxyDeliveryMode,
|
|
PartnerProxyOrderOptions,
|
|
PartnerProxyOrderPreviewResult,
|
|
ProxyOrderCreateResponse,
|
|
ProxyOrderPayResponse,
|
|
} from '@dukang/shared-types';
|
|
import ChinaRegionCascader from './ChinaRegionCascader';
|
|
import { parseRegionCodes } from '../lib/china-region';
|
|
import { request } from '../lib/api';
|
|
|
|
type ProxyOrderModalProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSuccess: (order: { id: string; orderNo: string }) => void;
|
|
};
|
|
|
|
type PayStatus = {
|
|
id: string;
|
|
orderNo: string;
|
|
status: string;
|
|
payStatus: string;
|
|
};
|
|
|
|
function fmtMoney(n: number) {
|
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
|
|
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
|
const [loadingOptions, setLoadingOptions] = useState(false);
|
|
const [phone, setPhone] = useState('');
|
|
const [receiverName, setReceiverName] = useState('');
|
|
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
|
const [addressDetail, setAddressDetail] = useState('');
|
|
const [productId, setProductId] = useState<string>();
|
|
const [quantity, setQuantity] = useState(2);
|
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
|
const [autoReceive, setAutoReceive] = useState(false);
|
|
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const [step, setStep] = useState<'form' | 'pay'>('form');
|
|
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
|
|
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
|
const [payLoading, setPayLoading] = useState(false);
|
|
const [mockConfirming, setMockConfirming] = useState(false);
|
|
const pollRef = useRef<number | null>(null);
|
|
|
|
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
|
|
|
function stopPoll() {
|
|
if (pollRef.current != null) {
|
|
window.clearInterval(pollRef.current);
|
|
pollRef.current = null;
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setLoadingOptions(true);
|
|
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
|
|
.then((data) => {
|
|
setOptions(data);
|
|
if (data.products[0]) setProductId(data.products[0].id);
|
|
})
|
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
|
|
.finally(() => setLoadingOptions(false));
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !productId || quantity < 1 || step !== 'form') {
|
|
if (step === 'form') setPreview(null);
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
setPreviewLoading(true);
|
|
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
productId,
|
|
quantity,
|
|
deliveryMode,
|
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
|
}),
|
|
})
|
|
.then(setPreview)
|
|
.catch(() => setPreview(null))
|
|
.finally(() => setPreviewLoading(false));
|
|
}, 300);
|
|
return () => window.clearTimeout(timer);
|
|
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
|
|
|
useEffect(() => () => stopPoll(), []);
|
|
|
|
function resetForm() {
|
|
stopPoll();
|
|
setPhone('');
|
|
setReceiverName('');
|
|
setRegionCodes([]);
|
|
setAddressDetail('');
|
|
setQuantity(2);
|
|
setPromoCodeId(undefined);
|
|
setDeliveryMode('ADDRESS');
|
|
setAutoReceive(false);
|
|
setPreview(null);
|
|
setProductId(options?.products[0]?.id);
|
|
setStep('form');
|
|
setCreated(null);
|
|
setCodeUrl(null);
|
|
setPayLoading(false);
|
|
setMockConfirming(false);
|
|
}
|
|
|
|
function handleClose() {
|
|
resetForm();
|
|
onClose();
|
|
}
|
|
|
|
function validateForm(): string | null {
|
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
|
if (!productId) return '请选择商品';
|
|
if (deliveryMode === 'ADDRESS') {
|
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
|
if (!addressDetail.trim()) return '请填写详细地址';
|
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
|
} else {
|
|
const product = options?.products.find((p) => p.id === productId);
|
|
if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货';
|
|
}
|
|
if (!preview) return '请等待费用计算完成';
|
|
return null;
|
|
}
|
|
|
|
function startPoll(orderId: string) {
|
|
stopPoll();
|
|
pollRef.current = window.setInterval(() => {
|
|
void request<PayStatus>(`/admin/proxy-orders/${orderId}/pay-status`)
|
|
.then((st) => {
|
|
if (st.payStatus === 'PAID') {
|
|
stopPoll();
|
|
message.success(`支付成功:${st.orderNo}`);
|
|
const payload = { id: st.id, orderNo: st.orderNo };
|
|
resetForm();
|
|
onSuccess(payload);
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
}, 2000);
|
|
}
|
|
|
|
async function submit() {
|
|
const err = validateForm();
|
|
if (err) {
|
|
message.warning(err);
|
|
return;
|
|
}
|
|
|
|
const payload: HqProxyOrderCreateRequest = {
|
|
phone: phone.trim(),
|
|
deliveryMode,
|
|
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
|
receiverName: receiverName.trim() || undefined,
|
|
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
|
|
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
|
|
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
|
|
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
|
|
productId: productId!,
|
|
quantity,
|
|
promoCodeId: promoCodeId || undefined,
|
|
};
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const order = await request<ProxyOrderCreateResponse>('/admin/proxy-orders', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
setCreated(order);
|
|
setStep('pay');
|
|
setPayLoading(true);
|
|
const pay = await request<ProxyOrderPayResponse>(`/admin/proxy-orders/${order.id}/pay`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
|
});
|
|
setCodeUrl(pay.codeUrl ?? null);
|
|
startPoll(order.id);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '下单失败');
|
|
setStep('form');
|
|
setCreated(null);
|
|
} finally {
|
|
setSubmitting(false);
|
|
setPayLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleMockConfirm() {
|
|
if (!created) return;
|
|
setMockConfirming(true);
|
|
try {
|
|
const st = await request<PayStatus>(`/admin/proxy-orders/${created.id}/pay/mock-confirm`, {
|
|
method: 'POST',
|
|
body: '{}',
|
|
});
|
|
stopPoll();
|
|
message.success(`支付成功:${st.orderNo}`);
|
|
const payload = { id: st.id, orderNo: st.orderNo };
|
|
resetForm();
|
|
onSuccess(payload);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '模拟支付失败');
|
|
} finally {
|
|
setMockConfirming(false);
|
|
}
|
|
}
|
|
|
|
const deliveryLabel =
|
|
preview?.deliveryType === 'ON_SITE_PICKUP'
|
|
? '现场提货'
|
|
: preview?.deliveryType === 'CROSS_CITY'
|
|
? '跨城配送'
|
|
: '同城配送';
|
|
|
|
return (
|
|
<Modal
|
|
title={step === 'pay' ? '代下单 · 待支付' : '代下单'}
|
|
open={open}
|
|
onCancel={handleClose}
|
|
width={640}
|
|
destroyOnClose
|
|
footer={
|
|
step === 'pay' ? (
|
|
<Space>
|
|
<Button onClick={handleClose}>关闭</Button>
|
|
<Button type="primary" loading={mockConfirming} onClick={() => void handleMockConfirm()}>
|
|
模拟支付成功
|
|
</Button>
|
|
</Space>
|
|
) : (
|
|
<Space>
|
|
<Button onClick={handleClose}>取消</Button>
|
|
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
|
|
提交并收款
|
|
</Button>
|
|
</Space>
|
|
)
|
|
}
|
|
>
|
|
{step === 'pay' && created ? (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16, textAlign: 'left' }}
|
|
message={`订单 ${created.orderNo} 待支付 ¥${fmtMoney(created.payAmount)},请扫码支付`}
|
|
description={
|
|
created.deliveryType === 'ON_SITE_PICKUP'
|
|
? '支付完成后将进入现场提货闭环'
|
|
: '支付完成后将进入待发货,由总部履约发货'
|
|
}
|
|
/>
|
|
{payLoading && !codeUrl ? (
|
|
<Typography.Text type="secondary">正在生成收款码…</Typography.Text>
|
|
) : codeUrl ? (
|
|
<Space direction="vertical" size={12} align="center">
|
|
<QRCode value={codeUrl} size={200} />
|
|
<Typography.Text type="secondary">请使用微信扫一扫完成支付(本地可用下方模拟支付)</Typography.Text>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
|
{codeUrl}
|
|
</Typography.Text>
|
|
</Space>
|
|
) : (
|
|
<Typography.Text type="danger">收款码生成失败,请关闭后重试</Typography.Text>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
message="提交后生成微信收款码,支付成功后发放好客权益;配送单进入待发货"
|
|
/>
|
|
|
|
<Form layout="vertical">
|
|
<Form.Item label="用户手机号" required>
|
|
<Input
|
|
placeholder="11 位手机号"
|
|
value={phone}
|
|
maxLength={11}
|
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="酒品" required>
|
|
<Select
|
|
showSearch
|
|
optionFilterProp="label"
|
|
loading={loadingOptions}
|
|
placeholder="选择商品"
|
|
value={productId}
|
|
onChange={setProductId}
|
|
options={(options?.products ?? []).map((p) => ({
|
|
value: p.id,
|
|
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="数量" required>
|
|
<InputNumber
|
|
min={1}
|
|
value={quantity}
|
|
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Form.Item>
|
|
|
|
{(options?.promoCodes.length ?? 0) > 0 ? (
|
|
<Form.Item label="绑定推广码(选填)">
|
|
<Select
|
|
allowClear
|
|
placeholder="不绑定"
|
|
value={promoCodeId}
|
|
onChange={setPromoCodeId}
|
|
options={(options?.promoCodes ?? []).map((p) => ({
|
|
value: p.id,
|
|
label: `${p.code} · ${p.name}`,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
) : null}
|
|
|
|
<Form.Item label="履约方式" required>
|
|
<Radio.Group
|
|
value={deliveryMode}
|
|
onChange={(e) => {
|
|
setDeliveryMode(e.target.value);
|
|
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
|
|
}}
|
|
options={[
|
|
{ value: 'ADDRESS', label: '配送到址' },
|
|
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
|
|
{deliveryMode === 'ADDRESS' ? (
|
|
<>
|
|
<Form.Item label="收货人(选填)">
|
|
<Input
|
|
placeholder="默认:用户+手机尾号"
|
|
value={receiverName}
|
|
maxLength={32}
|
|
onChange={(e) => setReceiverName(e.target.value)}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item label="省市区" required>
|
|
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
|
|
</Form.Item>
|
|
<Form.Item label="详细地址" required>
|
|
<Input.TextArea
|
|
rows={2}
|
|
placeholder="街道门牌等"
|
|
value={addressDetail}
|
|
maxLength={256}
|
|
onChange={(e) => setAddressDetail(e.target.value)}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
|
|
同意自动收货(配送到址必选)
|
|
</Checkbox>
|
|
</Form.Item>
|
|
</>
|
|
) : null}
|
|
|
|
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
|
|
<Typography.Text type="secondary">费用预览</Typography.Text>
|
|
{previewLoading ? (
|
|
<div>计算中…</div>
|
|
) : preview ? (
|
|
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
|
|
<div>履约:{deliveryLabel}</div>
|
|
<div>商品金额:¥{fmtMoney(preview.productAmount)}</div>
|
|
<div>实付:¥{fmtMoney(preview.payAmount)}</div>
|
|
<div>好客权益:¥{fmtMoney(preview.benefitAmount)}</div>
|
|
</Space>
|
|
) : (
|
|
<div style={{ marginTop: 8 }}>
|
|
<Typography.Text type="secondary">请完善商品与数量</Typography.Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Form>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|