merge(dev): v3.5.10 同城配送提示与企微客服
CI / verify (push) Waiting to run

This commit is contained in:
2026-08-25 14:30:43 +08:00
29 changed files with 758 additions and 28 deletions
@@ -7,6 +7,7 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
@@ -68,7 +69,10 @@ export default function FulfillmentProvidersPage() {
setLoading(true);
try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res);
setRows(Array.isArray(res) ? res : []);
} catch (e) {
setRows([]);
message.error(e instanceof Error ? e.message : '加载承运商失败');
} finally {
setLoading(false);
}
@@ -94,6 +98,7 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: '',
});
setOpen(true);
}
@@ -122,6 +127,7 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: row.deliveryHintHtml || '',
});
setOpen(true);
}
@@ -144,6 +150,7 @@ export default function FulfillmentProvidersPage() {
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
},
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
};
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -176,6 +183,16 @@ export default function FulfillmentProvidersPage() {
void load();
}
async function remove(row: FulfillmentProviderDto) {
try {
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{
@@ -230,11 +247,23 @@ export default function FulfillmentProvidersPage() {
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{
title: '操作',
width: 80,
width: 140,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Popconfirm
title={`确认删除承运商「${row.name}」?`}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => void remove(row)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
@@ -288,6 +317,17 @@ export default function FulfillmentProvidersPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item
name="deliveryHintHtml"
label="配送信息提示"
extra="C 端同城送展示。支持 HTMLspan/p/br/b/strong/i/em/fontstyle 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
>
<Input.TextArea
rows={3}
maxLength={2000}
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
/>
</Form.Item>
<Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
@@ -1,5 +1,9 @@
import { useEffect, useState } from 'react';
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';
export type ContactCsSessionContext = {
orderId?: string;
@@ -16,7 +20,7 @@ type ContactCsButtonProps = {
const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符) */
/** 组装 session-from(微信限制约 1000 字符;原生小程序客服兜底用 */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user';
const parts = ['dukang'];
@@ -26,16 +30,59 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
return parts.join('|');
}
function canOpenWecom(url: string, corpId: string) {
return !!url.trim() && !!corpId.trim();
}
/**
* 微信小程序客服入口(open-type=contact)。
* weapp 环境不渲染,由调用方走电话等兜底。
* C 端在线客服:
* - weapp 且已配置 CorpID + kfid:调起企业微信「微信客服」
* - weapp 未配 CorpID:回退 open-type=contact
* - H5:有 kfid 则打开企微客服链接
*/
export default function ContactCsButton({
className = '',
children = '联系在线客服',
session,
}: ContactCsButtonProps) {
if (!isWeapp) return null;
const [cs, setCs] = useState(() => getBrandAssetsSync());
useEffect(() => {
void loadBrandAssets().then(setCs);
}, []);
const wecomChatReady = canOpenWecom(cs.customerServiceWecomUrl, cs.wecomCorpId);
const wecomWebReady = !!cs.customerServiceWecomUrl.trim();
if (!isWeapp && !wecomWebReady) return null;
async function openWecom() {
try {
if (isWeapp && wecomChatReady) {
await openWecomCustomerServiceChat({
url: cs.customerServiceWecomUrl,
corpId: cs.wecomCorpId,
session,
});
return;
}
if (typeof window !== 'undefined' && cs.customerServiceWecomUrl) {
window.location.href = cs.customerServiceWecomUrl;
return;
}
toast('请在微信内打开后联系客服');
} catch (e) {
toast(e instanceof Error ? e.message : '无法打开客服');
}
}
if (wecomChatReady || (!isWeapp && wecomWebReady)) {
return (
<Button className={className} hoverClass="none" onClick={() => void openWecom()}>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
return (
<Button
@@ -0,0 +1,22 @@
import { RichText, Text } from '@tarojs/components';
import { deliveryHintHtmlToRichNodes } from '@dukang/shared-types';
import { DEFAULT_LOCAL_DELIVERY_HINT } from '../lib/local-delivery';
type DeliveryHintHtmlProps = {
html?: string | null;
className?: string;
};
/** 承运商配送提示:HTML 用 RichTexttextarea 里的换行转成 br */
export default function DeliveryHintHtml({
html,
className = '',
}: DeliveryHintHtmlProps) {
const raw = (html || '').trim() || DEFAULT_LOCAL_DELIVERY_HINT;
const nodes = deliveryHintHtmlToRichNodes(raw);
const looksHtml = /<[a-z][\s\S]*>/i.test(nodes);
if (!looksHtml) {
return <Text className={className}>{nodes}</Text>;
}
return <RichText className={className} nodes={nodes} />;
}
+57
View File
@@ -0,0 +1,57 @@
import type { LocalDeliveryDto } from '@dukang/shared-types';
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
import { request } from './api';
export { DEFAULT_LOCAL_DELIVERY_HINT };
const CACHE_TTL_MS = 60_000;
let cached: LocalDeliveryDto[] | null = null;
let cachedAt = 0;
let inflight: Promise<LocalDeliveryDto[]> | null = null;
function cityAliases(name: string): string[] {
const raw = name.trim();
if (!raw) return [];
const noSuffix = raw.replace(/市$/, '');
const withSuffix = raw.endsWith('市') ? raw : `${raw}`;
return [raw, noSuffix, withSuffix];
}
export async function loadLocalDeliveries(force = false): Promise<LocalDeliveryDto[]> {
if (!force && cached && Date.now() - cachedAt < CACHE_TTL_MS) return cached;
if (!force && inflight) return inflight;
inflight = request<LocalDeliveryDto[]>('/catalog/local-deliveries')
.then((list) => {
cached = Array.isArray(list) ? list : [];
cachedAt = Date.now();
return cached;
})
.catch(() => {
cached = cached ?? [];
return cached;
})
.finally(() => {
inflight = null;
});
return inflight;
}
export function matchLocalDelivery(
list: LocalDeliveryDto[],
opts: { cityCode?: string | null; cityName?: string | null },
): LocalDeliveryDto | null {
const code = String(opts.cityCode || '').trim();
if (code) {
const byCode = list.find((row) => row.city.code === code);
if (byCode) return byCode;
}
const names = new Set(cityAliases(String(opts.cityName || '')));
if (!names.size) return null;
return list.find((row) => cityAliases(row.city.name).some((n) => names.has(n))) ?? null;
}
export function resolveLocalDeliveryHintHtml(row: LocalDeliveryDto | null): string {
const html = row?.hintHtml?.trim();
if (html) return html;
return DEFAULT_LOCAL_DELIVERY_HINT;
}
@@ -1,3 +1,8 @@
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
/** @deprecated 使用 DEFAULT_LOCAL_DELIVERY_HINT;空配置回退 */
export const LOCAL_DELIVERY_ETA_HINT = DEFAULT_LOCAL_DELIVERY_HINT;
/** 商品履约能力(与 HQ / 交易硬闸一致) */
export type FulfillmentFlags = {
+85
View File
@@ -0,0 +1,85 @@
import Taro from '@tarojs/taro';
export type WecomCsSessionContext = {
orderId?: string;
orderNo?: string;
from?: string;
};
type OpenCsChatOption = {
extInfo: { url: string };
corpId: string;
showMessageCard?: boolean;
sendMessageTitle?: string;
sendMessagePath?: string;
};
type OpenCsChatFn = (option: OpenCsChatOption) => Promise<unknown>;
function getTaroOpenCsChat(): OpenCsChatFn | null {
const api = (Taro as unknown as { openCustomerServiceChat?: OpenCsChatFn }).openCustomerServiceChat;
return typeof api === 'function' ? api : 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;
}
/** 小程序调起企业微信「微信客服」会话 */
export async function openWecomCustomerServiceChat(params: {
url: string;
corpId: string;
session?: WecomCsSessionContext;
}): Promise<void> {
const url = params.url.trim();
const corpId = params.corpId.trim();
if (!url || !corpId) {
throw new Error('企微客服未配置');
}
const option: OpenCsChatOption = {
extInfo: { url },
corpId,
};
if (params.session?.orderNo || params.session?.orderId) {
option.showMessageCard = true;
option.sendMessageTitle = params.session.orderNo
? `订单 ${params.session.orderNo}`
: '订单咨询';
if (params.session.orderId) {
option.sendMessagePath = `pages/order-detail/index?id=${params.session.orderId}`;
}
}
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 || '无法打开企业微信客服')),
});
});
}
@@ -16,35 +16,43 @@ function dialPhone(phone: string) {
}
export default function CustomerServicePage() {
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
const [brand, setBrand] = useState(() => getBrandAssetsSync());
useEffect(() => {
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
void loadBrandAssets().then(setBrand);
}, []);
const phone = brand.customerServicePhone;
const wecomReady = !!brand.customerServiceWecomUrl.trim() && !!brand.wecomCorpId.trim();
const wecomUrlReady = !!brand.customerServiceWecomUrl.trim();
const hint = isWeapp
? wecomReady
? '点击下方按钮,进入企业微信客服会话'
: '点击下方按钮,进入在线客服会话'
: wecomUrlReady
? '点击下方按钮进入企业微信客服,或拨打客服电话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话';
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body">
<Text className="cs-brand"></Text>
<Text className="cs-hint">
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hint">{hint}</Text>
<Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp ? (
{isWeapp || wecomUrlReady ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
<View className={isWeapp || wecomUrlReady ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
<Text>
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
{isWeapp || wecomUrlReady ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
</Text>
</View>
{!isWeapp ? (
{!isWeapp && !wecomUrlReady ? (
<Text className="cs-phone-display">{phone}</Text>
) : null}
</View>
@@ -11,7 +11,9 @@ import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure';
@@ -75,6 +77,7 @@ export default function OrderConfirmPage() {
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [localHintHtml, setLocalHintHtml] = useState('');
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
@@ -151,6 +154,18 @@ export default function OrderConfirmPage() {
[addresses, addressId],
);
useEffect(() => {
const cityName = selectedAddress?.city;
if (!cityName) {
setLocalHintHtml('');
return;
}
void loadLocalDeliveries().then((list) => {
const row = matchLocalDelivery(list, { cityName });
setLocalHintHtml(row ? resolveLocalDeliveryHintHtml(row) : '');
});
}, [selectedAddress?.city]);
const allowCross =
preview?.allowCrossCityDelivery !== undefined
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
@@ -339,6 +354,12 @@ export default function OrderConfirmPage() {
</View>
) : null}
{!isCross && addressOk && addressId && localHintHtml ? (
<View className="order-card">
<DeliveryHintHtml className="u-muted" html={localHintHtml} />
</View>
) : null}
{preview ? (
<>
<View className="order-card">
@@ -10,6 +10,8 @@ import ContactCsButton from '../../components/ContactCsButton';
import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import {
formatEstimatedArrival,
isLogisticsNotArrived,
@@ -83,6 +85,22 @@ const STATUS_LABELS: Record<string, string> = {
REFUNDED: '已退款',
};
function applyLocalDeliveryHint(
data: OrderDetail,
setLocalHintHtml: (html: string) => void,
) {
if (data.deliveryType !== 'LOCAL') {
setLocalHintHtml('');
return;
}
void loadLocalDeliveries().then((list) => {
const row = data.receiverCity
? matchLocalDelivery(list, { cityName: data.receiverCity })
: null;
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
});
}
function fullReceiverAddress(order: OrderDetail) {
const detail = (order.receiverAddress || '').trim();
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
@@ -102,6 +120,7 @@ export default function OrderDetailPage() {
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
const [trackLoading, setTrackLoading] = useState(false);
const [localHintHtml, setLocalHintHtml] = useState('');
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
if (!orderId || !shouldLoadOrderTrack(delivery)) {
@@ -133,6 +152,7 @@ export default function OrderDetailPage() {
.then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery, data.status);
applyLocalDeliveryHint(data, setLocalHintHtml);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]);
@@ -144,6 +164,7 @@ export default function OrderDetailPage() {
.then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery, data.status);
applyLocalDeliveryHint(data, setLocalHintHtml);
})
.catch(() => {});
});
@@ -361,6 +382,12 @@ export default function OrderDetailPage() {
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text>
</View>
{order.deliveryType === 'LOCAL' && localHintHtml ? (
<View className="order-row">
<Text className="order-row-label"></Text>
<DeliveryHintHtml className="order-row-value" html={localHintHtml} />
</View>
) : null}
</View>
</>
)}
@@ -14,6 +14,7 @@ import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import BenefitFigure from '../../components/BenefitFigure';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api';
@@ -28,6 +29,8 @@ import {
canPickupOnSite,
normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment';
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import {
buildSceneSharePayload,
toWeappShareMessage,
@@ -90,6 +93,7 @@ export default function ProductDetailPage() {
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
const [selected, setSelected] = useState<Record<string, string>>({});
const [localHintHtml, setLocalHintHtml] = useState('');
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
@@ -136,6 +140,17 @@ export default function ProductDetailPage() {
useDidShow(() => {
loadProduct();
void resolveUserCity()
.then((city) =>
loadLocalDeliveries().then((list) => {
const row = matchLocalDelivery(list, {
cityCode: getCityCodeForCatalog(city),
cityName: city.cityName || city.displayCity || city.city,
});
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
}),
)
.catch(() => setLocalHintHtml(resolveLocalDeliveryHintHtml(null)));
});
const attrs = product?.specAttrs ?? [];
@@ -317,6 +332,10 @@ export default function ProductDetailPage() {
</View>
) : null}
{allowOnline && localHintHtml ? (
<DeliveryHintHtml className="product-detail-fulfillment" html={localHintHtml} />
) : null}
<View className="product-detail-promo">
<View className="product-detail-promo-glow" />
<View className="product-detail-promo-head">
@@ -12,7 +12,6 @@ import Taro, {
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
@@ -356,7 +355,6 @@ export default function StoreDetailPage() {
solid={headerSolid}
titleVisible={headerSolid}
onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="store-detail-hero full-bleed">
@@ -128,6 +128,14 @@
margin-bottom: 24px;
}
.product-detail-fulfillment {
display: block;
margin: 0 0 16px;
font-size: 13px;
line-height: 20px;
color: var(--color-on-surface-variant);
}
.product-detail-promo {
position: relative;
margin-top: 8px;
+1
View File
@@ -29,6 +29,7 @@
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
---
+2
View File
@@ -64,9 +64,11 @@
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 |
|------|------|
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
| 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
+121
View File
@@ -0,0 +1,121 @@
# 杜康好客 · v3.5.10 开发文档
> **2026-08-25** · mini-user / API
> **主题**:门店详情去掉分享按钮;同城配送提示 24 小时内送到;小程序客服接通企业微信
---
## 1. 版本目标
| # | 任务 | 类型 | 交付 |
|---|------|------|------|
| 1 | DPT-20260824-896 | BUG | C 端门店详情去掉顶栏「分享」按钮 |
| 2 | DPT-20260824-601 | 优化 | 同城送提示改承运商「配送信息提示」(HTML);C 端 `GET /catalog/local-deliveries` |
| 3 | DPT-20260822-651 | 需求 | 小程序在线客服调起企业微信「微信客服」 |
**不做**:改配送规则 / 改 SLA 计算;合伙人/门店端客服;禁用微信右上角「···」分享菜单。
---
## 2. 门店详情去掉分享按钮
`pages/store-detail` 顶栏不再渲染 `ShareNavButton`
右上角微信原生菜单仍可分享(`enableShareAppMessage` / `WechatShareReady` 保留)。只要去掉页面上的分享按钮。
---
## 3. 同城送提示(承运商 HTML)
不再写死文案。以用户**收货地址城市**为准:该市 `common_city.status=ACTIVE`(已开城)→ 同城,展示对应仓配承运商的「配送信息提示」;未开城 → 跨城,只展示「总部物流、运费到付」。
```
收货市 → 开城仓库(ACTIVE,优先 API_AUTO 且已绑承运商)→ 承运商.delivery_hint_html
```
空字段时 C 端回退纯文本 `同城配送,预计24小时内送到`。不改下单 / 推单 / 起购。
### 3.1 承运商字段
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。允许 `span/p/br/b/strong/i/em/font`style 仅 `color` / `font-weight` / `font-size` / `font-style`。保存与下发前消毒。HQ 文本框里的回车在 C 端转成换行(不必手写 `<br/>`)。
HQ「仓配管理 → 承运商」多行输入,例如:
```html
<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>
```
### 3.2 API
`GET /catalog/local-deliveries`(公开)。可选 `cityCode` / `cityName`
每项:`city` · `warehouse` · `provider` · `hintHtml`。未开城 / 无仓 / 无承运商返回空列表或 `hintHtml=null`,不报错。
### 3.3 C 端
| 页面 | 何时展示 |
|------|----------|
| 商品详情 | 可线上购;用当前选城预览 |
| 确认订单 | 收货市已开城且地址校验通过;`RichText` |
| 订单详情 | `deliveryType === LOCAL`;按收货市匹配 |
---
---
## 4. 小程序客服接通企业微信
原先 `open-type=contact` 进入**小程序原生客服**。本版在已配置企微参数时改为 `wx.openCustomerServiceChat`,进入企业微信「微信客服」(与 H5 kfid 同一套)。
### 配置(HQ → 系统设置 → 微信小程序配置)
| 键 | 说明 |
|----|------|
| `CUSTOMER_SERVICE_WECOM_URL` | 微信客服 kfid 链接;缺省用代码常量 |
| `WECOM_CORP_ID` | 企业 ID`ww` 开头)。企微「我的企业」可查 |
`GET /common/client-config` 下发 `customerServiceWecomUrl``wecomCorpId`
### 行为
| 环境 | 条件 | 行为 |
|------|------|------|
| 小程序 | 链接 + CorpID 都有 | `openCustomerServiceChat`;订单详情可带订单卡片 |
| 小程序 | 未填 CorpID | 回退 `open-type=contact` |
| H5 | 有 kfid 链接 | 打开企微客服网页 |
### 企微侧前置(运营)
1. 开通企业微信「微信客服」,拿到 kfid 链接。
2. 把 C 端小程序关联到该企业的微信客服。
3. 把 CorpID 填进系统设置。未填则用户仍走小程序原生客服。
---
## 4.1 Prisma
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。脚本:[`migrate-fulfillment-delivery-hint-v3510.sql`](../server/dukang-api/prisma/migrate-fulfillment-delivery-hint-v3510.sql)。
## 5. 验收清单
- [ ] 门店详情顶栏没有分享按钮;返回/导航/拨打不受影响
- [ ] HQ 承运商可编辑「配送信息提示」HTML(颜色/粗细/字号);非法标签被去掉
- [ ] `GET /catalog/local-deliveries` 按开城列出仓库+承运商+hintHtml;`cityName` 可筛收货市
- [ ] 商品详情(可线上购)按当前选城展示承运商提示(空则回退「同城配送,预计24小时内送到」)
- [ ] 确认订单:收货市已开城显示 HTML 提示;未开城只显示「总部物流、运费到付」
- [ ] 同城订单详情「配送时效」为该市承运商提示
- [ ] 系统设置可改客服链接与 CorpID;保存后 `client-config` 立即带出
- [ ] 小程序已填 CorpID:联系客服进入企微微信客服(非小程序原生客服后台)
- [ ] 未填 CorpID:小程序仍能打开原生客服,不白屏
---
## 6. 关键路径
| 域 | 路径 |
|----|------|
| 门店详情 | `apps/mini-user/src/pages/store-detail/index.tsx` |
| 同城提示 | `fulfillment-provider.service.ts` · `GET /catalog/local-deliveries` · `DeliveryHintHtml` |
| 客服按钮 | `apps/mini-user/src/components/ContactCsButton.tsx` · `lib/wecom-cs.ts` |
| 下发 | `client-config.controller.ts` · `packages/shared-types` `config.ts` / `wechat.ts` |
| HQ 配置 | `system-config.registry.ts` |
+2
View File
@@ -45,6 +45,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**HQ 列表(v3.5.9**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
**C 端(v3.5.10**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html``GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat``CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
## 5. 验收用例(必过)
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
+6 -3
View File
@@ -22,10 +22,11 @@
- 四 Tab:首页/权益/门店/我的;微信登录+7天会话
- 下单:选城→商品→地址→起购校验→微信支付→权益1:1
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟。二维码内容为门店 H5 URL`{SHOP_H5_URL}/redeem?token=`),微信扫一扫直达核销确认页
- 门店:仅 OPEN;详情含套餐/电话(脱敏可拨打)/两段营业时间
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
- 售后:客服入口;发票/四类型工单按 PRD Wave 进度
- 售后:客服入口(小程序优先企微微信客服,未配 CorpID 回退原生客服);发票/四类型工单按 PRD Wave 进度
- 同城送:收货市已开城则展示承运商「配送信息提示」(HTML);未开城走跨城到付;门店详情无顶栏分享按钮
- 版本:`minClientVersion` 过低强制更新或退出
- 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」)
@@ -47,7 +48,7 @@
## 3. 门店端(h5-shop
- 登录绑定门店;首页扫码核销(微信 JSSDK)
- 登录绑定门店;首页扫码核销(微信 JSSDK);也可微信扫一扫用户核销码直达确认页(仍需点「确认核销」)
- 核销记录;今日汇总;到账金额×60%展示
- 营业状态开关;Mine 门店信息
- 套餐:列表编辑→提交 HQ 审核(v3.4.10)
@@ -68,6 +69,7 @@
| 规则 | 说明 |
|------|------|
| iOS 登录/选店后 | 用 `location.replace(path)``hardNavigateInWechat`),禁止仅 React Router navigate |
| 微信扫一扫落地核销页 | 成功后须 `hardNavigateInWechat('/')` 回首页,否则入场 URL 仍是 `/redeem?token=`,下次首页扫码验签失败 |
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
@@ -150,6 +152,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
| 智能机器人 | `/wecom/bots` 长连接指令 |
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
| 日志 | `/logs/wecom-bots` |
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
eventKey`alert.ops` · `support_ticket.created` · `dev_plan.task_dispatch` · 支付/核销/结算告警。
+15 -2
View File
@@ -30,11 +30,16 @@ export interface AppConfig {
tencentLbsSecretKey: string;
/** C 端 H5 落地页(推广码二维码链接前缀) */
userH5Url: string;
/** 门店 H5 落地页(用户核销码二维码链接前缀) */
shopH5Url: string;
}
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
/** 门店 H5 默认落地页(系统设置 SHOP_H5_URL 未配时回退;HQ 可改) */
export const DEFAULT_SHOP_H5_URL = 'https://shop.dukanghaoke.com';
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
@@ -58,12 +63,15 @@ export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualifi
export const CUSTOMER_SERVICE_PHONE = '13203801799';
/**
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话
* 可在 h5-user 用 VITE_CS_WECOM_URL 覆盖
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页 / 小程序 openCustomerServiceChat
* 可在系统设置 CUSTOMER_SERVICE_WECOM_URL 覆盖
*/
export const CUSTOMER_SERVICE_WECOM_URL =
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
/** 企业微信 CorpID(小程序 wx.openCustomerServiceChat 必填;系统设置 WECOM_CORP_ID */
export const WECOM_CORP_ID = '';
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
export type ClientBrandRuntime = {
userH5Url: string;
@@ -74,6 +82,8 @@ export type ClientBrandRuntime = {
miniUserStaticOssBase: string;
qualificationDisclosureUrl: string;
customerServicePhone: string;
customerServiceWecomUrl: string;
wecomCorpId: string;
};
export function resolveClientBrandRuntime(
@@ -95,6 +105,8 @@ export function resolveClientBrandRuntime(
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
`${staticBase}qualification-disclosure.png`,
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
customerServiceWecomUrl: (e.CUSTOMER_SERVICE_WECOM_URL || CUSTOMER_SERVICE_WECOM_URL).trim(),
wecomCorpId: (e.WECOM_CORP_ID || WECOM_CORP_ID).trim(),
};
}
@@ -265,6 +277,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
shopH5Url: (e.SHOP_H5_URL || DEFAULT_SHOP_H5_URL).replace(/\/$/, ''),
};
return {
...base,
@@ -55,6 +55,8 @@ export interface FulfillmentProviderDto {
settlementMethod: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
prepaidBalance: number;
/** C 端同城配送提示(已消毒 HTML) */
deliveryHintHtml?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -74,6 +76,7 @@ export interface CreateFulfillmentProviderInput {
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
deliveryHintHtml?: string | null;
}
export interface UpdateFulfillmentProviderInput {
@@ -89,6 +92,7 @@ export interface UpdateFulfillmentProviderInput {
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
deliveryHintHtml?: string | null;
}
export { DEFAULT_XFX_LOGISTICS_PRICING };
@@ -113,3 +117,83 @@ export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
export function isXfxProviderCode(code: string): boolean {
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
}
/** 承运商未配置提示时 C 端回退文案 */
export const DEFAULT_LOCAL_DELIVERY_HINT = '同城配送,预计24小时内送到';
export const LOCAL_DELIVERY_HINT_MAX_LEN = 2000;
const HINT_ALLOWED_TAGS = new Set(['span', 'p', 'br', 'b', 'strong', 'i', 'em', 'font']);
const HINT_ALLOWED_STYLES = new Set(['color', 'font-weight', 'font-size', 'font-style']);
export type LocalDeliveryDto = {
city: { id: string; code: string; name: string };
warehouse: { id: string; name: string; fulfillmentMode: string } | null;
provider: { id: string; code: string; name: string } | null;
hintHtml: string | null;
};
function sanitizeHintStyle(raw: string): string {
return raw
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const idx = part.indexOf(':');
if (idx <= 0) return '';
const key = part.slice(0, idx).trim().toLowerCase();
const value = part.slice(idx + 1).trim();
if (!HINT_ALLOWED_STYLES.has(key)) return '';
if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(value)) return '';
return `${key}:${value}`;
})
.filter(Boolean)
.join(';');
}
/** 文本换行转成 br,供 C 端 RichText 使用(不改 HQ 存盘原文) */
export function deliveryHintHtmlToRichNodes(html: string): string {
return html
.replace(/\r\n|\r|\n/g, '<br/>')
.replace(/(?:<br\s*\/?>){3,}/gi, '<br/><br/>');
}
/** 承运商配送提示 HTML:去掉脚本/事件,只保留字号颜色粗细 */
export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
if (raw == null) return null;
let html = String(raw).trim();
if (!html) return null;
if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) {
html = html.slice(0, LOCAL_DELIVERY_HINT_MAX_LEN);
}
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
html = html.replace(/javascript\s*:/gi, '');
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (_full, tag: string, attrs: string) => {
const name = String(tag).toLowerCase();
const closing = String(_full).startsWith('</');
if (!HINT_ALLOWED_TAGS.has(name)) return '';
if (name === 'br') return closing ? '' : '<br/>';
if (closing) return `</${name}>`;
let style = '';
const styleMatch = String(attrs).match(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/i);
if (styleMatch) {
style = sanitizeHintStyle(styleMatch[2] ?? styleMatch[3] ?? '');
}
let color = '';
let size = '';
if (name === 'font') {
const colorMatch = String(attrs).match(/\scolor\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (colorMatch) color = (colorMatch[2] ?? colorMatch[3] ?? colorMatch[4] ?? '').trim();
const sizeMatch = String(attrs).match(/\ssize\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (sizeMatch) size = (sizeMatch[2] ?? sizeMatch[3] ?? sizeMatch[4] ?? '').trim();
}
const extra: string[] = [];
if (style) extra.push(`style="${style}"`);
if (color) extra.push(`color="${color.replace(/"/g, '')}"`);
if (size) extra.push(`size="${size.replace(/"/g, '')}"`);
return extra.length ? `<${name} ${extra.join(' ')}>` : `<${name}>`;
});
const cleaned = html.replace(/&nbsp;/g, ' ').trim();
return cleaned || null;
}
+4
View File
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
qualificationDisclosureUrl?: string;
/** 总部客服电话 */
customerServicePhone?: string;
/** 企业微信「微信客服」链接(kfid) */
customerServiceWecomUrl?: string;
/** 企业微信 CorpID(小程序调起企微客服) */
wecomCorpId?: string;
/** 合伙人入驻:企微客服二维码图片 URL */
partnerOnboardCsQrUrl?: string | null;
/** 合伙人入驻:企微客服提示文案 */
@@ -0,0 +1,3 @@
-- v3.5.10:承运商「配送信息提示」(C 端同城送 HTML)
ALTER TABLE `common_fulfillment_provider`
ADD COLUMN `delivery_hint_html` TEXT NULL AFTER `prepaid_balance`;
+2
View File
@@ -1057,6 +1057,8 @@ model FulfillmentProvider {
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
pricingRulesJson String? @map("pricing_rules_json") @db.Text
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
/// C 端同城配送提示(HTML,消毒后下发)
deliveryHintHtml String? @map("delivery_hint_html") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -5,6 +5,7 @@ import {
BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL,
CUSTOMER_SERVICE_PHONE,
CUSTOMER_SERVICE_WECOM_URL,
DEFAULT_SHARE_BENEFIT_TITLE,
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_HINT,
@@ -12,6 +13,7 @@ import {
DEFAULT_SHARE_ORDER_TITLE,
DEFAULT_SHARE_STORES_TITLE,
DEFAULT_SHARE_TITLE,
DEFAULT_SHOP_H5_URL,
DEFAULT_USER_H5_URL,
MINI_USER_STATIC_OSS_BASE,
MOCK_SMS_FIXED_CODE,
@@ -154,6 +156,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
placeholder: 'https://user.example.com/user',
description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)',
},
{
key: 'SHOP_H5_URL',
label: '门店 H5 落地页',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: 'https://shop.dukanghaoke.com',
description: '用户核销码二维码链接前缀;扫码直达门店核销确认页(无末尾斜杠)',
},
{
key: 'BRAND_LOGO_OSS_BASE',
label: '品牌 Logo OSS 根路径',
@@ -213,6 +224,24 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
placeholder: '13203801799',
description: 'C 端联系客服拨号号码',
},
{
key: 'CUSTOMER_SERVICE_WECOM_URL',
label: '企微微信客服链接',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: 'https://work.weixin.qq.com/kfid/kfc…',
description: 'C 端在线客服 kfid 链接;小程序需同时配置下方企业 ID',
},
{
key: 'WECOM_CORP_ID',
label: '企微企业 IDCorpID',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: 'wwxxxxxxxxxxxx',
description: '企业微信「我的企业」企业 ID。小程序须已关联该企业的微信客服;未填则回退小程序原生客服',
},
{
key: 'PARTNER_ONBOARD_CS_QR_URL',
label: '合伙人入驻 · 企微客服二维码',
@@ -584,6 +613,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
BRAND_LOGO_URL: BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL,
@@ -591,6 +621,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE,
QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL,
CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE,
CUSTOMER_SERVICE_WECOM_URL: CUSTOMER_SERVICE_WECOM_URL,
MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE,
SHARE_HINT: DEFAULT_SHARE_HINT,
SHARE_DEFAULT_TITLE: DEFAULT_SHARE_TITLE,
@@ -37,6 +37,8 @@ export class ClientConfigController {
brandLogoMarkUrl: brand.brandLogoMarkUrl,
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
customerServicePhone: brand.customerServicePhone,
customerServiceWecomUrl: brand.customerServiceWecomUrl || null,
wecomCorpId: brand.wecomCorpId || null,
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
partnerOnboardCsHint:
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
@@ -8,6 +8,7 @@ import {
import {
DEFAULT_XFX_LOGISTICS_PRICING,
isXfxProviderCode,
sanitizeDeliveryHintHtml,
type LogisticsPricingRuleDto,
type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic,
@@ -30,6 +31,7 @@ export type CreateFulfillmentProviderInput = {
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod | string;
pricingRules?: LogisticsPricingRuleDto | null;
deliveryHintHtml?: string | null;
};
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
@@ -149,6 +151,7 @@ export class FulfillmentProviderService {
bankAccountNo: this.normOptional(input.bankAccountNo),
settlementMethod,
pricingRulesJson,
deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml),
},
});
return this.toDto(row);
@@ -195,11 +198,43 @@ export class FulfillmentProviderService {
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
: {}),
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
...(input.deliveryHintHtml !== undefined
? { deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml) }
: {}),
},
});
return this.toDto(row);
}
async remove(id: bigint) {
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓配承运商不存在');
const [billCount, ledgerCount] = await Promise.all([
this.prisma.logisticsBill.count({ where: { fulfillmentProviderId: id } }),
this.prisma.logisticsPrepaidLedger.count({ where: { fulfillmentProviderId: id } }),
]);
if (billCount > 0) {
throw new BadRequestException(`该承运商已有 ${billCount} 笔物流对账单,不能删除`);
}
if (ledgerCount > 0) {
throw new BadRequestException('该承运商已有充值/扣款流水,不能删除');
}
await this.prisma.$transaction(async (tx) => {
await tx.cityWarehouse.updateMany({
where: { fulfillmentProviderId: id },
data: { fulfillmentProviderId: null, fulfillmentMode: 'MANUAL' },
});
await tx.orderDelivery.updateMany({
where: { fulfillmentProviderId: id },
data: { fulfillmentProviderId: null },
});
await tx.fulfillmentProvider.delete({ where: { id } });
});
return { ok: true, id: id.toString() };
}
/** 充值(结算模块可复用) */
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
@@ -413,6 +448,7 @@ export class FulfillmentProviderService {
settlementMethod?: string;
pricingRulesJson?: string | null;
prepaidBalance?: Prisma.Decimal | number;
deliveryHintHtml?: string | null;
createdAt: Date;
updatedAt: Date;
}) {
@@ -434,8 +470,61 @@ export class FulfillmentProviderService {
settlementMethod: row.settlementMethod ?? 'PREPAID',
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
prepaidBalance: Number(row.prepaidBalance ?? 0),
deliveryHintHtml: sanitizeDeliveryHintHtml(row.deliveryHintHtml ?? null),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
async listLocalDeliveries(filter?: { cityCode?: string; cityName?: string }) {
const cityCode = filter?.cityCode?.trim() || '';
const cityName = filter?.cityName?.trim() || '';
const cities = await this.prisma.commonCity.findMany({
where: {
status: 'ACTIVE',
...(cityCode ? { code: cityCode } : {}),
...(cityName && !cityCode
? { name: { in: this.cityNameAliases(cityName) } }
: {}),
},
include: {
warehouses: {
where: { status: 'ACTIVE' },
include: { fulfillmentProvider: true },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { name: 'asc' },
});
return cities.map((city) => {
const preferred =
city.warehouses.find((w) => w.fulfillmentMode === 'API_AUTO' && w.fulfillmentProviderId) ??
city.warehouses.find((w) => w.fulfillmentProviderId) ??
null;
const provider = preferred?.fulfillmentProvider ?? null;
return {
city: { id: city.id.toString(), code: city.code, name: city.name },
warehouse: preferred
? {
id: preferred.id.toString(),
name: preferred.name,
fulfillmentMode: preferred.fulfillmentMode,
}
: null,
provider: provider
? { id: provider.id.toString(), code: provider.code, name: provider.name }
: null,
hintHtml: sanitizeDeliveryHintHtml(provider?.deliveryHintHtml ?? null),
};
});
}
private cityNameAliases(name: string): string[] {
const raw = name.trim();
if (!raw) return [];
const noSuffix = raw.replace(/市$/, '');
const withSuffix = raw.endsWith('市') ? raw : `${raw}`;
return Array.from(new Set([raw, noSuffix, withSuffix]));
}
}
@@ -3,9 +3,11 @@ import { IntegrationsModule } from '../../integrations/integrations.module';
import { TradeModule } from '../trade/trade.module';
import { FulfillmentProviderService } from './fulfillment-provider.service';
import { FulfillmentService } from './fulfillment.service';
import { LocalDeliveriesController } from './local-deliveries.controller';
@Module({
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
controllers: [LocalDeliveriesController],
providers: [FulfillmentProviderService, FulfillmentService],
exports: [FulfillmentProviderService, FulfillmentService],
})
@@ -0,0 +1,12 @@
import { Controller, Get, Query } from '@nestjs/common';
import { FulfillmentProviderService } from './fulfillment-provider.service';
@Controller('catalog')
export class LocalDeliveriesController {
constructor(private readonly providers: FulfillmentProviderService) {}
@Get('local-deliveries')
list(@Query('cityCode') cityCode?: string, @Query('cityName') cityName?: string) {
return this.providers.listLocalDeliveries({ cityCode, cityName });
}
}
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
@@ -47,6 +47,7 @@ export class AdminFulfillmentProvidersController {
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
deliveryHintHtml: dto.deliveryHintHtml,
});
}
@@ -76,6 +77,7 @@ export class AdminFulfillmentProvidersController {
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
deliveryHintHtml: dto.deliveryHintHtml,
});
}
@@ -89,4 +91,14 @@ export class AdminFulfillmentProvidersController {
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.LOGISTICS_PROVIDER_DELETE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -797,6 +797,11 @@ export class CreateFulfillmentProviderDto {
boxBottles?: number;
boxFee?: number;
} | null;
@IsOptional()
@IsString()
@MaxLength(2000)
deliveryHintHtml?: string | null;
}
export class UpdateFulfillmentProviderDto {
@@ -859,6 +864,11 @@ export class UpdateFulfillmentProviderDto {
boxBottles?: number;
boxFee?: number;
} | null;
@IsOptional()
@IsString()
@MaxLength(2000)
deliveryHintHtml?: string | null;
}
export class RechargeFulfillmentProviderDto {