feat(ops): add HQ admin proxy order and mini-user store session fixes

Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 09:18:19 +08:00
parent 1a0afb6d39
commit 2cd4e25682
21 changed files with 1438 additions and 143 deletions
@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Typography,
message,
} from 'antd';
import type {
HqProxyOrderCreateRequest,
PartnerProxyDeliveryMode,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
} 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;
};
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 [customerSmsCode, setCustomerSmsCode] = useState('');
const [operatorSmsCode, setOperatorSmsCode] = 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 [confirmStep, setConfirmStep] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [customerCooldown, setCustomerCooldown] = useState(0);
const [operatorCooldown, setOperatorCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
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) {
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]);
function resetForm() {
setPhone('');
setCustomerSmsCode('');
setOperatorSmsCode('');
setReceiverName('');
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
setConfirmStep(false);
setPreview(null);
setCustomerCooldown(0);
setOperatorCooldown(0);
setProductId(options?.products[0]?.id);
}
function handleClose() {
resetForm();
onClose();
}
function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
setter(60);
const timer = window.setInterval(() => {
setter((s) => {
if (s <= 1) {
window.clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
message.warning('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-customer-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
});
message.success(`客户验证码已发送至 ${res.maskedPhone}`);
startCooldown(setCustomerCooldown);
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败');
}
}
async function sendOperatorSms() {
try {
const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-operator-sms', {
method: 'POST',
body: '{}',
});
message.success(`确认验证码已发送至总部手机 ${res.maskedPhone}`);
startCooldown(setOperatorCooldown);
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.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;
}
async function openConfirmStep() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
setConfirmStep(true);
setOperatorSmsCode('');
await sendOperatorSms();
}
async function submit() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
if (!operatorSmsCode.trim()) {
message.warning('请输入总部确认验证码');
return;
}
const payload: HqProxyOrderCreateRequest = {
phone: phone.trim(),
customerSmsCode: customerSmsCode.trim(),
operatorSmsCode: operatorSmsCode.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<{ id: string; orderNo: string }>('/admin/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success(`代下单成功:${order.orderNo}`);
resetForm();
onSuccess(order);
} catch (e) {
message.error(e instanceof Error ? e.message : '下单失败');
} finally {
setSubmitting(false);
}
}
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<Modal
title="代下单"
open={open}
onCancel={handleClose}
width={640}
destroyOnClose
footer={
confirmStep ? (
<Space>
<Button
onClick={() => {
setConfirmStep(false);
setOperatorSmsCode('');
}}
>
</Button>
<Button type="primary" loading={submitting} onClick={() => void submit()}>
</Button>
</Space>
) : (
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={loadingOptions} onClick={() => void openConfirmStep()}>
</Button>
</Space>
)
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="线下已收款:提交后订单直接完成并发放好客权益(客户验证码 + 总部确认码)"
/>
{!confirmStep ? (
<Form layout="vertical">
<Form.Item label="用户手机号" required>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="11 位手机号"
value={phone}
maxLength={11}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
<Button disabled={customerCooldown > 0} onClick={() => void sendCustomerSms()}>
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label="客户验证码" required>
<Input
placeholder="发至客户手机"
value={customerSmsCode}
maxLength={6}
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</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>
) : (
<Form layout="vertical">
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={`即将为 ${phone} 创建已完成订单并发放权益,请输入发至总部账号手机的确认验证码`}
/>
<Form.Item label="总部确认验证码" required>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="发至当前登录 HQ 手机"
value={operatorSmsCode}
maxLength={6}
onChange={(e) => setOperatorSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<Button disabled={operatorCooldown > 0} onClick={() => void sendOperatorSms()}>
{operatorCooldown > 0 ? `${operatorCooldown}s` : '重新发送'}
</Button>
</Space.Compact>
</Form.Item>
{preview ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{deliveryLabel} · ¥{fmtMoney(preview.payAmount)} · ¥{fmtMoney(preview.benefitAmount)}
</Typography.Paragraph>
) : null}
</Form>
)}
</Modal>
);
}
+4
View File
@@ -148,6 +148,10 @@ export type AdminOrderRow = {
fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: {
provider: string;
+44 -10
View File
@@ -28,6 +28,7 @@ import {
fmtTime,
} from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
import ProxyOrderModal from '../components/ProxyOrderModal';
type ShipDefaults = {
provider: string;
@@ -182,7 +183,9 @@ export default function OrdersPage() {
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -415,6 +418,11 @@ export default function OrdersPage() {
<Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}>
</Tag>
) : null}
</Space>
),
},
@@ -422,7 +430,8 @@ export default function OrdersPage() {
title: '配送',
dataIndex: 'deliveryType',
width: 90,
render: (v) => (v === 'LOCAL' ? '同城' : '跨城'),
render: (v) =>
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
@@ -467,15 +476,22 @@ export default function OrdersPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
<Space>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null}
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
@@ -588,8 +604,16 @@ export default function OrdersPage() {
'大单待确认'}
</Tag>
) : null}
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
</Descriptions.Item>
{detail.orderType === 'PROXY' || detail.isProxyOrder || detail.proxyPartnerName ? (
<Descriptions.Item label="代下单人">
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item>
) : null}
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
@@ -1112,6 +1136,16 @@ export default function OrdersPage() {
]}
/>
</Modal>
<ProxyOrderModal
open={proxyOpen}
onClose={() => setProxyOpen(false)}
onSuccess={(order) => {
setProxyOpen(false);
void load();
void openDetail(order.id);
}}
/>
</div>
);
}