feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址

同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 21:12:36 +08:00
parent 52a7d3789d
commit cc0c0a6ef8
44 changed files with 1029 additions and 218 deletions
@@ -10,10 +10,11 @@ import {
const CANVAS_ID = 'redeem-qr-canvas';
type RedeemQrCodeProps = {
token: string;
/** 二维码内容:门店 H5 落地 URL,缺省回退 token */
payload: string;
};
function drawOnWeappCanvas(token: string) {
function drawOnWeappCanvas(payload: string) {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
@@ -33,33 +34,33 @@ function drawOnWeappCanvas(token: string) {
canvas.width = layoutW * dpr;
canvas.height = layoutH * dpr;
ctx.scale(dpr, dpr);
drawRedeemQrOnCanvas(ctx, token, drawSize);
drawRedeemQrOnCanvas(ctx, payload, drawSize);
});
}
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
export default function RedeemQrCode({ payload }: RedeemQrCodeProps) {
const [imgSrc, setImgSrc] = useState('');
const isWeapp = process.env.TARO_ENV === 'weapp';
useEffect(() => {
if (!token) {
if (!payload) {
setImgSrc('');
return;
}
if (isWeapp) {
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
const timer = setTimeout(() => drawOnWeappCanvas(payload), 120);
return () => clearTimeout(timer);
}
let cancelled = false;
void buildRedeemQrDataUrl(token).then((url) => {
void buildRedeemQrDataUrl(payload).then((url) => {
if (!cancelled) setImgSrc(url);
});
return () => {
cancelled = true;
};
}, [token, isWeapp]);
}, [payload, isWeapp]);
return (
<View className="redeem-qr-box">
+8
View File
@@ -3,6 +3,7 @@ import {
BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL,
CUSTOMER_SERVICE_PHONE,
CUSTOMER_SERVICE_WECOM_URL,
QUALIFICATION_DISCLOSURE_URL,
type ClientRuntimeConfig,
} from '@dukang/shared-types';
@@ -14,6 +15,8 @@ export type BrandAssets = {
brandLogoMarkUrl: string;
qualificationDisclosureUrl: string;
customerServicePhone: string;
customerServiceWecomUrl: string;
wecomCorpId: string;
};
const FALLBACK: BrandAssets = {
@@ -22,6 +25,8 @@ const FALLBACK: BrandAssets = {
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
customerServicePhone: CUSTOMER_SERVICE_PHONE,
customerServiceWecomUrl: CUSTOMER_SERVICE_WECOM_URL,
wecomCorpId: '',
};
let cached: BrandAssets | null = null;
@@ -35,6 +40,9 @@ function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets
qualificationDisclosureUrl:
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
customerServiceWecomUrl:
config?.customerServiceWecomUrl?.trim() || FALLBACK.customerServiceWecomUrl,
wecomCorpId: config?.wecomCorpId?.trim() || FALLBACK.wecomCorpId,
};
}
+6 -6
View File
@@ -8,13 +8,13 @@ const QR_OPTIONS = {
color: { dark: '#1f1a17', light: '#ffffff' },
} as const;
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用payload 为落地 URL 或纯 token */
export function drawRedeemQrOnCanvas(
ctx: CanvasRenderingContext2D,
token: string,
payload: string,
sizePx = QR_SIZE,
) {
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' });
const count = qr.modules.size;
const cell = sizePx / count;
@@ -31,11 +31,11 @@ export function drawRedeemQrOnCanvas(
}
/** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
export async function buildRedeemQrDataUrl(payload: string): Promise<string> {
try {
return await QRCode.toDataURL(token, QR_OPTIONS);
return await QRCode.toDataURL(payload, QR_OPTIONS);
} catch {
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(payload)}`;
}
}
@@ -39,6 +39,10 @@ export default function RedeemCodePage() {
const router = useRouter();
const token = decodeURIComponent(router.params.token ?? '');
const amount = Number(router.params.amount ?? 0);
const landingUrl = router.params.landingUrl
? decodeURIComponent(router.params.landingUrl)
: '';
const qrPayload = landingUrl || token;
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -148,7 +152,7 @@ export default function RedeemCodePage() {
<View className="redeem-code-panel">
<Text className="redeem-code-head"></Text>
<View className="redeem-qr-wrap">
<RedeemQrCode token={token} />
<RedeemQrCode payload={qrPayload} />
</View>
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
+5 -2
View File
@@ -113,12 +113,15 @@ export default function RedeemPage() {
const body: { amount: number; couponId?: string } = { amount: value };
if (couponId) body.couponId = couponId;
const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
const data = await request<{ token: string; amount: number; landingUrl?: string }>('/redeem/tokens', {
method: 'POST',
data: body,
});
const landingQs = data.landingUrl
? `&landingUrl=${encodeURIComponent(data.landingUrl)}`
: '';
Taro.navigateTo({
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}${landingQs}`,
});
} catch (e) {
toast(e instanceof Error ? e.message : '生成失败');