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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +476,12 @@ export default function OrdersPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space>
|
||||
{canProxyOrder ? (
|
||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||
代下单
|
||||
</Button>
|
||||
) : null}
|
||||
{canDeleteOrders ? (
|
||||
<Button
|
||||
danger
|
||||
@@ -477,6 +492,7 @@ export default function OrdersPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function App({ children }: PropsWithChildren) {
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
if (route.includes('pickup-receive')) {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' }).catch(() => {});
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||
import { resetStoresSessionBootstrap } from './stores-session';
|
||||
|
||||
function resolveApiBase(): string {
|
||||
const origin =
|
||||
@@ -56,6 +57,8 @@ export function isLoggedIn(): boolean {
|
||||
|
||||
export function logout() {
|
||||
clearAuth();
|
||||
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选
|
||||
resetStoresSessionBootstrap();
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
|
||||
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
|
||||
* 仅 logout 时 clear。
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export type StoresSessionRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
};
|
||||
|
||||
export type StoresSessionCategory = {
|
||||
parentId: string;
|
||||
parentName: string;
|
||||
childId: string;
|
||||
childName: string;
|
||||
};
|
||||
|
||||
export type StoresListCache = {
|
||||
cityKey: string;
|
||||
cityCode: string;
|
||||
listRegion: StoresSessionRegion;
|
||||
items: unknown[];
|
||||
filterRegion: StoresSessionRegion;
|
||||
keyword: string;
|
||||
keywordInput: string;
|
||||
category: StoresSessionCategory;
|
||||
};
|
||||
|
||||
type StoresSession = {
|
||||
bootstrapped: boolean;
|
||||
cache: StoresListCache | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'dukang_stores_session_v1';
|
||||
|
||||
let memory: StoresSession | null = null;
|
||||
|
||||
function emptySession(): StoresSession {
|
||||
return { bootstrapped: false, cache: null };
|
||||
}
|
||||
|
||||
function readSession(): StoresSession {
|
||||
if (memory) return memory;
|
||||
try {
|
||||
const raw = Taro.getStorageSync(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
|
||||
memory = {
|
||||
bootstrapped: !!parsed.bootstrapped,
|
||||
cache: (parsed.cache as StoresListCache | null) ?? null,
|
||||
};
|
||||
return memory;
|
||||
} catch {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(next: StoresSession) {
|
||||
memory = next;
|
||||
try {
|
||||
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function isStoresSessionBootstrapped(): boolean {
|
||||
return readSession().bootstrapped;
|
||||
}
|
||||
|
||||
export function markStoresSessionBootstrapped(): void {
|
||||
const cur = readSession();
|
||||
writeSession({ ...cur, bootstrapped: true });
|
||||
}
|
||||
|
||||
export function getStoresListCache(): StoresListCache | null {
|
||||
return readSession().cache;
|
||||
}
|
||||
|
||||
export function setStoresListCache(cache: StoresListCache | null): void {
|
||||
const cur = readSession();
|
||||
writeSession({ ...cur, bootstrapped: true, cache });
|
||||
}
|
||||
|
||||
export function patchStoresFilterCache(
|
||||
patch: Partial<
|
||||
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
|
||||
>,
|
||||
): void {
|
||||
const cur = readSession();
|
||||
if (!cur.cache) {
|
||||
// 列表尚未写入时也要记下用户筛选,避免切回丢失
|
||||
writeSession({
|
||||
bootstrapped: true,
|
||||
cache: {
|
||||
cityKey: '',
|
||||
cityCode: '',
|
||||
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
|
||||
items: [],
|
||||
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
|
||||
keyword: patch.keyword ?? '',
|
||||
keywordInput: patch.keywordInput ?? '',
|
||||
category: patch.category ?? {
|
||||
parentId: '',
|
||||
parentName: '',
|
||||
childId: '',
|
||||
childName: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeSession({
|
||||
...cur,
|
||||
bootstrapped: true,
|
||||
cache: { ...cur.cache, ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
export function resetStoresSessionBootstrap(): void {
|
||||
memory = emptySession();
|
||||
try {
|
||||
Taro.removeStorageSync(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request, toast } from './api';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||
@@ -17,21 +18,54 @@ type PendingConfirm = {
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
type OpenBusinessViewFn = (opts: {
|
||||
type OpenBusinessViewOptions = {
|
||||
businessType: string;
|
||||
extraData: Record<string, string>;
|
||||
success?: () => void;
|
||||
fail?: (err: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
complete?: () => void;
|
||||
};
|
||||
|
||||
function getOpenBusinessView(): OpenBusinessViewFn | null {
|
||||
type MiniWx = {
|
||||
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取小程序原生 wx.openBusinessView。
|
||||
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
|
||||
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
|
||||
*/
|
||||
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | null {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
const taroAny = Taro as unknown as { openBusinessView?: OpenBusinessViewFn };
|
||||
if (typeof taroAny.openBusinessView === 'function') return taroAny.openBusinessView.bind(Taro);
|
||||
const wxAny = (globalThis as { wx?: { openBusinessView?: OpenBusinessViewFn } }).wx;
|
||||
if (wxAny && typeof wxAny.openBusinessView === 'function') {
|
||||
return wxAny.openBusinessView.bind(wxAny);
|
||||
|
||||
const candidates: Array<MiniWx | null | undefined> = [];
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { wx?: MiniWx };
|
||||
candidates.push(g.wx);
|
||||
|
||||
try {
|
||||
// 跳出 bundler 模块作用域,读微信运行时全局
|
||||
const fromRuntime = new Function(
|
||||
'return typeof wx !== "undefined" ? wx : null',
|
||||
)() as MiniWx | null;
|
||||
candidates.push(fromRuntime);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
for (const api of candidates) {
|
||||
if (api && typeof api.openBusinessView === 'function') {
|
||||
return api.openBusinessView.bind(api);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,8 +85,46 @@ export function takePendingWechatOrderConfirm(): PendingConfirm | null {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
|
||||
return {
|
||||
merchantId: payload?.merchantId?.trim() || undefined,
|
||||
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
|
||||
transactionId: payload?.transactionId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveConfirmPayload(
|
||||
orderId: string,
|
||||
hint?: WechatConfirmPayload | null,
|
||||
): Promise<WechatConfirmPayload> {
|
||||
const fromHint = normalizePayload(hint);
|
||||
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
|
||||
return fromHint;
|
||||
}
|
||||
|
||||
const order = await request<{
|
||||
orderNo?: string;
|
||||
payExternalNo?: string | null;
|
||||
payment?: { externalNo?: string | null } | null;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
}>(`/trade/orders/${orderId}`);
|
||||
|
||||
const fromApi = normalizePayload(order.wechatConfirm);
|
||||
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
|
||||
return fromApi;
|
||||
}
|
||||
|
||||
const transactionId =
|
||||
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
|
||||
return normalizePayload({
|
||||
transactionId,
|
||||
merchantTradeNo: order.orderNo,
|
||||
merchantId: fromApi.merchantId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步。
|
||||
* 拉起微信「确认收货」半屏组件。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||
*/
|
||||
export function openWechatOrderConfirm(opts: {
|
||||
@@ -61,12 +133,19 @@ export function openWechatOrderConfirm(opts: {
|
||||
redirectUrl?: string;
|
||||
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||
const open = getOpenBusinessView();
|
||||
if (!open) return Promise.resolve('unsupported');
|
||||
if (!open) {
|
||||
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
|
||||
taroEnv: process.env.TARO_ENV,
|
||||
});
|
||||
return Promise.resolve('unsupported');
|
||||
}
|
||||
|
||||
const transactionId = opts.payload.transactionId?.trim();
|
||||
const merchantId = opts.payload.merchantId?.trim();
|
||||
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
|
||||
const payload = normalizePayload(opts.payload);
|
||||
const transactionId = payload.transactionId;
|
||||
const merchantId = payload.merchantId;
|
||||
const merchantTradeNo = payload.merchantTradeNo;
|
||||
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||
console.warn('[wechat-order-confirm] missing pay ref', payload);
|
||||
return Promise.resolve('missing_pay_ref');
|
||||
}
|
||||
|
||||
@@ -81,16 +160,31 @@ export function openWechatOrderConfirm(opts: {
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(mode);
|
||||
};
|
||||
|
||||
try {
|
||||
open({
|
||||
businessType: 'weappOrderConfirm',
|
||||
extraData,
|
||||
success: () => resolve('opened'),
|
||||
success: () => done('opened'),
|
||||
fail: (err) => {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
|
||||
resolve('unsupported');
|
||||
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
|
||||
done('unsupported');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
console.error('[wechat-order-confirm] openBusinessView throw', err);
|
||||
toast('无法打开微信确认收货组件');
|
||||
done('unsupported');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,24 +237,11 @@ export async function handleWechatOrderConfirmShow(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */
|
||||
export async function confirmOrderReceive(opts: {
|
||||
async function confirmLocally(opts: {
|
||||
orderId: string;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
onSitePickup?: boolean;
|
||||
redirectUrl?: string;
|
||||
/** 降级本地确认成功后的回调(不经过微信回跳) */
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'wechat' | 'local'> {
|
||||
const mode = await openWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
payload: opts.wechatConfirm || {},
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
if (mode === 'opened') return 'wechat';
|
||||
|
||||
// Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧)
|
||||
}): Promise<'local'> {
|
||||
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
@@ -171,3 +252,48 @@ export async function confirmOrderReceive(opts: {
|
||||
await opts.onLocalSuccess?.();
|
||||
return 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口:
|
||||
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
|
||||
* - Mock / H5:本地确认
|
||||
*/
|
||||
export async function confirmOrderReceive(opts: {
|
||||
orderId: string;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
onSitePickup?: boolean;
|
||||
redirectUrl?: string;
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'wechat' | 'local'> {
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
if (!isWeapp) {
|
||||
return confirmLocally(opts);
|
||||
}
|
||||
|
||||
let mockPay = false;
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
mockPay = !!cfg.mockPay;
|
||||
} catch {
|
||||
mockPay = false;
|
||||
}
|
||||
|
||||
if (mockPay) {
|
||||
return confirmLocally(opts);
|
||||
}
|
||||
|
||||
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
|
||||
const mode = await openWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
payload,
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
if (mode === 'opened') return 'wechat';
|
||||
|
||||
if (mode === 'missing_pay_ref') {
|
||||
throw new Error('缺少微信支付单号,无法打开确认收货组件');
|
||||
}
|
||||
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DEFAULT_SHARE_TITLE,
|
||||
toWeappShareMessage,
|
||||
} from '../../lib/wechat-share';
|
||||
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
@@ -153,7 +154,7 @@ export default function BenefitPage() {
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-logo">
|
||||
<Text>康</Text>
|
||||
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -38,8 +38,8 @@ type MiniHomeConfig = {
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型' },
|
||||
{ key: 'NONGXIANG', label: '浓香型' },
|
||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||
{ key: 'NONGXIANG', label: '浓香型' },
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -111,21 +111,6 @@ export default function HomePage() {
|
||||
})();
|
||||
});
|
||||
|
||||
const availableAromas = useMemo(
|
||||
() =>
|
||||
AROMA_TABS.filter((item) =>
|
||||
products.some((product) => product.aromaType === item.key),
|
||||
),
|
||||
[products],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || availableAromas.length === 0) return;
|
||||
if (!availableAromas.some((item) => item.key === tab)) {
|
||||
setTab(availableAromas[0].key);
|
||||
}
|
||||
}, [availableAromas, loading, tab]);
|
||||
|
||||
function openProductDetail(id: string) {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||
}
|
||||
@@ -187,7 +172,7 @@ export default function HomePage() {
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
<View className="home-aroma-tabs">
|
||||
{availableAromas.map((t) => (
|
||||
{AROMA_TABS.map((t) => (
|
||||
<Text
|
||||
key={t.key}
|
||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||
@@ -205,6 +190,9 @@ export default function HomePage() {
|
||||
{!loading && products.length === 0 ? (
|
||||
<View className="home-empty">当前城市暂无在售商品</View>
|
||||
) : null}
|
||||
{!loading && products.length > 0 && filtered.length === 0 ? (
|
||||
<View className="home-empty">该香型暂未上线</View>
|
||||
) : null}
|
||||
{!loading &&
|
||||
filtered.map((p) => {
|
||||
const thumb = getProductMainImage(p);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
@@ -15,10 +15,16 @@ const TABS = [
|
||||
{ key: 'completed', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
function orderStatusLabel(tab: string, status?: string): string {
|
||||
if (tab !== 'all') {
|
||||
return TABS.find((t) => t.key === tab)?.label || status || '';
|
||||
/** 兼容历史链接 tab=done */
|
||||
function normalizeOrdersTab(raw?: string): string {
|
||||
if (!raw) return 'all';
|
||||
if (raw === 'done') return 'completed';
|
||||
return TABS.some((t) => t.key === raw) ? raw : 'all';
|
||||
}
|
||||
|
||||
function orderStatusLabel(tab: string, status?: string): string {
|
||||
const tabLabel = TABS.find((t) => t.key === tab)?.label;
|
||||
if (tab !== 'all' && tabLabel) return tabLabel;
|
||||
if (!status) return '';
|
||||
return ORDER_STATUS_LABELS[status] || status;
|
||||
}
|
||||
@@ -47,8 +53,7 @@ type OrderRow = {
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const initialTab = (router.params.tab as string) || 'all';
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -72,6 +77,12 @@ export default function OrdersPage() {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
useDidShow(() => {
|
||||
const next = normalizeOrdersTab(router.params.tab as string);
|
||||
if (next !== tab) setTab(next);
|
||||
else void loadOrders();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void loadOrders().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
@@ -23,11 +23,19 @@ type OrderDetail = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
items?: Array<{
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
productImage?: string;
|
||||
quantity?: number;
|
||||
}>;
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
};
|
||||
|
||||
const ORDERS_ALL_URL = '/pages/orders/index?tab=all';
|
||||
|
||||
export default function PickupReceivePage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? router.params.orderId ?? '';
|
||||
@@ -67,11 +75,11 @@ export default function PickupReceivePage() {
|
||||
orderId,
|
||||
wechatConfirm: order.wechatConfirm,
|
||||
onSitePickup: true,
|
||||
redirectUrl: '/pages/orders/index?tab=done',
|
||||
redirectUrl: ORDERS_ALL_URL,
|
||||
onLocalSuccess: async () => {
|
||||
toast('确认收货成功', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||
Taro.redirectTo({ url: ORDERS_ALL_URL });
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
@@ -83,9 +91,11 @@ export default function PickupReceivePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const name = order?.productName || order?.product?.name || '商品';
|
||||
const spec = order?.productSpec || order?.product?.spec;
|
||||
const item = order?.items?.[0];
|
||||
const name = item?.productName || order?.productName || order?.product?.name || '商品';
|
||||
const spec = item?.productSpec || order?.productSpec || order?.product?.spec;
|
||||
const image =
|
||||
(item?.productImage || '').trim() ||
|
||||
order?.mainImageUrl ||
|
||||
order?.imageUrl ||
|
||||
(order?.product ? getProductMainImage(order.product) : '') ||
|
||||
|
||||
@@ -25,8 +25,16 @@ import {
|
||||
toCityWideRegion,
|
||||
type UserCoords,
|
||||
} from '../../lib/user-location';
|
||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||
import { formatDistanceMeters } from '../../lib/geo';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import {
|
||||
getStoresListCache,
|
||||
isStoresSessionBootstrapped,
|
||||
markStoresSessionBootstrapped,
|
||||
patchStoresFilterCache,
|
||||
setStoresListCache,
|
||||
} from '../../lib/stores-session';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
@@ -54,42 +62,48 @@ type Store = {
|
||||
distanceMeters?: number | null;
|
||||
};
|
||||
|
||||
/** 筛选城市键(省+市);同城不重复请求 */
|
||||
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
|
||||
return `${region.province}|${region.city}`;
|
||||
}
|
||||
|
||||
function sameFilterCity(a: RegionSelection, b: RegionSelection): boolean {
|
||||
return a.province === b.province && a.city === b.city;
|
||||
/**
|
||||
* 定位城市若未开城,接口会 fallback 到郑州 cityCode;
|
||||
* 筛选器必须与真实拉取城市一致,否则列表被客户端滤空。
|
||||
*/
|
||||
function regionForCatalogFetch(resolved: {
|
||||
openCity: boolean;
|
||||
cityCode?: string;
|
||||
region: RegionSelection;
|
||||
}): { cityCode: string; region: RegionSelection } {
|
||||
const cityCode = getCityCodeForCatalog(resolved);
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
return { cityCode, region: toCityWideRegion(resolved.region) };
|
||||
}
|
||||
return { cityCode: FALLBACK_CITY_CODE, region: toCityWideRegion(DEFAULT_REGION) };
|
||||
}
|
||||
|
||||
/** 跨 tab 切换 / 页面重建仍复用,避免同城反复打 /stores */
|
||||
type StoresListCache = {
|
||||
cityKey: string;
|
||||
cityCode: string;
|
||||
region: RegionSelection;
|
||||
items: Store[];
|
||||
};
|
||||
let storesListCache: StoresListCache | null = null;
|
||||
|
||||
export default function StoresPage() {
|
||||
const [stores, setStores] = useState<Store[]>(() => storesListCache?.items ?? []);
|
||||
const [loading, setLoading] = useState(() => !storesListCache);
|
||||
const [keywordInput, setKeywordInput] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
// 必须与缓存城市对齐,否则 remount 时用默认「郑州」筛掉缓存列表会闪「暂无」
|
||||
const cached = getStoresListCache();
|
||||
const [stores, setStores] = useState<Store[]>(() => (cached?.items as Store[] | undefined) ?? []);
|
||||
const [loading, setLoading] = useState(() => !cached && !isStoresSessionBootstrapped());
|
||||
const [keywordInput, setKeywordInput] = useState(() => cached?.keywordInput ?? '');
|
||||
const [keyword, setKeyword] = useState(() => cached?.keyword ?? '');
|
||||
const [region, setRegion] = useState<RegionSelection>(
|
||||
() => storesListCache?.region ?? DEFAULT_REGION,
|
||||
() => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION,
|
||||
);
|
||||
const [regionOpen, setRegionOpen] = useState(false);
|
||||
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
|
||||
const [category, setCategory] = useState<CategorySelection>(
|
||||
() => cached?.category ?? EMPTY_CATEGORY,
|
||||
);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const fetchCityKeyRef = useRef<string | null>(storesListCache?.cityKey ?? null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
||||
const fetchSeqRef = useRef(0);
|
||||
const regionRef = useRef(region);
|
||||
regionRef.current = region;
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
/** 有数据时只显示列表,不用「加载中」盖住(避免闪烁) */
|
||||
const showBootLoading = loading && stores.length === 0;
|
||||
|
||||
const childIdsByParent = useMemo(() => {
|
||||
@@ -107,7 +121,9 @@ export default function StoresPage() {
|
||||
nextCode: string,
|
||||
coords: UserCoords | null,
|
||||
cityKey: string,
|
||||
nextRegion: RegionSelection,
|
||||
listRegion: RegionSelection,
|
||||
/** 写入会话的筛选器;默认保留用户当前选择 */
|
||||
filterRegion: RegionSelection = regionRef.current,
|
||||
) {
|
||||
const seq = ++fetchSeqRef.current;
|
||||
const qs = new URLSearchParams();
|
||||
@@ -123,12 +139,17 @@ export default function StoresPage() {
|
||||
const items = Array.isArray(list) ? list : [];
|
||||
setStores(items);
|
||||
fetchCityKeyRef.current = cityKey;
|
||||
storesListCache = {
|
||||
const prev = getStoresListCache();
|
||||
setStoresListCache({
|
||||
cityKey,
|
||||
cityCode: nextCode,
|
||||
region: toCityWideRegion(nextRegion),
|
||||
listRegion: toCityWideRegion(listRegion),
|
||||
items,
|
||||
};
|
||||
filterRegion,
|
||||
keyword: prev?.keyword ?? keyword,
|
||||
keywordInput: prev?.keywordInput ?? keywordInput,
|
||||
category: prev?.category ?? category,
|
||||
});
|
||||
} catch (e) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
@@ -138,46 +159,48 @@ export default function StoresPage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 先稳住缓存画面 → 再定位;同城不请求;换城再拉。
|
||||
* 首次进入:弹窗 + 定位 + 拉列表。
|
||||
* 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState。
|
||||
*/
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
|
||||
// 同步恢复缓存,避免 await 定位期间 region 不对导致列表被滤空
|
||||
if (storesListCache) {
|
||||
fetchCityKeyRef.current = storesListCache.cityKey;
|
||||
setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items));
|
||||
setRegion((prev) =>
|
||||
sameFilterCity(prev, storesListCache!.region) ? prev : storesListCache!.region,
|
||||
);
|
||||
setLoading(false);
|
||||
if (isStoresSessionBootstrapped()) {
|
||||
return;
|
||||
}
|
||||
markStoresSessionBootstrapped();
|
||||
|
||||
void (async () => {
|
||||
const resolved = await resolveUserCity();
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
const nextRegion = toCityWideRegion(resolved.region);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '获取当前位置',
|
||||
content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。',
|
||||
confirmText: '允许',
|
||||
cancelText: '暂不',
|
||||
}).catch(() => ({ confirm: false, cancel: true }));
|
||||
|
||||
if (
|
||||
fetchCityKeyRef.current === nextCityKey ||
|
||||
storesListCache?.cityKey === nextCityKey
|
||||
) {
|
||||
if (storesListCache?.cityKey === nextCityKey) {
|
||||
fetchCityKeyRef.current = nextCityKey;
|
||||
setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items));
|
||||
// 同城不覆盖用户已选区县
|
||||
setRegion((prev) => (sameFilterCity(prev, nextRegion) ? prev : nextRegion));
|
||||
}
|
||||
setLoading(false);
|
||||
if (confirm) {
|
||||
setLoading(true);
|
||||
const resolved = await resolveUserCity(true);
|
||||
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
setRegion(nextRegion);
|
||||
regionRef.current = nextRegion;
|
||||
await fetchStores(
|
||||
cityCode,
|
||||
readCachedUserCoords(),
|
||||
nextCityKey,
|
||||
nextRegion,
|
||||
nextRegion,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 换城:先清空再 loading,避免旧城数据 + 新城筛选交叉闪一下
|
||||
setStores([]);
|
||||
const nextRegion = toCityWideRegion(DEFAULT_REGION);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
setRegion(nextRegion);
|
||||
regionRef.current = nextRegion;
|
||||
setLoading(true);
|
||||
await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion);
|
||||
await fetchStores(FALLBACK_CITY_CODE, null, nextCityKey, nextRegion, nextRegion);
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -191,15 +214,21 @@ export default function StoresPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity(true);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
const nextRegion = toCityWideRegion(resolved.region);
|
||||
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
setRegion(nextRegion);
|
||||
regionRef.current = nextRegion;
|
||||
if (fetchCityKeyRef.current !== nextCityKey) {
|
||||
setStores([]);
|
||||
setLoading(true);
|
||||
}
|
||||
await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion);
|
||||
await fetchStores(
|
||||
cityCode,
|
||||
readCachedUserCoords(),
|
||||
nextCityKey,
|
||||
nextRegion,
|
||||
nextRegion,
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
setLoading(false);
|
||||
@@ -230,7 +259,9 @@ export default function StoresPage() {
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
setKeyword(keywordInput.trim());
|
||||
const next = keywordInput.trim();
|
||||
setKeyword(next);
|
||||
patchStoresFilterCache({ keyword: next, keywordInput });
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
@@ -238,6 +269,49 @@ export default function StoresPage() {
|
||||
setKeyword('');
|
||||
setCategory(EMPTY_CATEGORY);
|
||||
setRegion(DEFAULT_REGION);
|
||||
regionRef.current = DEFAULT_REGION;
|
||||
patchStoresFilterCache({
|
||||
keyword: '',
|
||||
keywordInput: '',
|
||||
category: EMPTY_CATEGORY,
|
||||
filterRegion: DEFAULT_REGION,
|
||||
});
|
||||
}
|
||||
|
||||
async function locateToUserRegion() {
|
||||
if (locating) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '获取当前位置',
|
||||
content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?',
|
||||
confirmText: '允许',
|
||||
cancelText: '暂不',
|
||||
}).catch(() => ({ confirm: false, cancel: true }));
|
||||
if (!confirm) return;
|
||||
|
||||
setLocating(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const resolved = await resolveUserCity(true);
|
||||
// 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州)
|
||||
const filterRegion = resolved.region;
|
||||
const { cityCode, region: listRegion } = regionForCatalogFetch(resolved);
|
||||
const nextCityKey = makeCityKey(listRegion);
|
||||
setRegion(filterRegion);
|
||||
regionRef.current = filterRegion;
|
||||
await fetchStores(
|
||||
cityCode,
|
||||
readCachedUserCoords(),
|
||||
nextCityKey,
|
||||
listRegion,
|
||||
filterRegion,
|
||||
);
|
||||
toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '定位失败');
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatHours(store: Store) {
|
||||
@@ -291,9 +365,23 @@ export default function StoresPage() {
|
||||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<Text className="store-filter-reset" onClick={resetFilters}>
|
||||
重置
|
||||
</Text>
|
||||
<View
|
||||
className="store-filter-icon-btn"
|
||||
onClick={resetFilters}
|
||||
aria-label="重置筛选"
|
||||
>
|
||||
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
|
||||
<Text className="store-filter-icon-glyph">↺</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
|
||||
onClick={() => {
|
||||
void locateToUserRegion();
|
||||
}}
|
||||
aria-label="获取当前位置"
|
||||
>
|
||||
<Text className="store-filter-icon-glyph">⌖</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -349,14 +437,21 @@ export default function StoresPage() {
|
||||
value={region}
|
||||
levels={3}
|
||||
onClose={() => setRegionOpen(false)}
|
||||
onConfirm={(next) => setRegion(next)}
|
||||
onConfirm={(next) => {
|
||||
setRegion(next);
|
||||
regionRef.current = next;
|
||||
patchStoresFilterCache({ filterRegion: next });
|
||||
}}
|
||||
/>
|
||||
<CategoryPicker
|
||||
open={categoryOpen}
|
||||
tree={categoryTree}
|
||||
value={category}
|
||||
onClose={() => setCategoryOpen(false)}
|
||||
onConfirm={(next) => setCategory(next)}
|
||||
onConfirm={(next) => {
|
||||
setCategory(next);
|
||||
patchStoresFilterCache({ category: next });
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -126,6 +126,13 @@
|
||||
color: var(--color-heritage-red);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.benefit-hero-logo-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.benefit-hero-cta {
|
||||
|
||||
@@ -116,15 +116,27 @@
|
||||
color: var(--color-subtle-gray);
|
||||
}
|
||||
|
||||
.store-filter-reset {
|
||||
.store-filter-icon-btn {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-heritage-red);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-filter-icon-btn--busy {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.store-filter-icon-glyph {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+8
@@ -14,6 +14,14 @@ declare const definePageConfig: (config: Record<string, unknown>) => Record<stri
|
||||
|
||||
declare const TARO_APP_API_ORIGIN: string;
|
||||
|
||||
/** 微信小程序全局(Taro 未封装的 API 如 openBusinessView 需直接调用) */
|
||||
declare const wx: {
|
||||
openBusinessView?: (opts: Record<string, unknown>) => void;
|
||||
canIUse?: (schema: string) => boolean;
|
||||
requestPayment?: (opts: Record<string, unknown>) => void;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
TARO_ENV: 'weapp' | 'h5' | string;
|
||||
|
||||
@@ -111,3 +111,20 @@ export type PartnerProxyOrderCreateRequest = {
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
};
|
||||
|
||||
/** 总部代下单(对齐合伙人双短信流程;确认码发至 HQ 登录手机) */
|
||||
export type HqProxyOrderCreateRequest = {
|
||||
phone: string;
|
||||
customerSmsCode: string;
|
||||
operatorSmsCode: string;
|
||||
deliveryMode: PartnerProxyDeliveryMode;
|
||||
autoReceive?: boolean;
|
||||
receiverName?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
addressDetail?: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export const HqOperationAction = {
|
||||
ORDER_SHIP: 'ORDER_SHIP',
|
||||
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
|
||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||
STORE_CREATE: 'STORE_CREATE',
|
||||
STORE_UPDATE: 'STORE_UPDATE',
|
||||
STORE_STATUS: 'STORE_STATUS',
|
||||
@@ -122,6 +123,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
|
||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import {
|
||||
HqProxyOrderCreateDto,
|
||||
HqProxyOrderPreviewDto,
|
||||
HqProxyOrderSendCustomerSmsDto,
|
||||
} from './dto/hq-proxy-order.dto';
|
||||
|
||||
@Controller('admin/proxy-orders')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('orders')
|
||||
export class AdminProxyOrdersController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get('options')
|
||||
options() {
|
||||
return this.tradeService.getHqProxyOrderOptions();
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
preview(@Body() dto: HqProxyOrderPreviewDto) {
|
||||
return this.tradeService.previewPartnerProxyOrder(dto);
|
||||
}
|
||||
|
||||
@Post('send-customer-sms')
|
||||
sendCustomerSms(@Body() dto: HqProxyOrderSendCustomerSmsDto) {
|
||||
return this.tradeService.sendHqProxyCustomerSms(dto.phone);
|
||||
}
|
||||
|
||||
@Post('send-operator-sms')
|
||||
sendOperatorSms(@CurrentUser() user: AuthUser) {
|
||||
return this.tradeService.sendHqProxyOperatorSms(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_PROXY_CREATE,
|
||||
refType: 'ORDER',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: HqProxyOrderCreateDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.tradeService.createHqProxyOrder(user.actorId, dto, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class HqProxyOrderPreviewDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverCity?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverDistrict?: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderSendCustomerSmsDto {
|
||||
@IsString()
|
||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderCreateDto {
|
||||
@IsString()
|
||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
customerSmsCode: string;
|
||||
|
||||
/** 发至当前 HQ 登录手机号的确认验证码 */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
operatorSmsCode: string;
|
||||
|
||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsBoolean()
|
||||
autoReceive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
receiverName?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
province?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
city?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
district?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
addressDetail?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminProxyOrdersController } from './admin-proxy-orders.controller';
|
||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||
import { AdminStoresService } from './admin-stores.service';
|
||||
@@ -76,6 +77,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminDeployController,
|
||||
AdminUsersController,
|
||||
AdminOrdersController,
|
||||
AdminProxyOrdersController,
|
||||
AdminStoresController,
|
||||
AdminStoreAccountsController,
|
||||
AdminStoreMediaController,
|
||||
|
||||
@@ -1383,4 +1383,255 @@ export class TradeService {
|
||||
|
||||
return this.getPartnerOrder(partnerAccountId, order.id);
|
||||
}
|
||||
|
||||
/** HQ 代下单:商品/推广码选项(不绑定合伙人门店) */
|
||||
async getHqProxyOrderOptions() {
|
||||
const [products, promoCodes] = await Promise.all([
|
||||
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
||||
this.promoCodeService.listActiveOptions(),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
products: products.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
spec: p.spec,
|
||||
price: Number(p.price),
|
||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
||||
})),
|
||||
promoCodes,
|
||||
stores: [],
|
||||
});
|
||||
}
|
||||
|
||||
async sendHqProxyCustomerSms(phone: string) {
|
||||
const normalizedPhone = phone.trim();
|
||||
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
|
||||
clientApp: ClientApp.HQ_WEB,
|
||||
});
|
||||
const masked =
|
||||
normalizedPhone.length >= 7
|
||||
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
|
||||
: normalizedPhone;
|
||||
return { ok: true, maskedPhone: masked };
|
||||
}
|
||||
|
||||
async sendHqProxyOperatorSms(hqAccountId: bigint) {
|
||||
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
||||
if (!hq || hq.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('总部账号无效');
|
||||
}
|
||||
const operatorPhone = hq.phone?.trim();
|
||||
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
|
||||
throw new BadRequestException('总部账号手机号无效,无法发送确认验证码');
|
||||
}
|
||||
await this.authService.sendSms(operatorPhone, SmsScene.PARTNER_PROXY_ORDER, {
|
||||
clientApp: ClientApp.HQ_WEB,
|
||||
});
|
||||
const masked =
|
||||
operatorPhone.length >= 7
|
||||
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
|
||||
: operatorPhone;
|
||||
return { ok: true, maskedPhone: masked };
|
||||
}
|
||||
|
||||
async createHqProxyOrder(
|
||||
hqAccountId: bigint,
|
||||
body: {
|
||||
phone: string;
|
||||
customerSmsCode: string;
|
||||
operatorSmsCode: string;
|
||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
autoReceive?: boolean;
|
||||
receiverName?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
addressDetail?: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
},
|
||||
req: Request,
|
||||
) {
|
||||
const normalizedPhone = body.phone.trim();
|
||||
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
||||
if (!hq || hq.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('总部账号无效');
|
||||
}
|
||||
const operatorPhone = hq.phone?.trim();
|
||||
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
|
||||
throw new BadRequestException('总部账号手机号无效');
|
||||
}
|
||||
|
||||
await this.authService.verifySmsCode(
|
||||
normalizedPhone,
|
||||
body.customerSmsCode.trim(),
|
||||
SmsScene.PARTNER_PROXY_CUSTOMER,
|
||||
);
|
||||
await this.authService.verifySmsCode(
|
||||
operatorPhone,
|
||||
body.operatorSmsCode.trim(),
|
||||
SmsScene.PARTNER_PROXY_ORDER,
|
||||
);
|
||||
|
||||
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
|
||||
throw new BadRequestException('配送到址须勾选同意自动收货');
|
||||
}
|
||||
|
||||
const maskedOperatorPhone =
|
||||
operatorPhone.length >= 7
|
||||
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
|
||||
: operatorPhone;
|
||||
const proxyDisplayName = `总部·${hq.name}`;
|
||||
|
||||
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
|
||||
sourceType: 'PARTNER_PROXY',
|
||||
sourceRefId: hq.id,
|
||||
sourceLabel: `总部代下单·${maskedOperatorPhone}`,
|
||||
});
|
||||
|
||||
const preview = await this.previewPartnerProxyOrder({
|
||||
productId: body.productId,
|
||||
quantity: body.quantity,
|
||||
deliveryMode: body.deliveryMode,
|
||||
receiverCity: body.city,
|
||||
receiverDistrict: body.district,
|
||||
});
|
||||
|
||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||
|
||||
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||
let receiverProvince = body.province?.trim() || '';
|
||||
let receiverCity = body.city?.trim() || '';
|
||||
let receiverDistrict = body.district?.trim() || '';
|
||||
let receiverAddress = '';
|
||||
let commissionDistrict = receiverDistrict;
|
||||
|
||||
if (body.deliveryMode === 'ON_SITE_PICKUP') {
|
||||
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||
receiverProvince = '现场';
|
||||
receiverCity = '现场';
|
||||
receiverDistrict = '取货';
|
||||
receiverAddress = '现场提货';
|
||||
commissionDistrict = '';
|
||||
} else {
|
||||
if (!receiverProvince || !receiverCity || !receiverDistrict) {
|
||||
throw new BadRequestException('请选择省市区');
|
||||
}
|
||||
if (!body.addressDetail?.trim()) {
|
||||
throw new BadRequestException('请填写详细地址');
|
||||
}
|
||||
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
||||
}
|
||||
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||
|
||||
let promoCodeId: bigint | undefined;
|
||||
if (body.promoCodeId?.trim()) {
|
||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
||||
}
|
||||
|
||||
const orderNo = generateOrderNo();
|
||||
const now = new Date();
|
||||
const location = buildOrderClientLocationSnapshot(
|
||||
req,
|
||||
this.ipGeoService.resolve(extractClientIp(req)),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
orderType: 'PROXY',
|
||||
userId: user.id,
|
||||
cityId: city.id,
|
||||
status: 'COMPLETED',
|
||||
payStatus: 'PAID',
|
||||
deliveryType: preview.deliveryType,
|
||||
channelSource: 'OFFLINE_PROXY',
|
||||
promoCodeId,
|
||||
productId: product.id,
|
||||
barcode69: product.barcode69,
|
||||
productName: product.name,
|
||||
productSpec: product.spec,
|
||||
imageResourceId: product.coverResourceId,
|
||||
quantity: body.quantity,
|
||||
listUnitPrice: product.price,
|
||||
listAmount: preview.productAmount,
|
||||
productAmount: preview.productAmount,
|
||||
payAmount: preview.payAmount,
|
||||
benefitAmount: preview.benefitAmount,
|
||||
freightAmount: 0,
|
||||
freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null,
|
||||
receiverName,
|
||||
receiverPhone: normalizedPhone,
|
||||
receiverAddress,
|
||||
receiverProvince,
|
||||
receiverCity,
|
||||
receiverDistrict,
|
||||
clientIp: location.clientIp,
|
||||
ipProvince: location.ipProvince,
|
||||
ipCity: location.ipCity,
|
||||
ipDistrict: location.ipDistrict,
|
||||
paidAt: now,
|
||||
shippedAt: now,
|
||||
completedAt: now,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
proxyPartnerAccountId: null,
|
||||
proxyPartnerName: proxyDisplayName,
|
||||
proxyPartnerPhone: operatorPhone,
|
||||
remark: `总部代下单 hqAccountId=${hq.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
|
||||
},
|
||||
include: { product: true, imageResource: true },
|
||||
});
|
||||
|
||||
await tx.orderDelivery.create({
|
||||
data: {
|
||||
orderId: created.id,
|
||||
provider: 'MANUAL',
|
||||
outWarehouseAt: now,
|
||||
shippingAt: now,
|
||||
deliveredAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: created.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'COMPLETED',
|
||||
operator: 'HQ_PROXY',
|
||||
remark: `总部线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
await tx.commonPromoCode.update({
|
||||
where: { id: promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
|
||||
return serializeBigInt({
|
||||
id: order.id,
|
||||
orderNo: order.orderNo,
|
||||
status: order.status,
|
||||
payAmount: Number(order.payAmount),
|
||||
benefitAmount: Number(order.benefitAmount),
|
||||
proxyPartnerName: proxyDisplayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user