Compare commits
9 Commits
v3.4.15
...
d98ef6b808
| Author | SHA1 | Date | |
|---|---|---|---|
| d98ef6b808 | |||
| d13a6ee3cd | |||
| 10fa361983 | |||
| 1a615ffe93 | |||
| 0269802e59 | |||
| e4e9eb2169 | |||
| b5fb9a0bb2 | |||
| 9d800a4cbd | |||
| 2a9493165b |
@@ -137,6 +137,8 @@ C 端门店仅 status=OPEN
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
```
|
||||
|
||||
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
|
||||
|
||||
## 环境与发版
|
||||
|
||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Image,
|
||||
Space,
|
||||
Spin,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
orderId: string | null;
|
||||
orderNo?: string | null;
|
||||
orderStatus?: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function providerLabel(provider?: string | null, company?: string | null) {
|
||||
if (company) return company;
|
||||
if (!provider) return '—';
|
||||
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
|
||||
if (isXfxProviderCode(provider)) return '小飞侠';
|
||||
return provider;
|
||||
}
|
||||
|
||||
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const ta = new Date(a.createTime).getTime();
|
||||
const tb = new Date(b.createTime).getTime();
|
||||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||
if (Number.isNaN(ta)) return 1;
|
||||
if (Number.isNaN(tb)) return -1;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
export default function OrderTrackDrawer({
|
||||
open,
|
||||
orderId,
|
||||
orderNo,
|
||||
orderStatus,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [track, setTrack] = useState<OrderTrackDto | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!orderId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
|
||||
setTrack(data);
|
||||
} catch (e) {
|
||||
setTrack(null);
|
||||
message.error(e instanceof Error ? e.message : '加载路由失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !orderId) {
|
||||
setTrack(null);
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}, [open, orderId, load]);
|
||||
|
||||
const nodes = useMemo(
|
||||
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
|
||||
[track?.nodes],
|
||||
);
|
||||
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||||
orderStatus || '',
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="物流路由"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
extra={
|
||||
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{(orderNo || track?.trackingNo || track?.provider) && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
|
||||
<Descriptions.Item label="配送方式">
|
||||
{providerLabel(track?.provider, track?.logisticsCompany)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
|
||||
{track?.manualQueryUrl ? (
|
||||
<Descriptions.Item label="物流查询">
|
||||
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
打开物流查询
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{track?.estimatedArrival?.arriveTime ? (
|
||||
<Descriptions.Item label="预计送达">
|
||||
{fmtTime(track.estimatedArrival.arriveTime)}
|
||||
{track.estimatedArrival.siteName
|
||||
? `(${track.estimatedArrival.siteName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
签收照片
|
||||
</Typography.Title>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap>
|
||||
{track.signPhotoUrls.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
物流动态
|
||||
</Typography.Title>
|
||||
{nodes.length > 0 ? (
|
||||
<Timeline
|
||||
items={nodes.map((node, index) => {
|
||||
const isLatest = index === nodes.length - 1;
|
||||
return {
|
||||
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
|
||||
children: (
|
||||
<div>
|
||||
{node.statusName ? (
|
||||
<Typography.Text strong style={{ display: 'block' }}>
|
||||
{node.statusName}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
|
||||
{node.trackInfo || '—'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{fmtTime(node.createTime)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
track?.manualQueryUrl
|
||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||
: '暂无路由信息,请稍后刷新'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,26 @@ import {
|
||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
||||
id: string;
|
||||
orderId: string;
|
||||
provider: string;
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
updatedAt: string;
|
||||
order?: {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
deliveryType: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function DeliveriesPage() {
|
||||
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
|
||||
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
|
||||
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
|
||||
|
||||
function openTrack(row: Row) {
|
||||
const orderId = row.orderId || row.order?.id;
|
||||
if (!orderId) {
|
||||
message.warning('缺少关联订单,无法查询路由');
|
||||
return;
|
||||
}
|
||||
setTrackOrderId(orderId);
|
||||
setTrackOrderNo(row.order?.orderNo ?? null);
|
||||
setTrackOrderStatus(row.order?.status ?? null);
|
||||
setTrackOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
|
||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||
</Form>
|
||||
<Button
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => openTrack(detail)}
|
||||
>
|
||||
查看路由
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<OrderTrackDrawer
|
||||
open={trackOpen}
|
||||
orderId={trackOrderId}
|
||||
orderNo={trackOrderNo}
|
||||
orderStatus={trackOrderStatus}
|
||||
onClose={() => setTrackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
@@ -186,6 +187,7 @@ export default function OrdersPage() {
|
||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [proxyOpen, setProxyOpen] = useState(false);
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||
|
||||
@@ -859,7 +861,20 @@ export default function OrdersPage() {
|
||||
</Descriptions>
|
||||
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions
|
||||
column={1}
|
||||
bordered
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<span>配送</span>
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
|
||||
查看路由
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Descriptions.Item label="快递公司">
|
||||
{detail.delivery.logisticsCompany ||
|
||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||
@@ -1232,6 +1247,14 @@ export default function OrdersPage() {
|
||||
void openDetail(order.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<OrderTrackDrawer
|
||||
open={trackOpen}
|
||||
orderId={detail?.id ?? null}
|
||||
orderNo={detail?.orderNo}
|
||||
orderStatus={detail?.status}
|
||||
onClose={() => setTrackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
|
||||
mode?: 'bind' | 'recover';
|
||||
onAuthorize: () => void;
|
||||
onRefresh?: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
|
||||
open,
|
||||
loading,
|
||||
error,
|
||||
mode = 'bind',
|
||||
onAuthorize,
|
||||
onRefresh,
|
||||
onCancel,
|
||||
}: WechatScanAuthModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const isRecover = mode === 'recover';
|
||||
|
||||
return (
|
||||
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
||||
<div className="shop-scan-auth-card">
|
||||
<div className="shop-scan-auth-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
|
||||
{isRecover ? '扫码能力未就绪' : '微信授权'}
|
||||
</h2>
|
||||
<p className="shop-scan-auth-desc">
|
||||
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||
{isRecover
|
||||
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
|
||||
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
|
||||
</p>
|
||||
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||
<div className="shop-scan-auth-actions">
|
||||
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
{isRecover && onRefresh ? (
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
|
||||
刷新页面
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||
{loading ? '跳转授权中…' : '微信授权'}
|
||||
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { isIosDevice } from '@dukang/weixin-sdk';
|
||||
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
||||
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
|
||||
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
|
||||
|
||||
export function markPendingScanAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
||||
@@ -27,6 +30,25 @@ export function clearPendingScanAfterAuth(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
|
||||
export function markScanWarmupAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeScanWarmupAfterAuth(): boolean {
|
||||
try {
|
||||
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
|
||||
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */
|
||||
export function getPostAuthScanDelayMs(): number {
|
||||
return isIosDevice() ? 1200 : 600;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { markScanWarmupAfterAuth } from './shop-scan-auth';
|
||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||
|
||||
export type ShopAccountProfile = {
|
||||
@@ -162,15 +163,19 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
markScanWarmupAfterAuth();
|
||||
const result = await weixinSdk.login();
|
||||
if (result) return handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
if (!isWxAuthorizeEnabled(config)) return 'skipped';
|
||||
if (!isWechatEnv()) return 'skipped';
|
||||
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
|
||||
if (session?.account?.hasWechat) return 'skipped';
|
||||
markScanWarmupAfterAuth();
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -180,5 +185,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
markScanWarmupAfterAuth();
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
|
||||
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
|
||||
clearPendingScanAfterAuth,
|
||||
|
||||
consumeScanWarmupAfterAuth,
|
||||
|
||||
getPostAuthScanDelayMs,
|
||||
|
||||
markPendingScanAfterAuth,
|
||||
@@ -56,9 +58,9 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
|
||||
|
||||
}
|
||||
|
||||
@@ -66,11 +68,11 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
if (opts?.afterAuth) {
|
||||
|
||||
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
|
||||
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
|
||||
|
||||
}
|
||||
|
||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
|
||||
|
||||
}
|
||||
|
||||
@@ -78,6 +80,12 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
}
|
||||
|
||||
function isScanRecoverableError(msg: string): boolean {
|
||||
|
||||
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -98,6 +106,8 @@ export default function HomePage() {
|
||||
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
|
||||
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
|
||||
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
const [authError, setAuthError] = useState('');
|
||||
@@ -190,7 +200,8 @@ export default function HomePage() {
|
||||
|
||||
try {
|
||||
|
||||
if (opts?.postAuthWarmup) {
|
||||
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL)
|
||||
if (opts?.postAuthWarmup || isIosDevice()) {
|
||||
|
||||
weixinSdk.reset();
|
||||
|
||||
@@ -226,7 +237,19 @@ export default function HomePage() {
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
|
||||
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
|
||||
|
||||
setScanMsg(tip);
|
||||
|
||||
if (isScanRecoverableError(tip)) {
|
||||
|
||||
setAuthModalMode('recover');
|
||||
|
||||
setAuthError(tip);
|
||||
|
||||
setAuthModalOpen(true);
|
||||
|
||||
}
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -302,13 +325,19 @@ export default function HomePage() {
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
setAuthModalMode('bind');
|
||||
|
||||
setAuthError('');
|
||||
|
||||
setAuthModalOpen(true);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
await runScan();
|
||||
const needWarmup = consumeScanWarmupAfterAuth();
|
||||
|
||||
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
@@ -556,16 +585,26 @@ export default function HomePage() {
|
||||
|
||||
open={authModalOpen}
|
||||
|
||||
mode={authModalMode}
|
||||
|
||||
loading={authLoading}
|
||||
|
||||
error={authError}
|
||||
|
||||
onAuthorize={() => void startWechatAuth()}
|
||||
|
||||
onRefresh={() => {
|
||||
|
||||
window.location.reload();
|
||||
|
||||
}}
|
||||
|
||||
onCancel={() => {
|
||||
|
||||
setAuthModalOpen(false);
|
||||
|
||||
setAuthModalMode('bind');
|
||||
|
||||
setAuthError('');
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
@@ -159,9 +159,9 @@ export default function LoginPage() {
|
||||
});
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
await bindShopWechatAfterSmsLogin(data);
|
||||
return;
|
||||
}
|
||||
routeAfterShopLogin(data, navigate);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -10,6 +11,14 @@ import {
|
||||
type ShopStoreOption,
|
||||
} from '../lib/api';
|
||||
|
||||
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat('/');
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
export default function SelectStorePage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, store, authenticated } = useStoreSession();
|
||||
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
if (storeId === currentStoreId) {
|
||||
navigate('/', { replace: true });
|
||||
goShopHome(navigate);
|
||||
return;
|
||||
}
|
||||
setLoadingId(storeId);
|
||||
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
navigate('/', { replace: true });
|
||||
goShopHome(navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} finally {
|
||||
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
if (needsStoreSelection(session)) {
|
||||
navigate('/select-store', { replace: true });
|
||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat(path);
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
navigate(path, { replace: true });
|
||||
}
|
||||
|
||||
@@ -794,6 +794,7 @@
|
||||
|
||||
.shop-scan-auth-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "3.4.14",
|
||||
"version": "3.4.15",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||
export const APP_VERSION = '3.4.14';
|
||||
export const APP_VERSION = '3.4.15';
|
||||
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||
|
||||
type Store = {
|
||||
id: string;
|
||||
@@ -341,12 +342,12 @@ export default function StoresPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function hoursLines(store: Store): string[] {
|
||||
function hoursText(store: Store): string {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
if (!parts.length) parts.push('10:00-22:00');
|
||||
return parts.map((p, i) => (i === 0 ? `营业时间: ${p}` : p));
|
||||
return `营业时间: ${parts.join(' ')}`;
|
||||
}
|
||||
|
||||
const sharePayload = useMemo(
|
||||
@@ -421,45 +422,38 @@ export default function StoresPage() {
|
||||
className="store-card"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
||||
>
|
||||
{s.coverUrl ? (
|
||||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="store-card-cover store-card-cover--empty" />
|
||||
)}
|
||||
<View className="store-card-cover-wrap">
|
||||
{s.coverUrl ? (
|
||||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="store-card-cover store-card-cover--empty" />
|
||||
)}
|
||||
<Image
|
||||
className="store-card-open-badge"
|
||||
src={openBadgeImg}
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</View>
|
||||
<View className="store-card-body">
|
||||
{/* 第1行:标题 + 距离 */}
|
||||
{/* 第1行:标题(截断无省略号,顶到最右) */}
|
||||
<View className="store-card-row store-card-row--head">
|
||||
<Text className="store-card-name" numberOfLines={1}>
|
||||
{s.name}
|
||||
<Text className="store-card-name">{s.name}</Text>
|
||||
</View>
|
||||
{/* 第2行:地址(最多两行)+ 距离 */}
|
||||
<View className="store-card-row store-card-row--mid">
|
||||
<Text className="store-card-address" numberOfLines={2}>
|
||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||||
</Text>
|
||||
<Text className="store-card-distance">
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 第2行:营业时间(多段各占一行,居左) */}
|
||||
<View className="store-card-hours">
|
||||
{hoursLines(s).map((line) => (
|
||||
<Text key={line} className="store-card-hours-line">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
{/* 第3行:地址(最多两行)+ 去核销 */}
|
||||
<View className="store-card-row store-card-row--foot">
|
||||
<Text className="store-card-address" numberOfLines={2}>
|
||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||||
</Text>
|
||||
<View
|
||||
className="store-card-cta"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/redeem/index' });
|
||||
}}
|
||||
>
|
||||
<Text className="store-card-cta-text">去核销</Text>
|
||||
</View>
|
||||
{/* 第3行:营业时间(同行) */}
|
||||
<View className="store-card-row store-card-row--hours">
|
||||
<Text className="store-card-hours">{hoursText(s)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="store-card-arrow">›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -144,12 +144,12 @@
|
||||
padding: 4px var(--space-page) 16px;
|
||||
}
|
||||
|
||||
/* 左图右文 */
|
||||
/* 左图 + 中间文案 + 右侧箭头 */
|
||||
.store-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-card);
|
||||
@@ -158,11 +158,19 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.store-card-cover {
|
||||
.store-card-cover-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-card-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-container);
|
||||
display: block;
|
||||
}
|
||||
@@ -171,6 +179,16 @@
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.store-card-open-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.store-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -184,19 +202,19 @@
|
||||
|
||||
.store-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 第1行:加粗标题(单行截断)+ 右对齐距离 */
|
||||
/* 第1行:加粗标题(单行截断,不显示 …,宽度顶到最右) */
|
||||
.store-card-row--head {
|
||||
gap: 8px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-card-name {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 15px;
|
||||
@@ -204,53 +222,23 @@
|
||||
line-height: 22px;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-card-distance {
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 第2行:营业时间独自居左;多段各占一行 */
|
||||
.store-card-hours {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-card-hours-line {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 第3行:地址最多两行截断 + 右对齐去核销;与按钮垂直居中 */
|
||||
.store-card-row--foot {
|
||||
/* 第2行:地址最多两行 + 右对齐距离 */
|
||||
.store-card-row--mid {
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.store-card-address {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
max-height: 28px;
|
||||
line-height: 16px;
|
||||
max-height: 32px;
|
||||
color: #999;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
@@ -260,25 +248,44 @@
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.store-card-cta {
|
||||
.store-card-distance {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-heritage-red, #a61d24);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-card-cta-text {
|
||||
max-width: 40%;
|
||||
padding-top: 1px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 第3行:营业时间同行 */
|
||||
.store-card-row--hours {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-card-hours {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-card-arrow {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# @dukang/weixin-sdk · 踩坑
|
||||
|
||||
## iOS 微信 H5:JSSDK 入场 URL(扫码 / 定位 / 选图)
|
||||
|
||||
### 症状
|
||||
|
||||
- 登录或 OAuth 后立刻调 `scanQRCode` / `getLocation` / `chooseImage` 失败
|
||||
- 错误类似:`permission value is offline verifying`、`invalid signature`
|
||||
- 业务文案常被写成「权限校验尚未完成,请等 1~2 秒」——**多数情况下等无效**
|
||||
- 关掉 webview 再进(整页重载)立即恢复
|
||||
|
||||
### 原因
|
||||
|
||||
iOS 微信对 JS-SDK 验签使用的是**本次 document 加载时的 URL**(去掉 `#` 后的完整 URL,**含 query**)。
|
||||
`history.pushState` / `replaceState`(含 React Router)**不会**更新微信内部用于验签的 URL。
|
||||
|
||||
典型错误链路:
|
||||
|
||||
1. OAuth 回跳:`/login?code=xxx&state=yyy`(入场 URL)
|
||||
2. SPA:`navigate('/')`
|
||||
3. 用当前页 `/` 或「去掉 code 后的 `/login`」去签名 → 与微信内部 URL 不一致 → 失败
|
||||
|
||||
### 正确做法
|
||||
|
||||
1. **业务跳转(登录成功 / 选店进首页)**:iOS 微信内用 `hardNavigateInWechat(path)` / `location.replace`,让目标页成为新的入场 URL。
|
||||
2. **签名 URL**:`getJssdkSignUrl()` 在 iOS 上返回入场 URL;OAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
|
||||
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**。
|
||||
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
|
||||
|
||||
### 相关 API
|
||||
|
||||
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
|
||||
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
|
||||
- `stripOAuthParamsFromLocation`
|
||||
@@ -5,10 +5,13 @@ export {
|
||||
ensureJssdkReady,
|
||||
isJssdkReady,
|
||||
normalizeJssdkPageUrl,
|
||||
jssdkUrlWithoutHash,
|
||||
getJssdkSignUrl,
|
||||
captureIosJssdkEntryUrl,
|
||||
resetJssdkConfig,
|
||||
stripOAuthParamsFromLocation,
|
||||
hardNavigateInWechat,
|
||||
shouldHardNavigateForJssdk,
|
||||
} from './jssdk';
|
||||
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
|
||||
export {
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
||||
import { isWechatBrowser, isWechatDevTools } from './env';
|
||||
import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
|
||||
import { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
|
||||
/** 旧版错误地把 SPA 当前 URL 写入 session;清理以免干扰排查 */
|
||||
const LEGACY_SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let configured = false;
|
||||
let configuredUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* iOS 微信 WebView:JSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query),
|
||||
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
|
||||
* OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除。
|
||||
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
|
||||
*/
|
||||
let iosEntryUrl: string | null = null;
|
||||
|
||||
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
|
||||
export function resetJssdkConfig(): void {
|
||||
configured = false;
|
||||
configuredUrl = null;
|
||||
}
|
||||
|
||||
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
|
||||
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||
/** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
|
||||
export function jssdkUrlWithoutHash(rawUrl: string): string {
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化签名 URL。
|
||||
* - 默认:去 hash;可保留 code/state(由 keepOAuthQuery 控制)
|
||||
* - Android / 当前页签名:通常已 stripOAuth 后再签,keepOAuthQuery=false
|
||||
*/
|
||||
export function normalizeJssdkPageUrl(rawUrl: string, opts?: { keepOAuthQuery?: boolean }): string {
|
||||
const keepOAuth = !!opts?.keepOAuthQuery;
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
url.hash = '';
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
if (!keepOAuth) {
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
}
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
const noHash = rawUrl.split('#')[0];
|
||||
const noHash = jssdkUrlWithoutHash(rawUrl);
|
||||
if (keepOAuth) return noHash;
|
||||
try {
|
||||
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
|
||||
url.searchParams.delete('code');
|
||||
@@ -42,25 +64,48 @@ function signUrlChanged(prev: string | null, current: string): boolean {
|
||||
return prev !== current;
|
||||
}
|
||||
|
||||
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isWechatBrowser() || isWechatDevTools()) return;
|
||||
const current = normalizeJssdkPageUrl(window.location.href);
|
||||
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
|
||||
if (!existing) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
return;
|
||||
}
|
||||
if (signUrlChanged(existing, current)) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
resetJssdkConfig();
|
||||
function clearLegacySignUrlCache(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */
|
||||
/**
|
||||
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
|
||||
* Android / 非微信环境为 no-op。
|
||||
*/
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
|
||||
clearLegacySignUrlCache();
|
||||
if (iosEntryUrl) return;
|
||||
iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query) */
|
||||
export function getJssdkSignUrl(rawUrl?: string): string {
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
|
||||
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
|
||||
captureIosJssdkEntryUrl();
|
||||
if (iosEntryUrl) return iosEntryUrl;
|
||||
}
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''), {
|
||||
keepOAuthQuery: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 微信内业务跳转须用整页导航,使下一页成为新的 JSSDK 入场 URL。
|
||||
* SPA navigate 会导致扫码/定位等 JSAPI 验签失败。
|
||||
*/
|
||||
export function hardNavigateInWechat(path: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.location.replace(path);
|
||||
}
|
||||
|
||||
export function shouldHardNavigateForJssdk(): boolean {
|
||||
return typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools();
|
||||
}
|
||||
|
||||
function isJssdkDebugEnabled(): boolean {
|
||||
@@ -76,6 +121,8 @@ export function stripOAuthParamsFromLocation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
|
||||
// iOS:须先锁定入场 URL(含 code/state),再 replaceState
|
||||
captureIosJssdkEntryUrl();
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
@@ -126,7 +173,7 @@ export async function initWechatJssdk(options: {
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const { apiBase, clientApp, getAccessToken } = options;
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
const pageUrl = getJssdkSignUrl(options.url);
|
||||
await loadScript();
|
||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||
|
||||
@@ -164,7 +211,7 @@ export async function ensureJssdkReady(options: {
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
const pageUrl = getJssdkSignUrl(options.url);
|
||||
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
|
||||
resetJssdkConfig();
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '3.4.14',
|
||||
placeholder: '3.4.15',
|
||||
description: 'semver 格式;客户端低于此版本时提示更新',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -83,17 +83,12 @@ export class WechatController {
|
||||
return this.wechat.createJssdkConfig(pageUrl, actorRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅去 hash。勿剔除 code/state:
|
||||
* iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。
|
||||
*/
|
||||
private normalizeJssdkUrl(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.delete('code');
|
||||
parsed.searchParams.delete('state');
|
||||
const query = parsed.searchParams.toString();
|
||||
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
|
||||
@Get('oauth-url')
|
||||
|
||||
@@ -38,6 +38,12 @@ export class AdminOrdersController {
|
||||
return this.ordersService.getShipDefaults();
|
||||
}
|
||||
|
||||
/** 物流路由(小飞侠实时轨迹,对齐 C 端 GET /trade/orders/:id/track) */
|
||||
@Get(':id/track')
|
||||
track(@Param('id') id: string) {
|
||||
return this.ordersService.getOrderTrack(BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.ordersService.detail(BigInt(id));
|
||||
|
||||
@@ -63,6 +63,15 @@ export class AdminOrdersService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getOrderTrack(id: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return serializeBigInt(await this.fulfillmentService.getOrderTrack(id));
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
||||
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;**微信小程序配置可配 Logo·客服·H5·Mock码** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
|
||||
| 3.4.15 | 08-06 | mini-user 门店列表卡片:去核销改箭头、地址/距离/营业时间重排、营业中角标 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,11 +56,13 @@
|
||||
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
|
||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
||||
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
|
||||
| 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
|
||||
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
|
||||
| 2026-08-05 | v3.4.13 |
|
||||
| 2026-08-04 | v3.4.11 / v3.4.12 |
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 杜康好客 · v3.4.15 mini-user 门店列表卡片优化
|
||||
|
||||
> **2026-08-06** · **开发中** · mini-user `3.4.15` · **未发版**
|
||||
|
||||
## 更新内容
|
||||
|
||||
- 门店列表卡片去掉「去核销」按钮,整卡最右侧改为小箭头(进入详情)
|
||||
- 第 1 行:门店名单行截断(不显示省略号),宽度顶到文案区最右
|
||||
- 第 2 行:地址最多两行;右侧显示距离
|
||||
- 第 3 行:营业时间同行展示(多段时段空格拼接)
|
||||
- 店铺封面右上角叠加斜角「营业中」标签图
|
||||
|
||||
## 范围
|
||||
|
||||
| 项 | 交付 |
|
||||
|----|------|
|
||||
| 门店列表卡片 | 布局与交互如上 |
|
||||
| 版本号 | `mini-user` `3.4.15`(`APP_VERSION` / package.json) |
|
||||
|
||||
## ACC
|
||||
|
||||
- [ ] 列表项无「去核销」按钮;右侧有 › 箭头;点击整卡进详情
|
||||
- [ ] 长店名单行截断且无 `…`;标题行无距离
|
||||
- [ ] 地址 ≤2 行,距离在第二行右侧
|
||||
- [ ] 营业时间单行;双时段同行显示
|
||||
- [ ] 封面右上角可见「营业中」斜角标签
|
||||
+22
-1
@@ -34,7 +34,28 @@
|
||||
- 核销记录;今日汇总;到账金额×60%展示
|
||||
- 营业状态开关;Mine 门店信息
|
||||
- 套餐:列表编辑→提交 HQ 审核(v3.4.10)
|
||||
- iOS 微信:OAuth 后自动续扫(v3.4.13)
|
||||
- iOS 微信:OAuth 后自动续扫(v3.4.13);登录后须整页跳转(见下「踩坑」)
|
||||
|
||||
### 踩坑 · iOS 微信 H5 扫码(必读,勿再回归)
|
||||
|
||||
**现象**:手机号重新登录后点「扫码核销」提示「微信权限校验尚未完成…」;关掉 H5 再进就正常。
|
||||
|
||||
**根因(不是系统相机权限)**:
|
||||
|
||||
1. iOS 微信 WebView 对 JSSDK 验签用的是**本次 document 加载的入场 URL**(含 query),不是 SPA `pushState` 之后的 `location.href`。
|
||||
2. 登录 / OAuth 回跳常落在 `/login?code=…`,再 `navigate('/')` 进首页 → 签名 URL 与微信内部入场 URL 不一致 → `permission value is offline verifying` / invalid signature。
|
||||
3. 文案「等 1~2 秒再点」只覆盖「权限离线校验偏慢」的一小部分场景;**签名错了等多久都不行**,必须整页刷新或重新授权。
|
||||
|
||||
**硬规则(编码)**:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| iOS 登录/选店后 | 用 `location.replace(path)`(`hardNavigateInWechat`),禁止仅 React Router navigate |
|
||||
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL,**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
|
||||
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
|
||||
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
|
||||
|
||||
实现:`packages/weixin-sdk/src/jssdk.ts` · `apps/h5-shop` 登录/选店/HomePage。
|
||||
|
||||
## 4. 合伙人端(h5-partner)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user