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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 09:18:19 +08:00
parent 1a0afb6d39
commit 2cd4e25682
21 changed files with 1438 additions and 143 deletions
@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Typography,
message,
} from 'antd';
import type {
HqProxyOrderCreateRequest,
PartnerProxyDeliveryMode,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
} from '@dukang/shared-types';
import ChinaRegionCascader from './ChinaRegionCascader';
import { parseRegionCodes } from '../lib/china-region';
import { request } from '../lib/api';
type ProxyOrderModalProps = {
open: boolean;
onClose: () => void;
onSuccess: (order: { id: string; orderNo: string }) => void;
};
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [phone, setPhone] = useState('');
const [customerSmsCode, setCustomerSmsCode] = useState('');
const [operatorSmsCode, setOperatorSmsCode] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [confirmStep, setConfirmStep] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [customerCooldown, setCustomerCooldown] = useState(0);
const [operatorCooldown, setOperatorCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
useEffect(() => {
if (!open) return;
setLoadingOptions(true);
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, [open]);
useEffect(() => {
if (!open || !productId || quantity < 1) {
setPreview(null);
return;
}
const timer = window.setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
deliveryMode,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
})
.then(setPreview)
.catch(() => setPreview(null))
.finally(() => setPreviewLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [open, productId, quantity, deliveryMode, region?.city, region?.district]);
function resetForm() {
setPhone('');
setCustomerSmsCode('');
setOperatorSmsCode('');
setReceiverName('');
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
setConfirmStep(false);
setPreview(null);
setCustomerCooldown(0);
setOperatorCooldown(0);
setProductId(options?.products[0]?.id);
}
function handleClose() {
resetForm();
onClose();
}
function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
setter(60);
const timer = window.setInterval(() => {
setter((s) => {
if (s <= 1) {
window.clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
message.warning('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-customer-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
});
message.success(`客户验证码已发送至 ${res.maskedPhone}`);
startCooldown(setCustomerCooldown);
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败');
}
}
async function sendOperatorSms() {
try {
const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-operator-sms', {
method: 'POST',
body: '{}',
});
message.success(`确认验证码已发送至总部手机 ${res.maskedPhone}`);
startCooldown(setOperatorCooldown);
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.trim()) return '请输入客户验证码';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else {
const product = options?.products.find((p) => p.id === productId);
if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
async function openConfirmStep() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
setConfirmStep(true);
setOperatorSmsCode('');
await sendOperatorSms();
}
async function submit() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
if (!operatorSmsCode.trim()) {
message.warning('请输入总部确认验证码');
return;
}
const payload: HqProxyOrderCreateRequest = {
phone: phone.trim(),
customerSmsCode: customerSmsCode.trim(),
operatorSmsCode: operatorSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
};
setSubmitting(true);
try {
const order = await request<{ id: string; orderNo: string }>('/admin/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success(`代下单成功:${order.orderNo}`);
resetForm();
onSuccess(order);
} catch (e) {
message.error(e instanceof Error ? e.message : '下单失败');
} finally {
setSubmitting(false);
}
}
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<Modal
title="代下单"
open={open}
onCancel={handleClose}
width={640}
destroyOnClose
footer={
confirmStep ? (
<Space>
<Button
onClick={() => {
setConfirmStep(false);
setOperatorSmsCode('');
}}
>
</Button>
<Button type="primary" loading={submitting} onClick={() => void submit()}>
</Button>
</Space>
) : (
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={loadingOptions} onClick={() => void openConfirmStep()}>
</Button>
</Space>
)
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="线下已收款:提交后订单直接完成并发放好客权益(客户验证码 + 总部确认码)"
/>
{!confirmStep ? (
<Form layout="vertical">
<Form.Item label="用户手机号" required>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="11 位手机号"
value={phone}
maxLength={11}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
<Button disabled={customerCooldown > 0} onClick={() => void sendCustomerSms()}>
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label="客户验证码" required>
<Input
placeholder="发至客户手机"
value={customerSmsCode}
maxLength={6}
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</Form.Item>
<Form.Item label="酒品" required>
<Select
showSearch
optionFilterProp="label"
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={setProductId}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
}))}
/>
</Form.Item>
<Form.Item label="数量" required>
<InputNumber min={1} value={quantity} onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))} style={{ width: '100%' }} />
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)">
<Select
allowClear
placeholder="不绑定"
value={promoCodeId}
onChange={setPromoCodeId}
options={(options?.promoCodes ?? []).map((p) => ({
value: p.id,
label: `${p.code} · ${p.name}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="履约方式" required>
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
</Form.Item>
{deliveryMode === 'ADDRESS' ? (
<>
<Form.Item label="收货人(选填)">
<Input
placeholder="默认:用户+手机尾号"
value={receiverName}
maxLength={32}
onChange={(e) => setReceiverName(e.target.value)}
/>
</Form.Item>
<Form.Item label="省市区" required>
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
</Form.Item>
<Form.Item label="详细地址" required>
<Input.TextArea
rows={2}
placeholder="街道门牌等"
value={addressDetail}
maxLength={256}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
</Checkbox>
</Form.Item>
</>
) : null}
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
{previewLoading ? (
<div></div>
) : preview ? (
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
<div>{deliveryLabel}</div>
<div>¥{fmtMoney(preview.productAmount)}</div>
<div>¥{fmtMoney(preview.payAmount)}</div>
<div>¥{fmtMoney(preview.benefitAmount)}</div>
</Space>
) : (
<div style={{ marginTop: 8 }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</div>
</Form>
) : (
<Form layout="vertical">
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={`即将为 ${phone} 创建已完成订单并发放权益,请输入发至总部账号手机的确认验证码`}
/>
<Form.Item label="总部确认验证码" required>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="发至当前登录 HQ 手机"
value={operatorSmsCode}
maxLength={6}
onChange={(e) => setOperatorSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<Button disabled={operatorCooldown > 0} onClick={() => void sendOperatorSms()}>
{operatorCooldown > 0 ? `${operatorCooldown}s` : '重新发送'}
</Button>
</Space.Compact>
</Form.Item>
{preview ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{deliveryLabel} · ¥{fmtMoney(preview.payAmount)} · ¥{fmtMoney(preview.benefitAmount)}
</Typography.Paragraph>
) : null}
</Form>
)}
</Modal>
);
}
+4
View File
@@ -148,6 +148,10 @@ export type AdminOrderRow = {
fulfillmentWarehouse?: { id: string; name: string } | null; 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;
+44 -10
View File
@@ -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>
); );
} }
+1 -1
View File
@@ -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(() => {
+3
View File
@@ -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' });
} }
+135
View File
@@ -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 */
}
}
+164 -38
View File
@@ -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('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
}
+3 -2
View File
@@ -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
+5 -17
View File
@@ -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);
+17 -6
View File
@@ -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) : '') ||
+156 -61
View File
@@ -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>
); );
+7
View File
@@ -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 {
+16 -4
View File
@@ -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);
} }
+8
View File
@@ -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;
+17
View File
@@ -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,
});
}
} }