增加现场提货的配置

This commit is contained in:
2026-08-26 09:22:29 +08:00
parent c7cd0f6cf2
commit 632539e8dc
28 changed files with 393 additions and 105 deletions
+23 -2
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import {
Button,
Descriptions,
Divider,
Drawer,
Form,
Input,
@@ -362,8 +363,28 @@ export default function CitiesPage() {
<Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Divider orientation="left"></Divider>
<Form.Item
name="pickupMinQty"
label="现场提货"
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="localMinQty"
label="同城配送"
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="crossMinQty"
label="跨城配送"
extra="按瓶当量。当前箱规 6 瓶/箱时,填 6 = 最少 1 箱。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="maxPartnerCommissionPercent"
label="合伙人佣金合计上限 %"
@@ -349,6 +349,7 @@ export default function FulfillmentProvidersPage() {
<Form.Item
name="baseBottles"
label="起送瓶数"
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
@@ -3,7 +3,7 @@ import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react';
import { toast } from '../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets';
import { openWecomCustomerServiceChat } from '../lib/wecom-cs';
import { formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs';
export type ContactCsSessionContext = {
orderId?: string;
@@ -72,7 +72,9 @@ export default function ContactCsButton({
}
toast('请在微信内打开后联系客服');
} catch (e) {
toast(e instanceof Error ? e.message : '无法打开客服');
console.error('[wecom-cs] open failed', e);
const msg = formatOpenCsError(e);
if (msg) toast(msg);
}
}
+66 -33
View File
@@ -1,5 +1,3 @@
import Taro from '@tarojs/taro';
export type WecomCsSessionContext = {
orderId?: string;
orderNo?: string;
@@ -14,28 +12,69 @@ type OpenCsChatOption = {
sendMessagePath?: string;
};
type OpenCsChatFn = (option: OpenCsChatOption) => Promise<unknown>;
type WxCsFail = { errMsg?: string; errCode?: number };
function getTaroOpenCsChat(): OpenCsChatFn | null {
const api = (Taro as unknown as { openCustomerServiceChat?: OpenCsChatFn }).openCustomerServiceChat;
return typeof api === 'function' ? api : null;
type WxCsChat = {
openCustomerServiceChat?: (
option: OpenCsChatOption & {
success?: () => void;
fail?: (err: WxCsFail) => void;
},
) => void;
};
/**
* 取小程序原生 wx.openCustomerServiceChat。
* 官方不支持 Promise 风格;Taro 模块作用域下也可能读不到全局 wx。
*/
function getWxOpenCsChat(): WxCsChat['openCustomerServiceChat'] | null {
if (process.env.TARO_ENV !== 'weapp') return null;
const candidates: Array<WxCsChat | null | undefined> = [];
try {
// eslint-disable-next-line no-undef
if (typeof wx !== 'undefined') candidates.push(wx as WxCsChat);
} catch {
/* ignore */
}
candidates.push((globalThis as typeof globalThis & { wx?: WxCsChat }).wx);
try {
const fromRuntime = new Function(
'return typeof wx !== "undefined" ? wx : null',
)() as WxCsChat | null;
candidates.push(fromRuntime);
} catch {
/* ignore */
}
for (const api of candidates) {
if (api && typeof api.openCustomerServiceChat === 'function') {
return api.openCustomerServiceChat.bind(api);
}
}
return null;
}
function getWxOpenCsChat(): ((option: OpenCsChatOption & {
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => void) | null {
const wxApi = (
globalThis as {
wx?: {
openCustomerServiceChat?: (option: OpenCsChatOption & {
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => void;
};
}
).wx?.openCustomerServiceChat;
return typeof wxApi === 'function' ? wxApi : null;
function rawCsError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (err && typeof err === 'object' && 'errMsg' in err) {
return String((err as WxCsFail).errMsg || '');
}
return '';
}
/** 把微信 fail 转成可读提示;用户取消返回空串,调用方不 toast */
export function formatOpenCsError(err: unknown): string {
const raw = rawCsError(err);
if (/cancel/i.test(raw)) return '';
if (/not bind|not bound/i.test(raw)) {
return '企业ID未绑定当前小程序。请到微信公众平台「功能 → 客服 → 微信客服」填写同一企业ID';
}
if (!raw || /openCustomerServiceChat:fail$/i.test(raw.trim())) {
return '无法打开微信客服。开发者工具模拟器通常不可用,请用真机预览';
}
return raw;
}
/** 小程序调起企业微信「微信客服」会话 */
@@ -50,6 +89,11 @@ export async function openWecomCustomerServiceChat(params: {
throw new Error('企微客服未配置');
}
const wxApi = getWxOpenCsChat();
if (!wxApi) {
throw new Error('当前微信版本不支持企业微信客服');
}
const option: OpenCsChatOption = {
extInfo: { url },
corpId,
@@ -64,22 +108,11 @@ export async function openWecomCustomerServiceChat(params: {
}
}
const taroApi = getTaroOpenCsChat();
if (taroApi) {
await taroApi(option);
return;
}
const wxApi = getWxOpenCsChat();
if (!wxApi) {
throw new Error('当前微信版本不支持企业微信客服');
}
await new Promise<void>((resolve, reject) => {
wxApi({
...option,
success: () => resolve(),
fail: (err) => reject(new Error(err?.errMsg || '无法打开企业微信客服')),
fail: (err) => reject(err),
});
});
}
+1 -1
View File
@@ -185,7 +185,7 @@ export default function HomePage() {
}
async function goOnSitePickup(productId: string) {
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
@@ -56,6 +56,11 @@ export default function OrderConfirmPickupPage() {
})
.then((data) => {
if (!cancelled) {
const floor = data.minQty ?? 2;
if (quantity < floor) {
setQuantity(floor);
return;
}
setPreview(data);
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
}
@@ -109,7 +114,7 @@ export default function OrderConfirmPickupPage() {
async function submit() {
if (!canSubmit) {
if (!quantityOk) {
const tip = `现场提货至少购买 ${minQty}`;
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
setMsg(tip);
toast(tip);
return;
@@ -170,7 +175,7 @@ export default function OrderConfirmPickupPage() {
const submitLabel = loading
? '提交中…'
: !quantityOk
? `至少购买 ${minQty}`
? `至少购买 ${minQty}${unitLabel}`
: '提交订单';
return (
@@ -223,7 +228,7 @@ export default function OrderConfirmPickupPage() {
</View>
{!quantityOk ? (
<Text className="order-qty-hint">
{`现场提货至少购买 ${minQty},请调整数量`}
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
</Text>
) : null}
</View>
@@ -304,7 +304,7 @@ export default function OrderConfirmPage() {
: !addressOk
? '请更换地址'
: !quantityOk
? `至少购买 ${minQty}`
? `至少购买 ${minQty}${unitLabel}`
: '提交订单';
const displayMsg = msg || addressHint;
@@ -349,7 +349,7 @@ export default function OrderConfirmPage() {
<View className="order-card">
<Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}
{quantity < minQty ? `;跨城至少购买 ${minQty}${unitLabel}` : ''}
</Text>
</View>
) : null}
@@ -232,7 +232,7 @@ export default function ProductDetailPage() {
if (!productId) return;
const skuId = ensureSkuSelected();
if (specEnabled && !skuId) return;
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
const qs = [`productId=${productId}`, `qty=${qty}`];
if (skuId) qs.push(`skuId=${skuId}`);
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;