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;
|
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||||
fulfillmentHold?: boolean;
|
fulfillmentHold?: boolean;
|
||||||
fulfillmentHoldReason?: string | null;
|
fulfillmentHoldReason?: string | null;
|
||||||
|
orderType?: string;
|
||||||
|
isProxyOrder?: boolean;
|
||||||
|
proxyPartnerName?: string | null;
|
||||||
|
proxyPartnerPhone?: string | null;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
fmtTime,
|
fmtTime,
|
||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||||
|
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||||
|
|
||||||
type ShipDefaults = {
|
type ShipDefaults = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -182,7 +183,9 @@ export default function OrdersPage() {
|
|||||||
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
||||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
|
const [proxyOpen, setProxyOpen] = useState(false);
|
||||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||||
|
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
|
||||||
const selectedOrders = useMemo(
|
const selectedOrders = useMemo(
|
||||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||||
@@ -415,6 +418,11 @@ export default function OrdersPage() {
|
|||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
{row.fulfillmentHold ? <Tag color="orange">大单待确认</Tag> : null}
|
{row.fulfillmentHold ? <Tag color="orange">大单待确认</Tag> : null}
|
||||||
|
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||||
|
<Tag color="purple" title={row.proxyPartnerName || undefined}>
|
||||||
|
代下单
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -422,7 +430,8 @@ export default function OrdersPage() {
|
|||||||
title: '配送',
|
title: '配送',
|
||||||
dataIndex: 'deliveryType',
|
dataIndex: 'deliveryType',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => (v === 'LOCAL' ? '同城' : '跨城'),
|
render: (v) =>
|
||||||
|
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
|
||||||
},
|
},
|
||||||
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||||
@@ -467,15 +476,22 @@ export default function OrdersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||||
{canDeleteOrders ? (
|
<Space>
|
||||||
<Button
|
{canProxyOrder ? (
|
||||||
danger
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
disabled={!selectedRowKeys.length}
|
代下单
|
||||||
onClick={() => setBatchDeleteOpen(true)}
|
</Button>
|
||||||
>
|
) : null}
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
{canDeleteOrders ? (
|
||||||
</Button>
|
<Button
|
||||||
) : null}
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => setBatchDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="orderNo" label="订单号">
|
<Form.Item name="orderNo" label="订单号">
|
||||||
@@ -588,8 +604,16 @@ export default function OrdersPage() {
|
|||||||
'大单待确认'}
|
'大单待确认'}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
|
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
|
||||||
|
<Tag color="purple">代下单</Tag>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</Descriptions.Item>
|
</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.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||||
@@ -1112,6 +1136,16 @@ export default function OrdersPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ProxyOrderModal
|
||||||
|
open={proxyOpen}
|
||||||
|
onClose={() => setProxyOpen(false)}
|
||||||
|
onSuccess={(order) => {
|
||||||
|
setProxyOpen(false);
|
||||||
|
void load();
|
||||||
|
void openDetail(order.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function App({ children }: PropsWithChildren) {
|
|||||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||||
const route = cur?.route || '';
|
const route = cur?.route || '';
|
||||||
if (route.includes('pickup-receive')) {
|
if (route.includes('pickup-receive')) {
|
||||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' }).catch(() => {});
|
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { ClientApp } from '@dukang/shared-types';
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
import { forceReloadAfterAccountMerge } from './auth-nav';
|
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||||
|
import { resetStoresSessionBootstrap } from './stores-session';
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const origin =
|
const origin =
|
||||||
@@ -56,6 +57,8 @@ export function isLoggedIn(): boolean {
|
|||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选
|
||||||
|
resetStoresSessionBootstrap();
|
||||||
Taro.reLaunch({ url: '/pages/home/index' });
|
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 Taro from '@tarojs/taro';
|
||||||
import { request, toast } from './api';
|
import { request, toast } from './api';
|
||||||
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
|
||||||
/** 微信确认收货组件来源 AppId(官方固定) */
|
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||||
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||||
@@ -17,21 +18,54 @@ type PendingConfirm = {
|
|||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenBusinessViewFn = (opts: {
|
type OpenBusinessViewOptions = {
|
||||||
businessType: string;
|
businessType: string;
|
||||||
extraData: Record<string, string>;
|
extraData: Record<string, string>;
|
||||||
success?: () => void;
|
success?: () => void;
|
||||||
fail?: (err: { errMsg?: string }) => 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;
|
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 candidates: Array<MiniWx | null | undefined> = [];
|
||||||
const wxAny = (globalThis as { wx?: { openBusinessView?: OpenBusinessViewFn } }).wx;
|
|
||||||
if (wxAny && typeof wxAny.openBusinessView === 'function') {
|
try {
|
||||||
return wxAny.openBusinessView.bind(wxAny);
|
// 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;
|
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
|
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||||
*/
|
*/
|
||||||
export function openWechatOrderConfirm(opts: {
|
export function openWechatOrderConfirm(opts: {
|
||||||
@@ -61,12 +133,19 @@ export function openWechatOrderConfirm(opts: {
|
|||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||||
const open = getOpenBusinessView();
|
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 payload = normalizePayload(opts.payload);
|
||||||
const merchantId = opts.payload.merchantId?.trim();
|
const transactionId = payload.transactionId;
|
||||||
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
|
const merchantId = payload.merchantId;
|
||||||
|
const merchantTradeNo = payload.merchantTradeNo;
|
||||||
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||||
|
console.warn('[wechat-order-confirm] missing pay ref', payload);
|
||||||
return Promise.resolve('missing_pay_ref');
|
return Promise.resolve('missing_pay_ref');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,16 +160,31 @@ export function openWechatOrderConfirm(opts: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
open({
|
let settled = false;
|
||||||
businessType: 'weappOrderConfirm',
|
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
|
||||||
extraData,
|
if (settled) return;
|
||||||
success: () => resolve('opened'),
|
settled = true;
|
||||||
fail: (err) => {
|
resolve(mode);
|
||||||
Taro.removeStorageSync(PENDING_KEY);
|
};
|
||||||
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
|
|
||||||
resolve('unsupported');
|
try {
|
||||||
},
|
open({
|
||||||
});
|
businessType: 'weappOrderConfirm',
|
||||||
|
extraData,
|
||||||
|
success: () => done('opened'),
|
||||||
|
fail: (err) => {
|
||||||
|
Taro.removeStorageSync(PENDING_KEY);
|
||||||
|
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/无能力时降级为本地确认 */
|
async function confirmLocally(opts: {
|
||||||
export async function confirmOrderReceive(opts: {
|
|
||||||
orderId: string;
|
orderId: string;
|
||||||
wechatConfirm?: WechatConfirmPayload | null;
|
|
||||||
onSitePickup?: boolean;
|
onSitePickup?: boolean;
|
||||||
redirectUrl?: string;
|
|
||||||
/** 降级本地确认成功后的回调(不经过微信回跳) */
|
|
||||||
onLocalSuccess?: () => void | Promise<void>;
|
onLocalSuccess?: () => void | Promise<void>;
|
||||||
}): Promise<'wechat' | 'local'> {
|
}): Promise<'local'> {
|
||||||
const mode = await openWechatOrderConfirm({
|
|
||||||
orderId: opts.orderId,
|
|
||||||
payload: opts.wechatConfirm || {},
|
|
||||||
redirectUrl: opts.redirectUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mode === 'opened') return 'wechat';
|
|
||||||
|
|
||||||
// Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧)
|
|
||||||
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
@@ -171,3 +252,48 @@ export async function confirmOrderReceive(opts: {
|
|||||||
await opts.onLocalSuccess?.();
|
await opts.onLocalSuccess?.();
|
||||||
return 'local';
|
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 { 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 Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import ShareNavButton from '../../components/ShareNavButton';
|
import ShareNavButton from '../../components/ShareNavButton';
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DEFAULT_SHARE_TITLE,
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
|
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
||||||
|
|
||||||
type BenefitSummary = {
|
type BenefitSummary = {
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
@@ -153,7 +154,7 @@ export default function BenefitPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="benefit-hero-logo">
|
<View className="benefit-hero-logo">
|
||||||
<Text>康</Text>
|
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ type MiniHomeConfig = {
|
|||||||
|
|
||||||
const AROMA_TABS = [
|
const AROMA_TABS = [
|
||||||
{ key: 'QINGXIANG', label: '清香型' },
|
{ key: 'QINGXIANG', label: '清香型' },
|
||||||
{ key: 'NONGXIANG', label: '浓香型' },
|
|
||||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||||
|
{ key: 'NONGXIANG', label: '浓香型' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function HomePage() {
|
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) {
|
function openProductDetail(id: string) {
|
||||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
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-nav">
|
||||||
<View className="home-aroma-tabs">
|
<View className="home-aroma-tabs">
|
||||||
{availableAromas.map((t) => (
|
{AROMA_TABS.map((t) => (
|
||||||
<Text
|
<Text
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||||
@@ -205,6 +190,9 @@ export default function HomePage() {
|
|||||||
{!loading && products.length === 0 ? (
|
{!loading && products.length === 0 ? (
|
||||||
<View className="home-empty">当前城市暂无在售商品</View>
|
<View className="home-empty">当前城市暂无在售商品</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!loading && products.length > 0 && filtered.length === 0 ? (
|
||||||
|
<View className="home-empty">该香型暂未上线</View>
|
||||||
|
) : null}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
filtered.map((p) => {
|
filtered.map((p) => {
|
||||||
const thumb = getProductMainImage(p);
|
const thumb = getProductMainImage(p);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
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 PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
@@ -15,10 +15,16 @@ const TABS = [
|
|||||||
{ key: 'completed', label: '已完成' },
|
{ key: 'completed', label: '已完成' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 兼容历史链接 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 {
|
function orderStatusLabel(tab: string, status?: string): string {
|
||||||
if (tab !== 'all') {
|
const tabLabel = TABS.find((t) => t.key === tab)?.label;
|
||||||
return TABS.find((t) => t.key === tab)?.label || status || '';
|
if (tab !== 'all' && tabLabel) return tabLabel;
|
||||||
}
|
|
||||||
if (!status) return '';
|
if (!status) return '';
|
||||||
return ORDER_STATUS_LABELS[status] || status;
|
return ORDER_STATUS_LABELS[status] || status;
|
||||||
}
|
}
|
||||||
@@ -47,8 +53,7 @@ type OrderRow = {
|
|||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const initialTab = (router.params.tab as string) || 'all';
|
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
|
||||||
const [tab, setTab] = useState(initialTab);
|
|
||||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -72,6 +77,12 @@ export default function OrdersPage() {
|
|||||||
void loadOrders();
|
void loadOrders();
|
||||||
}, [loadOrders]);
|
}, [loadOrders]);
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
const next = normalizeOrdersTab(router.params.tab as string);
|
||||||
|
if (next !== tab) setTab(next);
|
||||||
|
else void loadOrders();
|
||||||
|
});
|
||||||
|
|
||||||
usePullDownRefresh(() => {
|
usePullDownRefresh(() => {
|
||||||
void loadOrders().finally(() => Taro.stopPullDownRefresh());
|
void loadOrders().finally(() => Taro.stopPullDownRefresh());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,11 +23,19 @@ type OrderDetail = {
|
|||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
carouselUrls?: string[] | null;
|
carouselUrls?: string[] | null;
|
||||||
};
|
};
|
||||||
|
items?: Array<{
|
||||||
|
productName?: string;
|
||||||
|
productSpec?: string;
|
||||||
|
productImage?: string;
|
||||||
|
quantity?: number;
|
||||||
|
}>;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
wechatConfirm?: WechatConfirmPayload | null;
|
wechatConfirm?: WechatConfirmPayload | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ORDERS_ALL_URL = '/pages/orders/index?tab=all';
|
||||||
|
|
||||||
export default function PickupReceivePage() {
|
export default function PickupReceivePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const orderId = router.params.id ?? router.params.orderId ?? '';
|
const orderId = router.params.id ?? router.params.orderId ?? '';
|
||||||
@@ -67,11 +75,11 @@ export default function PickupReceivePage() {
|
|||||||
orderId,
|
orderId,
|
||||||
wechatConfirm: order.wechatConfirm,
|
wechatConfirm: order.wechatConfirm,
|
||||||
onSitePickup: true,
|
onSitePickup: true,
|
||||||
redirectUrl: '/pages/orders/index?tab=done',
|
redirectUrl: ORDERS_ALL_URL,
|
||||||
onLocalSuccess: async () => {
|
onLocalSuccess: async () => {
|
||||||
toast('确认收货成功', 'success');
|
toast('确认收货成功', 'success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
Taro.redirectTo({ url: ORDERS_ALL_URL });
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -83,9 +91,11 @@ export default function PickupReceivePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = order?.productName || order?.product?.name || '商品';
|
const item = order?.items?.[0];
|
||||||
const spec = order?.productSpec || order?.product?.spec;
|
const name = item?.productName || order?.productName || order?.product?.name || '商品';
|
||||||
|
const spec = item?.productSpec || order?.productSpec || order?.product?.spec;
|
||||||
const image =
|
const image =
|
||||||
|
(item?.productImage || '').trim() ||
|
||||||
order?.mainImageUrl ||
|
order?.mainImageUrl ||
|
||||||
order?.imageUrl ||
|
order?.imageUrl ||
|
||||||
(order?.product ? getProductMainImage(order.product) : '') ||
|
(order?.product ? getProductMainImage(order.product) : '') ||
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ import {
|
|||||||
toCityWideRegion,
|
toCityWideRegion,
|
||||||
type UserCoords,
|
type UserCoords,
|
||||||
} from '../../lib/user-location';
|
} from '../../lib/user-location';
|
||||||
|
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||||
import { formatDistanceMeters } from '../../lib/geo';
|
import { formatDistanceMeters } from '../../lib/geo';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
import {
|
||||||
|
getStoresListCache,
|
||||||
|
isStoresSessionBootstrapped,
|
||||||
|
markStoresSessionBootstrapped,
|
||||||
|
patchStoresFilterCache,
|
||||||
|
setStoresListCache,
|
||||||
|
} from '../../lib/stores-session';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SHARE_DESC,
|
DEFAULT_SHARE_DESC,
|
||||||
DEFAULT_SHARE_TITLE,
|
DEFAULT_SHARE_TITLE,
|
||||||
@@ -54,42 +62,48 @@ type Store = {
|
|||||||
distanceMeters?: number | null;
|
distanceMeters?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 筛选城市键(省+市);同城不重复请求 */
|
|
||||||
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
|
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
|
||||||
return `${region.province}|${region.city}`;
|
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() {
|
export default function StoresPage() {
|
||||||
const [stores, setStores] = useState<Store[]>(() => storesListCache?.items ?? []);
|
const cached = getStoresListCache();
|
||||||
const [loading, setLoading] = useState(() => !storesListCache);
|
const [stores, setStores] = useState<Store[]>(() => (cached?.items as Store[] | undefined) ?? []);
|
||||||
const [keywordInput, setKeywordInput] = useState('');
|
const [loading, setLoading] = useState(() => !cached && !isStoresSessionBootstrapped());
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keywordInput, setKeywordInput] = useState(() => cached?.keywordInput ?? '');
|
||||||
// 必须与缓存城市对齐,否则 remount 时用默认「郑州」筛掉缓存列表会闪「暂无」
|
const [keyword, setKeyword] = useState(() => cached?.keyword ?? '');
|
||||||
const [region, setRegion] = useState<RegionSelection>(
|
const [region, setRegion] = useState<RegionSelection>(
|
||||||
() => storesListCache?.region ?? DEFAULT_REGION,
|
() => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION,
|
||||||
);
|
);
|
||||||
const [regionOpen, setRegionOpen] = useState(false);
|
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 [categoryOpen, setCategoryOpen] = useState(false);
|
||||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
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 fetchSeqRef = useRef(0);
|
||||||
|
const regionRef = useRef(region);
|
||||||
|
regionRef.current = region;
|
||||||
const regionLabel = formatRegionLabel(region);
|
const regionLabel = formatRegionLabel(region);
|
||||||
const categoryLabel = formatCategoryLabel(category);
|
const categoryLabel = formatCategoryLabel(category);
|
||||||
/** 有数据时只显示列表,不用「加载中」盖住(避免闪烁) */
|
|
||||||
const showBootLoading = loading && stores.length === 0;
|
const showBootLoading = loading && stores.length === 0;
|
||||||
|
|
||||||
const childIdsByParent = useMemo(() => {
|
const childIdsByParent = useMemo(() => {
|
||||||
@@ -107,7 +121,9 @@ export default function StoresPage() {
|
|||||||
nextCode: string,
|
nextCode: string,
|
||||||
coords: UserCoords | null,
|
coords: UserCoords | null,
|
||||||
cityKey: string,
|
cityKey: string,
|
||||||
nextRegion: RegionSelection,
|
listRegion: RegionSelection,
|
||||||
|
/** 写入会话的筛选器;默认保留用户当前选择 */
|
||||||
|
filterRegion: RegionSelection = regionRef.current,
|
||||||
) {
|
) {
|
||||||
const seq = ++fetchSeqRef.current;
|
const seq = ++fetchSeqRef.current;
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -123,12 +139,17 @@ export default function StoresPage() {
|
|||||||
const items = Array.isArray(list) ? list : [];
|
const items = Array.isArray(list) ? list : [];
|
||||||
setStores(items);
|
setStores(items);
|
||||||
fetchCityKeyRef.current = cityKey;
|
fetchCityKeyRef.current = cityKey;
|
||||||
storesListCache = {
|
const prev = getStoresListCache();
|
||||||
|
setStoresListCache({
|
||||||
cityKey,
|
cityKey,
|
||||||
cityCode: nextCode,
|
cityCode: nextCode,
|
||||||
region: toCityWideRegion(nextRegion),
|
listRegion: toCityWideRegion(listRegion),
|
||||||
items,
|
items,
|
||||||
};
|
filterRegion,
|
||||||
|
keyword: prev?.keyword ?? keyword,
|
||||||
|
keywordInput: prev?.keywordInput ?? keywordInput,
|
||||||
|
category: prev?.category ?? category,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== fetchSeqRef.current) return;
|
if (seq !== fetchSeqRef.current) return;
|
||||||
toast(e instanceof Error ? e.message : '加载失败');
|
toast(e instanceof Error ? e.message : '加载失败');
|
||||||
@@ -138,46 +159,48 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 先稳住缓存画面 → 再定位;同城不请求;换城再拉。
|
* 首次进入:弹窗 + 定位 + 拉列表。
|
||||||
|
* 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState。
|
||||||
*/
|
*/
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
syncTabBarSelected(1);
|
syncTabBarSelected(1);
|
||||||
|
|
||||||
// 同步恢复缓存,避免 await 定位期间 region 不对导致列表被滤空
|
if (isStoresSessionBootstrapped()) {
|
||||||
if (storesListCache) {
|
return;
|
||||||
fetchCityKeyRef.current = storesListCache.cityKey;
|
|
||||||
setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items));
|
|
||||||
setRegion((prev) =>
|
|
||||||
sameFilterCity(prev, storesListCache!.region) ? prev : storesListCache!.region,
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
markStoresSessionBootstrapped();
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const resolved = await resolveUserCity();
|
const { confirm } = await Taro.showModal({
|
||||||
const nextCode = getCityCodeForCatalog(resolved);
|
title: '获取当前位置',
|
||||||
const nextRegion = toCityWideRegion(resolved.region);
|
content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。',
|
||||||
const nextCityKey = makeCityKey(nextRegion);
|
confirmText: '允许',
|
||||||
|
cancelText: '暂不',
|
||||||
|
}).catch(() => ({ confirm: false, cancel: true }));
|
||||||
|
|
||||||
if (
|
if (confirm) {
|
||||||
fetchCityKeyRef.current === nextCityKey ||
|
setLoading(true);
|
||||||
storesListCache?.cityKey === nextCityKey
|
const resolved = await resolveUserCity(true);
|
||||||
) {
|
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
|
||||||
if (storesListCache?.cityKey === nextCityKey) {
|
const nextCityKey = makeCityKey(nextRegion);
|
||||||
fetchCityKeyRef.current = nextCityKey;
|
setRegion(nextRegion);
|
||||||
setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items));
|
regionRef.current = nextRegion;
|
||||||
// 同城不覆盖用户已选区县
|
await fetchStores(
|
||||||
setRegion((prev) => (sameFilterCity(prev, nextRegion) ? prev : nextRegion));
|
cityCode,
|
||||||
}
|
readCachedUserCoords(),
|
||||||
setLoading(false);
|
nextCityKey,
|
||||||
|
nextRegion,
|
||||||
|
nextRegion,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 换城:先清空再 loading,避免旧城数据 + 新城筛选交叉闪一下
|
const nextRegion = toCityWideRegion(DEFAULT_REGION);
|
||||||
setStores([]);
|
const nextCityKey = makeCityKey(nextRegion);
|
||||||
setRegion(nextRegion);
|
setRegion(nextRegion);
|
||||||
|
regionRef.current = nextRegion;
|
||||||
setLoading(true);
|
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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveUserCity(true);
|
const resolved = await resolveUserCity(true);
|
||||||
const nextCode = getCityCodeForCatalog(resolved);
|
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
|
||||||
const nextRegion = toCityWideRegion(resolved.region);
|
|
||||||
const nextCityKey = makeCityKey(nextRegion);
|
const nextCityKey = makeCityKey(nextRegion);
|
||||||
setRegion(nextRegion);
|
setRegion(nextRegion);
|
||||||
|
regionRef.current = nextRegion;
|
||||||
if (fetchCityKeyRef.current !== nextCityKey) {
|
if (fetchCityKeyRef.current !== nextCityKey) {
|
||||||
setStores([]);
|
setStores([]);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion);
|
await fetchStores(
|
||||||
|
cityCode,
|
||||||
|
readCachedUserCoords(),
|
||||||
|
nextCityKey,
|
||||||
|
nextRegion,
|
||||||
|
nextRegion,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '加载失败');
|
toast(e instanceof Error ? e.message : '加载失败');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -230,7 +259,9 @@ export default function StoresPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function applySearch() {
|
function applySearch() {
|
||||||
setKeyword(keywordInput.trim());
|
const next = keywordInput.trim();
|
||||||
|
setKeyword(next);
|
||||||
|
patchStoresFilterCache({ keyword: next, keywordInput });
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
@@ -238,6 +269,49 @@ export default function StoresPage() {
|
|||||||
setKeyword('');
|
setKeyword('');
|
||||||
setCategory(EMPTY_CATEGORY);
|
setCategory(EMPTY_CATEGORY);
|
||||||
setRegion(DEFAULT_REGION);
|
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) {
|
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-text">{categoryLabel}</Text>
|
||||||
<Text className="store-filter-chip-arrow">▾</Text>
|
<Text className="store-filter-chip-arrow">▾</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="store-filter-reset" onClick={resetFilters}>
|
<View
|
||||||
重置
|
className="store-filter-icon-btn"
|
||||||
</Text>
|
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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -349,14 +437,21 @@ export default function StoresPage() {
|
|||||||
value={region}
|
value={region}
|
||||||
levels={3}
|
levels={3}
|
||||||
onClose={() => setRegionOpen(false)}
|
onClose={() => setRegionOpen(false)}
|
||||||
onConfirm={(next) => setRegion(next)}
|
onConfirm={(next) => {
|
||||||
|
setRegion(next);
|
||||||
|
regionRef.current = next;
|
||||||
|
patchStoresFilterCache({ filterRegion: next });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<CategoryPicker
|
<CategoryPicker
|
||||||
open={categoryOpen}
|
open={categoryOpen}
|
||||||
tree={categoryTree}
|
tree={categoryTree}
|
||||||
value={category}
|
value={category}
|
||||||
onClose={() => setCategoryOpen(false)}
|
onClose={() => setCategoryOpen(false)}
|
||||||
onConfirm={(next) => setCategory(next)}
|
onConfirm={(next) => {
|
||||||
|
setCategory(next);
|
||||||
|
patchStoresFilterCache({ category: next });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -126,6 +126,13 @@
|
|||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefit-hero-logo-img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.benefit-hero-cta {
|
.benefit-hero-cta {
|
||||||
|
|||||||
@@ -116,15 +116,27 @@
|
|||||||
color: var(--color-subtle-gray);
|
color: var(--color-subtle-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-filter-reset {
|
.store-filter-icon-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 10px;
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-container-low);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 13px;
|
color: var(--color-heritage-red);
|
||||||
font-weight: 600;
|
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);
|
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;
|
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 {
|
declare namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
TARO_ENV: 'weapp' | 'h5' | string;
|
TARO_ENV: 'weapp' | 'h5' | string;
|
||||||
|
|||||||
@@ -111,3 +111,20 @@ export type PartnerProxyOrderCreateRequest = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
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_SHIP: 'ORDER_SHIP',
|
||||||
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
|
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
|
||||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||||
|
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||||
STORE_CREATE: 'STORE_CREATE',
|
STORE_CREATE: 'STORE_CREATE',
|
||||||
STORE_UPDATE: 'STORE_UPDATE',
|
STORE_UPDATE: 'STORE_UPDATE',
|
||||||
STORE_STATUS: 'STORE_STATUS',
|
STORE_STATUS: 'STORE_STATUS',
|
||||||
@@ -122,6 +123,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||||
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
|
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
|
||||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||||
|
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
[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 { AdminUsersService } from './admin-users.service';
|
||||||
import { AdminOrdersController } from './admin-orders.controller';
|
import { AdminOrdersController } from './admin-orders.controller';
|
||||||
import { AdminOrdersService } from './admin-orders.service';
|
import { AdminOrdersService } from './admin-orders.service';
|
||||||
|
import { AdminProxyOrdersController } from './admin-proxy-orders.controller';
|
||||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||||
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||||
import { AdminStoresService } from './admin-stores.service';
|
import { AdminStoresService } from './admin-stores.service';
|
||||||
@@ -76,6 +77,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
|||||||
AdminDeployController,
|
AdminDeployController,
|
||||||
AdminUsersController,
|
AdminUsersController,
|
||||||
AdminOrdersController,
|
AdminOrdersController,
|
||||||
|
AdminProxyOrdersController,
|
||||||
AdminStoresController,
|
AdminStoresController,
|
||||||
AdminStoreAccountsController,
|
AdminStoreAccountsController,
|
||||||
AdminStoreMediaController,
|
AdminStoreMediaController,
|
||||||
|
|||||||
@@ -1383,4 +1383,255 @@ export class TradeService {
|
|||||||
|
|
||||||
return this.getPartnerOrder(partnerAccountId, order.id);
|
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