feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import Taro from '@tarojs/taro';
import { createUserTracker, getSessionId } from '@dukang/client-logging';
import { API_BASE, CLIENT_APP, getToken } from './api';
function currentPagePath(): string | undefined {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined;
return cur?.$taroPath || cur?.route || undefined;
} catch {
return undefined;
}
}
const tracker = createUserTracker({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getToken,
getPagePath: currentPagePath,
postJson: ({ url, headers, body }) => {
void Taro.request({ url, method: 'POST', header: headers, data: body }).catch(() => {});
},
});
export { getSessionId };
export function track(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
export function initUserAnalytics() {
tracker.trackSessionStart();
}
+139
View File
@@ -0,0 +1,139 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session';
function resolveApiBase(): string {
const origin =
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5'
? 'http://localhost:3000'
: '';
if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`;
}
return '/api/v1';
}
export const API_BASE = resolveApiBase();
const TOKEN_KEY = 'user_access_token';
const REFRESH_KEY = 'user_refresh_token';
/** H5 产物走公众号体系(USER_H5);小程序原生走 USER_MINI。勿混用,否则 JSAPI 会出现 appid 与 openid 不匹配 */
export const CLIENT_APP =
process.env.TARO_ENV === 'h5' ? ClientApp.USER_H5 : ClientApp.USER_MINI;
export function getToken(): string {
try {
return Taro.getStorageSync(TOKEN_KEY) || '';
} catch {
return '';
}
}
export function getRefreshToken(): string {
try {
return Taro.getStorageSync(REFRESH_KEY) || '';
} catch {
return '';
}
}
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
Taro.setStorageSync(TOKEN_KEY, data.accessToken);
if (data.refreshToken) {
Taro.setStorageSync(REFRESH_KEY, data.refreshToken);
}
}
export function clearAuth() {
Taro.removeStorageSync(TOKEN_KEY);
Taro.removeStorageSync(REFRESH_KEY);
}
export function isLoggedIn(): boolean {
return !!getToken();
}
export function logout() {
clearAuth();
// 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选
resetStoresSessionBootstrap();
resetHomeCatalogBootstrap();
Taro.reLaunch({ url: '/pages/home/index' });
}
type ReqOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
data?: Record<string, unknown> | unknown;
auth?: boolean;
};
function parseBody(data: unknown): { code?: number; message?: string } {
if (data && typeof data === 'object') {
return data as { code?: number; message?: string };
}
return {};
}
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) header.Authorization = `Bearer ${token}`;
const res = await Taro.request({
url: `${API_BASE}${path}`,
method: options.method ?? 'GET',
data: options.data as Record<string, unknown>,
header,
});
const status = res.statusCode;
const body = parseBody(res.data);
if (status === 401 || body?.code === 401) {
// 仅清掉「发起本请求时」仍在使用的 token,避免登录页旧 /auth/me 竞态清掉刚写入的新 token
const stillCurrent = !!token && getToken() === token;
if (stillCurrent) {
clearAuth();
const mergedMsg = body?.message || '';
if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge();
}
}
throw new Error(body?.message || '登录已过期,请重新登录');
}
if (status === 404 && body.code === undefined) {
throw new Error('接口不可达,请确认 API 服务已启动');
}
if (status >= 400 || body.code !== 0) {
throw new Error(body?.message || `请求失败(${status})`);
}
return (res.data as { data: T }).data;
}
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
Taro.showToast({ title, icon, duration: 1800 });
}
export type SessionPayload = {
accessToken: string;
refreshToken?: string;
phoneVerified?: boolean;
accountMerged?: boolean;
};
export type UserProfile = {
id: string;
phone?: string | null;
nickname?: string | null;
avatarUrl?: string | null;
phoneVerified?: boolean;
hasWechat?: boolean;
};
+130
View File
@@ -0,0 +1,130 @@
import Taro from '@tarojs/taro';
const TAB_PAGES = new Set([
'/pages/home/index',
'/pages/stores/index',
'/pages/benefit/index',
'/pages/mine/index',
]);
let loginNavigationPending = false;
function isLoginPageActive(): boolean {
const pages = Taro.getCurrentPages();
const current = pages[pages.length - 1] as { route?: string } | undefined;
return !!current?.route?.includes('pages/login/');
}
function currentPagePath(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
| { route?: string; options?: Record<string, string | undefined> }
| undefined;
if (!cur?.route) return '';
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
if (path.includes('/pages/login/')) return '';
const opts = cur.options ?? {};
const qs = Object.entries(opts)
.filter(([, v]) => v != null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return qs ? `${path}?${qs}` : path;
}
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
if (loginNavigationPending || isLoginPageActive()) return;
const returnTo = returnPath ?? currentPagePath();
const parts: string[] = [];
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
if (extras) {
for (const [key, value] of Object.entries(extras)) {
if (value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
loginNavigationPending = true;
void Taro.navigateTo({ url })
.catch(() => Taro.redirectTo({ url }))
.finally(() => {
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
setTimeout(() => {
loginNavigationPending = false;
}, 500);
});
}
/** 登录成功后回到 return 页,或回退 / 首页 */
export function finishLoginNavigate(returnTo?: string) {
const raw = (returnTo || '').trim();
let target = '';
try {
target = raw ? decodeURIComponent(raw) : '';
} catch {
target = raw;
}
// 防止 return 仍指向登录页造成死循环
const pathOnly = target.split('?')[0];
if (!pathOnly || pathOnly.includes('/pages/login')) {
Taro.reLaunch({ url: '/pages/home/index' });
return;
}
if (TAB_PAGES.has(pathOnly)) {
Taro.switchTab({ url: pathOnly }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
if (pathOnly.startsWith('/pages/')) {
Taro.redirectTo({ url: target }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
Taro.reLaunch({ url: '/pages/home/index' });
}
/**
* 账号合并后强制整页刷新,避免旧会话栈 / 旧用户缓存继续提示绑定手机号。
* H5:按当前路径推算出落地 URL 后 location.replace;小程序:reLaunch。
*/
export function forceReloadAfterAccountMerge(returnTo?: string) {
const raw = (returnTo || '').trim();
let target = '';
try {
target = raw ? decodeURIComponent(raw) : '';
} catch {
target = raw;
}
const pathOnly = target.split('?')[0];
const safePath =
pathOnly && pathOnly.startsWith('/pages/') && !pathOnly.includes('/pages/login')
? target
: '/pages/home/index';
const launchPath = safePath.split('?')[0];
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const { origin, pathname, search } = window.location;
const marker = '/pages/';
const idx = pathname.indexOf(marker);
let href: string;
if (idx >= 0) {
href = `${origin}${pathname.slice(0, idx)}${safePath}`;
} else if (window.location.hash.includes('/pages/')) {
href = `${origin}${pathname}${search}#${safePath}`;
} else {
const base = pathname.replace(/\/$/, '') || '';
href = `${origin}${base}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
}
window.location.replace(href);
return;
}
if (TAB_PAGES.has(launchPath)) {
Taro.reLaunch({ url: launchPath });
return;
}
Taro.reLaunch({ url: safePath.startsWith('/') ? safePath : `/${safePath}` });
}
+59
View File
@@ -0,0 +1,59 @@
export type CheckoutContext = {
productId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
select?: boolean;
};
export function buildQuery(ctx: CheckoutContext): string {
const parts: string[] = [];
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
if (ctx.cross) parts.push('cross=1');
if (ctx.select) parts.push('select=1');
return parts.join('&');
}
export function buildOrderConfirmUrl(ctx: CheckoutContext): string {
const qs = buildQuery(ctx);
return qs ? `/pages/order-confirm/index?${qs}` : '/pages/order-confirm/index';
}
export function buildAddressListUrl(ctx: CheckoutContext): string {
const qs = buildQuery({ ...ctx, select: true });
return qs ? `/pages/addresses/index?${qs}` : '/pages/addresses/index';
}
export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext): string {
const base = id ? `/pages/address-edit/index?id=${encodeURIComponent(id)}` : '/pages/address-edit/index';
const extra = buildQuery(ctx);
if (!extra) return base;
return `${base}${base.includes('?') ? '&' : '?'}${extra}`;
}
export function buildPayUrl(params: {
orderId: string;
productId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
}): string {
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
if (params.cross) parts.push('cross=1');
return `/pages/pay/index?${parts.join('&')}`;
}
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
return {
productId: params.productId,
qty: params.qty,
addressId: params.addressId,
cross: params.cross === '1',
select: params.select === '1',
};
}
+125
View File
@@ -0,0 +1,125 @@
import Taro from '@tarojs/taro';
import { CLIENT_APP, getToken, API_BASE } from './api';
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
export type ClientErrorCategory =
| 'js_error'
| 'unhandled_rejection'
| 'api_error'
| 'network'
| 'render'
| 'bridge'
| 'other';
export type ClientErrorPayload = {
level: ClientErrorLevel;
category: ClientErrorCategory;
message: string;
stack?: string;
pagePath?: string;
extra?: Record<string, unknown>;
};
function currentPagePath(): string | undefined {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined;
return cur?.$taroPath || cur?.route || undefined;
} catch {
return undefined;
}
}
/** 上报客户端错误(失败静默,避免递归) */
export function reportClientError(payload: ClientErrorPayload): void {
const body = {
level: payload.level,
category: payload.category,
message: String(payload.message || 'unknown').slice(0, 1000),
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
pagePath: payload.pagePath || currentPagePath(),
clientApp: CLIENT_APP,
extra: payload.extra,
};
const header: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) header.Authorization = `Bearer ${token}`;
void Taro.request({
url: `${API_BASE}/common/client-errors`,
method: 'POST',
data: body,
header,
}).catch(() => {});
}
let installed = false;
/** 安装小程序/H5 全局未捕获错误钩子(幂等) */
export function installClientErrorReporting(): void {
if (installed) return;
installed = true;
try {
Taro.onError?.((msg) => {
reportClientError({
level: 'fatal',
category: 'js_error',
message: typeof msg === 'string' ? msg : String(msg),
});
});
} catch {
/* ignore */
}
try {
Taro.onUnhandledRejection?.((res) => {
const reason = (res as { reason?: unknown })?.reason;
const message =
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: JSON.stringify(reason);
const stack = reason instanceof Error ? reason.stack : undefined;
reportClientError({
level: 'error',
category: 'unhandled_rejection',
message: message || 'UnhandledRejection',
stack,
});
});
} catch {
/* ignore */
}
if (typeof window !== 'undefined') {
window.addEventListener('error', (ev) => {
reportClientError({
level: 'fatal',
category: 'js_error',
message: ev.message || 'window.error',
stack: ev.error instanceof Error ? ev.error.stack : undefined,
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
});
});
window.addEventListener('unhandledrejection', (ev) => {
const reason = ev.reason;
reportClientError({
level: 'error',
category: 'unhandled_rejection',
message:
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: 'unhandledrejection',
stack: reason instanceof Error ? reason.stack : undefined,
});
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import { weixinSdk } from './weixin';
export type ClientGpsLocation = {
province?: string;
city?: string;
district?: string;
latitude: number;
longitude: number;
address?: string;
};
/** 尝试获取客户端 GPS(微信 JSSDK / 浏览器 Geolocation),失败返回 null 不阻塞下单 */
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
if (process.env.TARO_ENV === 'weapp') {
try {
const Taro = (await import('@tarojs/taro')).default;
const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
return { latitude: loc.latitude, longitude: loc.longitude };
} catch {
return null;
}
}
const loc = await weixinSdk.getLocation();
if (!loc) return null;
return {
latitude: loc.latitude,
longitude: loc.longitude,
};
}
+16
View File
@@ -0,0 +1,16 @@
/** 格式化为 Asia/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
export function formatShanghaiDateTime(input?: string | Date | null): string {
if (input == null || input === '') return '—';
if (typeof input === 'string') {
const s = input.trim();
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(s)) {
return s.slice(0, 19).replace('T', ' ');
}
}
const d = input instanceof Date ? input : new Date(input);
if (Number.isNaN(d.getTime())) return '—';
// 用 UTC 读数 + 固定东八区偏移,避免依赖 Intl / 设备时区 API 差异
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return `${sh.getUTCFullYear()}-${p(sh.getUTCMonth() + 1)}-${p(sh.getUTCDate())} ${p(sh.getUTCHours())}:${p(sh.getUTCMinutes())}:${p(sh.getUTCSeconds())}`;
}
+23
View File
@@ -0,0 +1,23 @@
/** 球面距离(米) */
export function haversineMeters(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
export function formatDistanceMeters(meters: number | null | undefined): string {
if (meters == null || !Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return `${Math.max(1, Math.round(meters))}m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)}km`;
}
@@ -0,0 +1,75 @@
/**
* 首页商品列表「当次登录」会话 —— 切 tab 不重复拉商品;
* 城市 / 登录态变化或下拉刷新时再请求。logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type HomeCatalogCache = {
cityCode: string;
authKey: string;
products: unknown[];
};
type HomeSession = {
bootstrapped: boolean;
cache: HomeCatalogCache | null;
};
const STORAGE_KEY = 'dukang_home_catalog_session_v1';
let memory: HomeSession | null = null;
function emptySession(): HomeSession {
return { bootstrapped: false, cache: null };
}
function readSession(): HomeSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<HomeSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as HomeCatalogCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: HomeSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isHomeCatalogBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function getHomeCatalogCache(): HomeCatalogCache | null {
return readSession().cache;
}
export function setHomeCatalogCache(cache: HomeCatalogCache): void {
writeSession({ bootstrapped: true, cache });
}
export function resetHomeCatalogBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* 微信小程序基础库无 Intl。业务代码已避免依赖,此处仅作兜底,
* 防止旧包 / 依赖偶发 `new Intl.DateTimeFormat` 直接白屏。
*/
function pad(n: number) {
return String(n).padStart(2, '0');
}
function shanghaiParts(date: Date) {
const sh = new Date(date.getTime() + 8 * 60 * 60 * 1000);
return {
year: String(sh.getUTCFullYear()),
month: pad(sh.getUTCMonth() + 1),
day: pad(sh.getUTCDate()),
hour: pad(sh.getUTCHours()),
minute: pad(sh.getUTCMinutes()),
second: pad(sh.getUTCSeconds()),
};
}
function installIntlStub() {
const root = (typeof globalThis !== 'undefined'
? globalThis
: typeof global !== 'undefined'
? global
: typeof wx !== 'undefined'
? wx
: {}) as typeof globalThis & { Intl?: typeof Intl };
if (typeof root.Intl !== 'undefined' && typeof root.Intl.DateTimeFormat === 'function') {
return;
}
class MiniDateTimeFormat {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(_locales?: string | string[], _options?: Record<string, unknown>) {}
format(date?: Date | number) {
const d = date instanceof Date ? date : new Date(date ?? Date.now());
if (Number.isNaN(d.getTime())) return '';
const p = shanghaiParts(d);
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
}
formatToParts(date?: Date | number) {
const d = date instanceof Date ? date : new Date(date ?? Date.now());
if (Number.isNaN(d.getTime())) return [];
const p = shanghaiParts(d);
return [
{ type: 'year', value: p.year },
{ type: 'literal', value: '-' },
{ type: 'month', value: p.month },
{ type: 'literal', value: '-' },
{ type: 'day', value: p.day },
{ type: 'literal', value: ' ' },
{ type: 'hour', value: p.hour },
{ type: 'literal', value: ':' },
{ type: 'minute', value: p.minute },
{ type: 'literal', value: ':' },
{ type: 'second', value: p.second },
];
}
}
root.Intl = {
DateTimeFormat: MiniDateTimeFormat,
} as unknown as typeof Intl;
}
installIntlStub();
export {};
@@ -0,0 +1,157 @@
import Taro from '@tarojs/taro';
import type { UserProfile } from './api';
export type MiniWechatProfile = {
nickname?: string;
avatarUrl?: string;
};
export type MiniWechatProfileUpdate = MiniWechatProfile & {
avatarResourceId?: string;
};
export type UploadedAvatarResource = {
resourceId: string;
url: string;
bucket: string;
ossKey: string;
};
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
export function cacheWxProfile(info: MiniWechatProfile) {
if (!info.nickname && !info.avatarUrl) return;
try {
Taro.setStorageSync(WX_PROFILE_CACHE_KEY, JSON.stringify(info));
} catch {
/* ignore */
}
}
export function getCachedWxProfile(): MiniWechatProfile | null {
try {
const raw = Taro.getStorageSync(WX_PROFILE_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as MiniWechatProfile;
if (!parsed?.nickname && !parsed?.avatarUrl) return null;
return parsed;
} catch {
return null;
}
}
export function isDefaultMiniNickname(nickname?: string | null): boolean {
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
return /^用户\d{4}$/.test(nickname);
}
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
if (!profile) return true;
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
}
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
const cached = getCachedWxProfile();
if (!cached && !profile.hasWechat) return profile;
const nickname =
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
cached?.nickname ||
profile.nickname ||
'微信用户';
return {
...profile,
nickname,
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
};
}
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const token = getToken();
if (!token) throw new Error('请先登录');
const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`,
filePath: tempFilePath,
name: 'file',
formData: {
bizType: 'AVATAR',
mediaType: 'IMAGE',
},
header: {
Authorization: `Bearer ${token}`,
'X-Client-App': CLIENT_APP,
},
});
let body: {
code?: number;
message?: string;
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
} = {};
try {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
throw new Error('头像上传响应异常');
}
if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录');
}
if (
res.statusCode >= 400 ||
body.code !== 0 ||
!body.data?.resourceId ||
!body.data.url ||
!body.data.bucket ||
!body.data.ossKey
) {
throw new Error(body.message || '头像上传失败');
}
return {
resourceId: body.data.resourceId,
url: body.data.url,
bucket: body.data.bucket,
ossKey: body.data.ossKey,
};
}
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
const { request } = await import('./api');
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
method: 'POST',
data: info,
});
cacheWxProfile({
nickname: updated?.nickname ?? info.nickname,
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
});
return updated;
}
/**
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
*/
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
const cached = getCachedWxProfile();
if (cached?.nickname || cached?.avatarUrl) {
return cached;
}
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
}
/** 绑定后上报微信资料(优先使用已拉取的信息) */
export async function syncMiniWechatProfile(
prefetched?: MiniWechatProfile | null,
): Promise<MiniWechatProfile | null> {
if (process.env.TARO_ENV !== 'weapp') return null;
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
if (!info?.nickname && !info?.avatarUrl) return null;
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
return info;
}
+9
View File
@@ -0,0 +1,9 @@
/** 金额展示(不依赖 Intl / toLocaleString,兼容微信小程序) */
export function formatMoney(amount: number | string): string {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0.00';
const fixed = n.toFixed(2);
const [intPart, dec] = fixed.split('.');
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${withComma}.${dec}`;
}
+130
View File
@@ -0,0 +1,130 @@
import { useMemo } from 'react';
import Taro from '@tarojs/taro';
export type NavBarMetrics = {
/** 状态栏高度(刘海/灵动岛上方) */
statusBarHeight: number;
/** 导航栏总高度 = 状态栏 + 内容区 */
navBarHeight: number;
/** 标题栏内容区高度(与胶囊对齐) */
navContentHeight: number;
/** 右侧留白,避免与微信胶囊按钮重叠 */
navBarPaddingRight: number;
/** 左侧留白,与右侧对称以实现标题视觉居中 */
navBarPaddingLeft: number;
};
const H5_FALLBACK: NavBarMetrics = {
statusBarHeight: 0,
navBarHeight: 56,
navContentHeight: 56,
/** 避开微信内置浏览器右上角 ··· / 设置入口(约一颗胶囊宽) */
navBarPaddingRight: 96,
navBarPaddingLeft: 16,
};
const WEAPP_FALLBACK: NavBarMetrics = {
statusBarHeight: 20,
navBarHeight: 64,
navContentHeight: 44,
navBarPaddingRight: 96,
navBarPaddingLeft: 96,
};
/** 计算小程序自定义导航栏尺寸(对齐微信胶囊按钮) */
export function getNavBarMetrics(): NavBarMetrics {
if (process.env.TARO_ENV === 'h5') {
// H5:在微信浏览器内额外避让右上角菜单;非微信保持较小右侧留白
const inWechat =
typeof navigator !== 'undefined' && /MicroMessenger/i.test(navigator.userAgent || '');
return {
...H5_FALLBACK,
navBarPaddingRight: inWechat ? 96 : 16,
};
}
try {
const win = Taro.getWindowInfo?.() ?? Taro.getSystemInfoSync();
const menu = Taro.getMenuButtonBoundingClientRect();
const statusBarHeight = win.statusBarHeight ?? WEAPP_FALLBACK.statusBarHeight;
const navContentHeight =
menu.height > 0
? (menu.top - statusBarHeight) * 2 + menu.height
: WEAPP_FALLBACK.navContentHeight;
const navBarHeight = statusBarHeight + navContentHeight;
const navBarPaddingRight =
menu.width > 0
? Math.max(win.windowWidth - menu.left + 8, 16)
: WEAPP_FALLBACK.navBarPaddingRight;
return {
statusBarHeight,
navBarHeight,
navContentHeight,
navBarPaddingRight,
navBarPaddingLeft: navBarPaddingRight,
};
} catch {
return WEAPP_FALLBACK;
}
}
export function useNavBarMetrics(): NavBarMetrics {
return useMemo(() => getNavBarMetrics(), []);
}
/** 仅注入 CSS 变量,供 PageShell / sticky 子元素使用(不加 height/padding */
export function pageShellCssVars(metrics: NavBarMetrics): Record<string, string> {
return {
'--nav-bar-height': `${metrics.navBarHeight}px`,
'--nav-content-height': `${metrics.navContentHeight}px`,
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
'--nav-padding-right': `${metrics.navBarPaddingRight}px`,
'--nav-padding-left': `${metrics.navBarPaddingLeft}px`,
};
}
/** 顶栏自身样式:statusBar padding + 总高 + CSS 变量 */
export function navBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
...pageShellCssVars(metrics),
};
}
/** Tab 顶栏内容行:左右留白(标题单独全屏居中) */
export function tabNavContentStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
height: `${metrics.navContentHeight}px`,
paddingLeft: '20px',
paddingRight: `${metrics.navBarPaddingRight}px`,
boxSizing: 'border-box',
};
}
/** 子页/内页顶栏:标题全屏居中,内容区单独留白 */
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
const pagePad = 20;
// 标题两侧取「右侧避让胶囊」宽度,保证相对屏幕视觉居中
const titlePad = Math.max(metrics.navBarPaddingRight, 72);
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
'--nav-bar-height': `${metrics.navBarHeight}px`,
'--nav-content-height': `${metrics.navContentHeight}px`,
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
'--nav-padding-left': `${pagePad}px`,
'--nav-padding-right': `${titlePad}px`,
};
}
/** 子页顶栏内容行:左侧页边距 + 右侧避让胶囊 */
export function subPageNavContentStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
height: `${metrics.navContentHeight}px`,
paddingLeft: '20px',
paddingRight: `${metrics.navBarPaddingRight}px`,
boxSizing: 'border-box',
};
}
@@ -0,0 +1,69 @@
/**
* H5Taro Vite 偶发把 @tarojs/plugin-framework-react/dist/runtime 打成两份,
* 导致 createReactApp 初始化了 B 份 reactMeta,而 Taro.useDidShow 仍绑定 A 份(R={}),
* 页面一进就报 n.useContext is not a function。
*
* 在 App 渲染前,用与 createReactApp 同一份 runtime 的 hooks 覆盖到 Taro 上。
*/
import Taro from '@tarojs/taro';
import {
useAddToFavorites,
useDidHide,
useDidShow,
useError,
useKeyboardHeight,
useLaunch,
useLoad,
useOptionMenuClick,
usePageNotFound,
usePageScroll,
usePullDownRefresh,
usePullIntercept,
useReachBottom,
useReady,
useResize,
useRouter,
useSaveExitState,
useScope,
useShareAppMessage,
useShareTimeline,
useTabItemTap,
useTitleClick,
useUnhandledRejection,
useUnload,
} from '@tarojs/plugin-framework-react/dist/runtime';
const HOOKS = {
useAddToFavorites,
useDidHide,
useDidShow,
useError,
useKeyboardHeight,
useLaunch,
useLoad,
useOptionMenuClick,
usePageNotFound,
usePageScroll,
usePullDownRefresh,
usePullIntercept,
useReachBottom,
useReady,
useResize,
useRouter,
useSaveExitState,
useScope,
useShareAppMessage,
useShareTimeline,
useTabItemTap,
useTitleClick,
useUnhandledRejection,
useUnload,
} as const;
export function patchTaroH5Hooks() {
if (process.env.TARO_ENV !== 'h5') return;
const target = Taro as unknown as Record<string, unknown>;
for (const [key, fn] of Object.entries(HOOKS)) {
target[key] = fn;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { goLogin } from './auth-nav';
import { isLoggedIn } from './api';
import { ensureWechatAuthForPay } from './wechat-auth';
import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat';
/**
* 支付前门禁:
* - 未登录 → 跳转登录页(微信授权即可,不强制手机号)
* - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页)
* - 小程序缺绑定 → 跳转登录页 needWechat
*/
export async function ensurePayReady(returnPath: string): Promise<boolean> {
if (!isLoggedIn()) {
goLogin(returnPath);
return false;
}
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
if (!needsWechatAuthForPay(config, profile)) {
return true;
}
if (process.env.TARO_ENV === 'h5') {
const auth = await ensureWechatAuthForPay();
if (auth.ok) return true;
// 旧版 needBindPhone 已不再返回;缺 openId 时走登录补微信绑定
if ('needBindPhone' in auth && auth.needBindPhone) {
goLogin(returnPath, { needWechat: '1' });
return false;
}
// redirecting:正在跳转微信 OAuth
return false;
}
goLogin(returnPath, { needWechat: '1' });
return false;
} catch (e) {
// 仅会话失效时踢回登录;网络/业务错误不误清登录态
const msg = e instanceof Error ? e.message : '';
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
goLogin(returnPath);
}
return false;
}
}
+94
View File
@@ -0,0 +1,94 @@
import type {
ClientRuntimeConfig,
WechatJsapiPrepayParams,
WechatLoginResult,
WechatPayOrderResult,
} from '@dukang/shared-types';
import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types';
import { invokeWechatPay } from '@dukang/weixin-sdk';
import { request, saveAuth, type UserProfile } from './api';
import { isWechatEnv, weixinSdk } from './weixin';
export function isMiniWechatEnv(): boolean {
return process.env.TARO_ENV === 'weapp';
}
export function isWechatAuthRequiredError(err: unknown): boolean {
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('/common/client-config');
}
export async function fetchUserProfile(): Promise<UserProfile> {
return request<UserProfile>('/auth/me');
}
/** 真实微信支付且未绑定微信时需要授权(小程序 / H5 微信内) */
export function needsWechatAuthForPay(
config: ClientRuntimeConfig,
profile: UserProfile | null,
): boolean {
if (!isWxAuthorizeEnabled(config)) return false;
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
}
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
return true;
}
/** H5:发起公众号 OAuth(可能直接跳转);小程序请用 bindWechatForUser */
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
if (process.env.TARO_ENV === 'h5') {
return weixinSdk.login();
}
throw new Error('请使用小程序微信授权');
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitOrderPaid(orderId: string, maxAttempts = 15): Promise<boolean> {
for (let i = 0; i < maxAttempts; i += 1) {
const order = await request<{ payStatus?: string }>(`/trade/orders/${orderId}`);
if (order.payStatus === 'PAID') return true;
await sleep(2000);
}
return false;
}
export async function payOrder(orderId: string): Promise<'paid' | 'pending'> {
const result = await request<WechatPayOrderResult>(`/trade/orders/${orderId}/pay`, {
method: 'POST',
});
if (result.mode === 'jsapi' && result.prepay) {
const prepay = result.prepay as WechatJsapiPrepayParams;
if (process.env.TARO_ENV === 'h5') {
await weixinSdk.pay(prepay);
} else {
await invokeWechatPay(prepay, { platform: 'mini' });
}
const paid = await waitOrderPaid(orderId);
return paid ? 'paid' : 'pending';
}
return 'paid';
}
export type WechatBindResult =
| { ok: true; profile?: UserProfile }
| { ok: false; needBindPhone: true; wxSessionKey: string }
| { ok: false; redirecting: true };
+23
View File
@@ -0,0 +1,23 @@
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
export function normalizePhoneInput(value: string): string {
return value.replace(/\D/g, '').slice(0, 11);
}
export function validateMobilePhone(phone: string): { ok: boolean; message?: string } {
const trimmed = phone.trim();
if (!trimmed) {
return { ok: false, message: '请输入手机号码' };
}
if (trimmed.length !== 11) {
return { ok: false, message: '手机号码须为 11 位' };
}
if (!MOBILE_PHONE_RE.test(trimmed)) {
return { ok: false, message: '请输入正确的手机号码' };
}
return { ok: true };
}
export function maskPhone(phone: string) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
@@ -0,0 +1,45 @@
/** 商品履约能力(与 HQ / 交易硬闸一致) */
export type FulfillmentFlags = {
allowOnlinePurchase?: boolean | null;
allowOnSitePickup?: boolean | null;
allowCrossCityDelivery?: boolean | null;
};
/** 未返回时默认允许线上(存量商品) */
export function canBuyOnline(p: FulfillmentFlags): boolean {
return p.allowOnlinePurchase !== false;
}
/** 仅显式开启才展示现场取货 */
export function canPickupOnSite(p: FulfillmentFlags): boolean {
return p.allowOnSitePickup === true;
}
export function canCrossCity(p: FulfillmentFlags): boolean {
return p.allowCrossCityDelivery !== false;
}
export function normalizeFulfillmentFlags<T extends FulfillmentFlags>(p: T): T {
return {
...p,
allowOnlinePurchase: canBuyOnline(p),
allowOnSitePickup: canPickupOnSite(p),
allowCrossCityDelivery: canCrossCity(p),
};
}
/** 与交易侧一致:收货市 ≠ 开城市且 ≠ 郑州 → 跨城 */
export function isCrossCityAddress(
addressCity: string | null | undefined,
openCityName: string | null | undefined,
): boolean {
const addr = (addressCity || '').trim();
const open = (openCityName || '').trim();
if (!addr) return false;
if (addr === '郑州市') return false;
if (open && addr === open) return false;
// 尚无开城信息时,非郑州地址先按可能跨城处理(由预览接口最终裁定)
if (!open) return addr !== '郑州市';
return true;
}
+45
View File
@@ -0,0 +1,45 @@
/** 商品无图时的占位(小程序端无本地兜底图时用空串由 UI 显示灰底) */
export const PRODUCT_IMAGE_FALLBACK = '';
export type ProductImageSource = {
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
detailImageUrls?: string[] | null;
};
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>();
const result: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) continue;
seen.add(url);
result.push(url);
}
return result;
}
export function getProductMainImage(source?: ProductImageSource | null): string {
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
}
export function getProductImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
const main = source?.mainImageUrl;
if (main) return [main];
return PRODUCT_IMAGE_FALLBACK ? [PRODUCT_IMAGE_FALLBACK] : [];
}
/** 详情页顶部轮播 */
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
return getProductImages(source);
}
/** 详情页图文长图 */
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
return uniqueUrls(source?.detailImageUrls ?? []);
}
export const FALLBACK_CITY_CODE = '410100';
+129
View File
@@ -0,0 +1,129 @@
import Taro from '@tarojs/taro';
import { request } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
function safeDecode(raw: string): string {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function normalizePromoId(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
// 小程序码 scene 写入的是推广活动数字 ID
if (!/^\d+$/.test(s)) return null;
return s;
}
type EnterOptionsLike = {
scene?: string | number;
query?: Record<string, string | undefined>;
path?: string;
};
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
normalizePromoId(q.pid) ||
null
);
}
export function getStoredPromoId(): string | null {
try {
const v = Taro.getStorageSync(PROMO_ID_KEY);
return normalizePromoId(v);
} catch {
return null;
}
}
export function setStoredPromoId(promoId: string) {
const id = normalizePromoId(promoId);
if (!id) return;
try {
Taro.setStorageSync(PROMO_ID_KEY, id);
} catch {
/* ignore */
}
}
function readEnterOptions(): EnterOptionsLike | null {
try {
if (typeof Taro.getEnterOptionsSync === 'function') {
return Taro.getEnterOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
try {
if (typeof Taro.getLaunchOptionsSync === 'function') {
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
// H5:从 URL query 读取
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return {
query: {
scene: params.get('scene') || undefined,
promoId: params.get('promoId') || undefined,
pid: params.get('pid') || undefined,
},
};
}
return null;
}
/**
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey === lastScanTouchKey) return;
lastScanTouchKey = touchKey;
await touchPromo({ promoId: fromEnter, countScan: true });
return;
}
// 无新 scene 时不重复扫码计数
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
}
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
try {
await request('/promo/touch', {
method: 'POST',
data: {
promoId: input.promoId,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
+42
View File
@@ -0,0 +1,42 @@
import './text-encoding-polyfill';
import QRCode from 'qrcode';
const QR_SIZE = 240;
const QR_OPTIONS = {
width: QR_SIZE * 2,
margin: 0,
color: { dark: '#1f1a17', light: '#ffffff' },
} as const;
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
export function drawRedeemQrOnCanvas(
ctx: CanvasRenderingContext2D,
token: string,
sizePx = QR_SIZE,
) {
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
const count = qr.modules.size;
const cell = sizePx / count;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, sizePx, sizePx);
ctx.fillStyle = '#1f1a17';
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(col * cell, row * cell, cell, cell);
}
}
}
}
/** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
try {
return await QRCode.toDataURL(token, QR_OPTIONS);
} catch {
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
}
}
export const REDEEM_QR_DISPLAY_SIZE = QR_SIZE;
+166
View File
@@ -0,0 +1,166 @@
import { regionData } from 'element-china-area-data';
export type RegionTree = Record<string, Record<string, string[]>>;
function buildRegionTree(): RegionTree {
const tree: RegionTree = {};
for (const province of regionData) {
const cities: Record<string, string[]> = {};
for (const city of province.children ?? []) {
cities[city.label] = (city.children ?? []).map((district) => district.label);
}
tree[province.label] = cities;
}
return tree;
}
export const REGION_TREE: RegionTree = buildRegionTree();
export const PROVINCES = Object.keys(REGION_TREE);
export const REGION_ALL = '全市';
export function getCities(province: string): string[] {
if (province === REGION_ALL) return [];
return Object.keys(REGION_TREE[province] ?? {});
}
export function getDistricts(province: string, city: string): string[] {
if (province === REGION_ALL || city === REGION_ALL) return [];
return REGION_TREE[province]?.[city] ?? [];
}
export function getProvincesForPicker(): string[] {
return [...PROVINCES];
}
export function getCitiesForPicker(province: string): string[] {
if (province === REGION_ALL) return [REGION_ALL];
return [...getCities(province)];
}
export function getDistrictsForPicker(province: string, city: string): string[] {
if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL];
return [REGION_ALL, ...getDistricts(province, city)];
}
export function formatRegion(province: string, city: string, district: string): string {
if (!province) return '';
if (province === REGION_ALL) return REGION_ALL;
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`;
if (!city || !district) return '';
return `${province} ${city} ${district}`;
}
export function formatRegionCity(province: string, city: string): string {
if (!province) return '';
if (province === REGION_ALL) return REGION_ALL;
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
if (!city) return province;
return `${province} ${city}`;
}
export function toCityLevelRegion(selection: RegionSelection): RegionSelection {
const normalized = normalizeRegionSelection(selection);
return {
province: normalized.province,
city: normalized.city,
district: REGION_ALL,
};
}
export type RegionSelection = {
province: string;
city: string;
district: string;
};
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
if (selection.province === REGION_ALL) {
return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL };
}
const province = PROVINCES.includes(selection.province)
? selection.province
: DEFAULT_REGION.province;
if (selection.city === REGION_ALL) {
return { province, city: REGION_ALL, district: REGION_ALL };
}
const cities = getCities(province);
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city);
if (selection.district === REGION_ALL) {
return { province, city, district: REGION_ALL };
}
const districts = getDistricts(province, city);
const district = districts.includes(selection.district)
? selection.district
: (districts[0] ?? DEFAULT_REGION.district);
return { province, city, district };
}
export const DEFAULT_REGION: RegionSelection = {
province: '河南省',
city: '郑州市',
district: REGION_ALL,
};
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
const cityName = city.endsWith('市') ? city : `${city}`;
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
const cities = getCities(provinceInTree);
const matchedCity = cities.includes(cityName)
? cityName
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
const districts = getDistricts(provinceInTree, matchedCity);
const districtName =
district && districts.includes(district) ? district : REGION_ALL;
return normalizeRegionSelection({
province: provinceInTree,
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
district: districtName,
});
}
function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim();
}
type RegionFilterStore = {
province?: string;
cityName?: string;
district?: string;
};
/** 门店列表按省市区筛选(支持 REGION_ALL */
export function matchesRegionFilter(store: RegionFilterStore, region: RegionSelection): boolean {
const normalized = normalizeRegionSelection(region);
if (normalized.province !== REGION_ALL) {
if ((store.province ?? '') !== normalized.province) return false;
}
if (normalized.city !== REGION_ALL) {
const storeCity = store.cityName ?? '';
const cityNorm = normalizeCityName(normalized.city);
if (
storeCity !== normalized.city &&
normalizeCityName(storeCity) !== cityNorm
) {
return false;
}
}
if (normalized.district !== REGION_ALL) {
if ((store.district ?? '') !== normalized.district) return false;
}
return true;
}
export function formatRegionLabel(region: RegionSelection): string {
const normalized = normalizeRegionSelection(region);
if (normalized.province === REGION_ALL) return REGION_ALL;
if (normalized.city === REGION_ALL) return normalized.province;
if (normalized.district === REGION_ALL) return `${normalized.city}`;
return normalized.district;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
* 仅 logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type StoresSessionRegion = {
province: string;
city: string;
district: string;
};
export type StoresSessionCategory = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export type StoresListCache = {
cityKey: string;
cityCode: string;
/** 登录态指纹:token 变化时需重新拉取(白名单) */
authKey: string;
listRegion: StoresSessionRegion;
items: unknown[];
filterRegion: StoresSessionRegion;
keyword: string;
keywordInput: string;
category: StoresSessionCategory;
};
type StoresSession = {
bootstrapped: boolean;
cache: StoresListCache | null;
};
const STORAGE_KEY = 'dukang_stores_session_v1';
let memory: StoresSession | null = null;
function emptySession(): StoresSession {
return { bootstrapped: false, cache: null };
}
function readSession(): StoresSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as StoresListCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: StoresSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isStoresSessionBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function markStoresSessionBootstrapped(): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true });
}
export function getStoresListCache(): StoresListCache | null {
return readSession().cache;
}
export function setStoresListCache(cache: StoresListCache | null): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true, cache });
}
export function patchStoresFilterCache(
patch: Partial<
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
>,
): void {
const cur = readSession();
if (!cur.cache) {
// 列表尚未写入时也要记下用户筛选,避免切回丢失
writeSession({
bootstrapped: true,
cache: {
cityKey: '',
cityCode: '',
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
items: [],
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
keyword: patch.keyword ?? '',
keywordInput: patch.keywordInput ?? '',
category: patch.category ?? {
parentId: '',
parentName: '',
childId: '',
childName: '',
},
},
});
return;
}
writeSession({
...cur,
bootstrapped: true,
cache: { ...cur.cache, ...patch },
});
}
export function resetStoresSessionBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
@@ -0,0 +1,52 @@
/** 微信小程序基础库未内置 TextEncoderqrcode 库依赖它编码 payload */
function installTextEncodingPolyfill() {
const root = (typeof globalThis !== 'undefined'
? globalThis
: typeof global !== 'undefined'
? global
: typeof wx !== 'undefined'
? wx
: {}) as typeof globalThis & { TextEncoder?: typeof TextEncoder };
if (typeof root.TextEncoder !== 'undefined') return;
class MiniTextEncoder implements TextEncoder {
readonly encoding = 'utf-8';
encode(input?: string): Uint8Array {
const str = input ?? '';
const bytes: number[] = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
const next = str.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
i += 1;
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
bytes.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
}
return new Uint8Array(bytes);
}
}
root.TextEncoder = MiniTextEncoder as unknown as typeof TextEncoder;
}
installTextEncodingPolyfill();
export {};
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPageView } from './analytics';
export function usePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPageView(eventName, params);
}, [eventName, params]);
}
+284
View File
@@ -0,0 +1,284 @@
import Taro from '@tarojs/taro';
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
import { API_BASE, CLIENT_APP, getToken, request } from './api';
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
import { FALLBACK_CITY_CODE } from './product-images';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
const USER_COORDS_KEY = 'dukang_user_coords';
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
const LOCATION_DENIED_KEY = 'dukang_location_denied';
export type ResolvedUserCity = {
province: string;
city: string;
district: string;
cityCode?: string;
cityName?: string;
openCity: boolean;
region: RegionSelection;
displayCity: string;
};
export type UserCoords = { latitude: number; longitude: number };
type GpsCityCache = ResolvedUserCity & { timestamp: number };
const FALLBACK_CITY: ResolvedUserCity = {
province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city,
district: REGION_ALL,
cityCode: FALLBACK_CITY_CODE,
cityName: '郑州市',
openCity: true,
region: DEFAULT_REGION,
displayCity: '郑州市',
};
function isLocationDenied(): boolean {
try {
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
} catch {
return false;
}
}
function markLocationDenied() {
try {
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
} catch {
/* ignore */
}
}
function clearLocationDenied() {
try {
Taro.removeStorageSync(LOCATION_DENIED_KEY);
} catch {
/* ignore */
}
}
function isDenyMessage(errMsg?: string): boolean {
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
errMsg || '',
);
}
function readCache(): GpsCityCache | null {
try {
const raw = Taro.getStorageSync(GPS_CITY_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as GpsCityCache;
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
return parsed;
} catch {
return null;
}
}
function writeCache(data: ResolvedUserCity) {
try {
Taro.setStorageSync(
GPS_CITY_STORAGE_KEY,
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
);
} catch {
/* ignore */
}
}
export function writeUserCoords(latitude: number, longitude: number) {
try {
Taro.setStorageSync(
USER_COORDS_KEY,
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
);
} catch {
/* ignore */
}
}
export function readCachedUserCoords(): UserCoords | null {
try {
const raw = Taro.getStorageSync(USER_COORDS_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
return { latitude: parsed.latitude, longitude: parsed.longitude };
} catch {
return null;
}
}
/** 门店列表默认用市级全市筛选 */
export function toCityWideRegion(region: RegionSelection): RegionSelection {
return {
province: region.province,
city: region.city,
district: REGION_ALL,
};
}
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
function cacheFallbackAndMaybeDeny(denied: boolean) {
if (denied) markLocationDenied();
writeCache(FALLBACK_CITY);
}
async function reportLocationToServer(payload: {
latitude?: number;
longitude?: number;
sdk: 'jssdk' | 'geolocation';
status: 'success' | 'fail';
errMsg?: string;
}) {
return request<{
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity?: boolean;
}>('/common/wechat/location', {
method: 'POST',
data: payload,
});
}
function toResolved(data: {
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity?: boolean;
}): ResolvedUserCity | null {
if (!data.province || !data.city) return null;
const region = regionFromGeo(data.province, data.city, data.district);
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}`);
return {
province: data.province,
city: data.city,
district: data.district ?? '',
cityCode: data.cityCode,
cityName: data.cityName,
openCity: !!data.openCity,
region,
displayCity,
};
}
async function promptLocationAuthOnce() {
await Taro.showModal({
title: '位置授权',
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
confirmText: '知道了',
showCancel: false,
}).catch(() => {});
}
function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
return new Promise((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
}
async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
const outcome = await getWechatLocationDetailed({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getAccessToken: () => getToken() || null,
});
if (!outcome.location) {
const denied = isDenyMessage(outcome.errMsg);
await reportLocationToServer({
sdk: outcome.sdk,
status: 'fail',
errMsg: outcome.errMsg,
}).catch(() => {});
cacheFallbackAndMaybeDeny(denied);
return null;
}
try {
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
const data = await reportLocationToServer({
latitude: outcome.location.latitude,
longitude: outcome.location.longitude,
sdk: outcome.sdk,
status: 'success',
});
const resolved = toResolved(data);
if (resolved) {
clearLocationDenied();
writeCache(resolved);
}
return resolved;
} catch {
cacheFallbackAndMaybeDeny(false);
return null;
}
}
/** 获取并解析用户当前城市;失败返回郑州市兜底 */
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
if (!force) {
if (isLocationDenied()) {
const cached = readCache();
return cached ?? FALLBACK_CITY;
}
const cached = readCache();
if (cached) return cached;
}
if (process.env.TARO_ENV === 'h5') {
const fromJssdk = await resolveViaH5Jssdk();
return fromJssdk ?? FALLBACK_CITY;
}
if (process.env.TARO_ENV !== 'weapp') {
return FALLBACK_CITY;
}
try {
const loc = await getMiniLocation();
writeUserCoords(loc.latitude, loc.longitude);
const data = await reportLocationToServer({
latitude: loc.latitude,
longitude: loc.longitude,
sdk: 'jssdk',
status: 'success',
});
const resolved = toResolved(data);
if (resolved) {
clearLocationDenied();
writeCache(resolved);
return resolved;
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const denied = isDenyMessage(errMsg);
if (denied && !isLocationDenied()) {
await promptLocationAuthOnce();
}
await reportLocationToServer({
sdk: 'jssdk',
status: 'fail',
errMsg: errMsg.slice(0, 200),
}).catch(() => {});
cacheFallbackAndMaybeDeny(denied);
}
return FALLBACK_CITY;
}
export function getCityCodeForCatalog(resolved: ResolvedUserCity): string {
return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE;
}
+38
View File
@@ -0,0 +1,38 @@
import Taro from '@tarojs/taro';
import { normalizePhoneInput } from './phone';
const USER_PHONE_KEY = 'user_phone';
function isValidMobile(phone: string) {
return /^1[3-9]\d{9}$/.test(phone);
}
export function saveUserPhone(phone: string) {
const normalized = normalizePhoneInput(phone);
if (!isValidMobile(normalized)) return;
try {
Taro.setStorageSync(USER_PHONE_KEY, normalized);
} catch {
/* ignore */
}
}
export function getStoredUserPhone(): string {
try {
const value = Taro.getStorageSync(USER_PHONE_KEY);
return typeof value === 'string' ? value : '';
} catch {
return '';
}
}
/** 新增地址等场景:优先本地缓存,其次资料里的已验证手机号 */
export function resolveDefaultUserPhone(profile?: { phone?: string | null; phoneVerified?: boolean } | null) {
const stored = getStoredUserPhone();
if (isValidMobile(stored)) return stored;
if (!profile?.phoneVerified || !profile.phone) return '';
const fromProfile = normalizePhoneInput(profile.phone);
if (!isValidMobile(fromProfile)) return '';
saveUserPhone(fromProfile);
return fromProfile;
}
+149
View File
@@ -0,0 +1,149 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import Taro from '@tarojs/taro';
import { saveAuth } from './api';
import {
fetchMiniWechatUserInfo,
mergeWxDisplayProfile,
needsWxProfileFill,
syncMiniWechatProfile,
} from './mini-wechat-profile';
/**
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
*(开发者工具热更新时常见旧页 + 新 common 混用)
*/
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
const _wxProfileCompat = {
fetchMiniWechatUserInfo,
mergeWxDisplayProfile,
needsWxProfileFill,
};
if (
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
) {
throw new Error('mini-wechat-profile helpers missing');
}
import {
authorizeWechatForPay,
fetchClientConfig,
fetchUserProfile,
needsWechatAuthForPay,
saveWechatLoginResult,
type WechatBindResult,
} from './pay-wechat';
import { isWechatEnv, weixinSdk } from './weixin';
export type WechatAuthEnsureResult =
| { ok: true }
| { ok: false; redirecting: true }
| { ok: false; needBindPhone: true; wxSessionKey: string };
/** 小程序:Taro.login → /auth/login/wechatH5:公众号 OAuth */
export async function loginWithWechat(): Promise<WechatLoginResult | void> {
if (process.env.TARO_ENV === 'h5') {
return loginWithWechatSdk();
}
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
}
const { request } = await import('./api');
return request<WechatLoginResult>('/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
}
export async function checkNeedsWechatAuth(): Promise<boolean> {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
return needsWechatAuthForPay(config, profile);
}
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
if (!isWechatEnv()) return { ok: true };
if (!(await checkNeedsWechatAuth())) return { ok: true };
const result = await authorizeWechatForPay();
if (!result) return { ok: false, redirecting: true };
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (saveWechatLoginResult(result)) {
return { ok: true };
}
return { ok: false, redirecting: true };
}
/** 处理 URL 中 OAuth ?code= 回调(H5 公众号) */
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
if (process.env.TARO_ENV !== 'h5') return null;
if (!isWechatEnv()) return null;
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return null;
const result = await weixinSdk.handleOAuthCallback();
if (result) stripOAuthParamsFromLocation();
return result;
}
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error('请在微信内打开以使用微信一键授权');
}
return weixinSdk.login();
}
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
return saveWechatLoginResult(result);
}
/** 已登录用户绑定微信:小程序 code;H5 走公众号 OAuth(带 JWT 时服务端会 attach */
export async function bindWechatForUser(
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
): Promise<WechatBindResult> {
if (process.env.TARO_ENV === 'h5') {
const result = await authorizeWechatForPay();
if (!result) {
return { ok: false, redirecting: true };
}
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (result.accessToken) {
saveAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
}
const profile = await fetchUserProfile();
return { ok: true, profile };
}
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信授权失败');
}
const { request } = await import('./api');
const data = await request<WechatLoginResult>('/auth/wechat/bind', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
if (data.needBindPhone && data.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey };
}
await syncMiniWechatProfile(prefetchedWxProfile);
const profile = await fetchUserProfile();
return { ok: true, profile };
}
export type { WechatBindResult };
@@ -0,0 +1,299 @@
import Taro from '@tarojs/taro';
import { request, toast } from './api';
import { fetchClientConfig } from './pay-wechat';
/** 微信确认收货组件来源 AppId(官方固定) */
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
const PENDING_KEY = 'pending_wechat_order_confirm';
export type WechatConfirmPayload = {
merchantId?: string | null;
merchantTradeNo?: string | null;
transactionId?: string | null;
};
type PendingConfirm = {
orderId: string;
redirectUrl?: string;
};
type OpenBusinessViewOptions = {
businessType: string;
extraData: Record<string, string>;
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
complete?: () => void;
};
type MiniWx = {
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
};
/**
* 取小程序原生 wx.openBusinessView。
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
*/
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | null {
if (process.env.TARO_ENV !== 'weapp') return null;
const candidates: Array<MiniWx | null | undefined> = [];
try {
// eslint-disable-next-line no-undef
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
} catch {
/* ignore */
}
const g = globalThis as typeof globalThis & { wx?: MiniWx };
candidates.push(g.wx);
try {
// 跳出 bundler 模块作用域,读微信运行时全局
const fromRuntime = new Function(
'return typeof wx !== "undefined" ? wx : null',
)() as MiniWx | null;
candidates.push(fromRuntime);
} catch {
/* ignore */
}
for (const api of candidates) {
if (api && typeof api.openBusinessView === 'function') {
return api.openBusinessView.bind(api);
}
}
return null;
}
export function savePendingWechatOrderConfirm(pending: PendingConfirm) {
Taro.setStorageSync(PENDING_KEY, JSON.stringify(pending));
}
export function takePendingWechatOrderConfirm(): PendingConfirm | null {
try {
const raw = Taro.getStorageSync(PENDING_KEY);
if (!raw) return null;
Taro.removeStorageSync(PENDING_KEY);
return typeof raw === 'string' ? (JSON.parse(raw) as PendingConfirm) : (raw as PendingConfirm);
} catch {
Taro.removeStorageSync(PENDING_KEY);
return null;
}
}
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
return {
merchantId: payload?.merchantId?.trim() || undefined,
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
transactionId: payload?.transactionId?.trim() || undefined,
};
}
async function resolveConfirmPayload(
orderId: string,
hint?: WechatConfirmPayload | null,
): Promise<WechatConfirmPayload> {
const fromHint = normalizePayload(hint);
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
return fromHint;
}
const order = await request<{
orderNo?: string;
payExternalNo?: string | null;
payment?: { externalNo?: string | null } | null;
wechatConfirm?: WechatConfirmPayload | null;
}>(`/trade/orders/${orderId}`);
const fromApi = normalizePayload(order.wechatConfirm);
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
return fromApi;
}
const transactionId =
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
return normalizePayload({
transactionId,
merchantTradeNo: order.orderNo,
merchantId: fromApi.merchantId,
});
}
/**
* 拉起微信「确认收货」半屏组件。
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
*/
export function openWechatOrderConfirm(opts: {
orderId: string;
payload: WechatConfirmPayload;
redirectUrl?: string;
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
const open = getOpenBusinessView();
if (!open) {
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
taroEnv: process.env.TARO_ENV,
});
return Promise.resolve('unsupported');
}
const payload = normalizePayload(opts.payload);
const transactionId = payload.transactionId;
const merchantId = payload.merchantId;
const merchantTradeNo = payload.merchantTradeNo;
if (!transactionId && !(merchantId && merchantTradeNo)) {
console.warn('[wechat-order-confirm] missing pay ref', payload);
return Promise.resolve('missing_pay_ref');
}
const extraData: Record<string, string> = {};
if (transactionId) extraData.transaction_id = transactionId;
if (merchantId) extraData.merchant_id = merchantId;
if (merchantTradeNo) extraData.merchant_trade_no = merchantTradeNo;
savePendingWechatOrderConfirm({
orderId: opts.orderId,
redirectUrl: opts.redirectUrl,
});
return new Promise((resolve) => {
let settled = false;
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
if (settled) return;
settled = true;
resolve(mode);
};
try {
open({
businessType: 'weappOrderConfirm',
extraData,
success: () => done('opened'),
fail: (err) => {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
done('unsupported');
},
});
} catch (err) {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView throw', err);
toast('无法打开微信确认收货组件');
done('unsupported');
}
});
}
type ReferrerExtra = {
status?: string;
errormsg?: string;
req_extradata?: Record<string, string>;
};
/**
* 处理确认收货组件回跳(App/页面 onShow)。
* 成功则调用后端同步订单,并返回是否已处理。
*/
export async function handleWechatOrderConfirmShow(options?: {
referrerInfo?: { appId?: string; extraData?: ReferrerExtra };
}): Promise<{ handled: boolean; orderId?: string; redirectUrl?: string }> {
const info = options?.referrerInfo;
if (!info?.appId || info.appId !== WECHAT_ORDER_CONFIRM_APPID) {
return { handled: false };
}
const pending = takePendingWechatOrderConfirm();
const status = info.extraData?.status;
if (status === 'cancel') {
toast('已取消确认收货');
return { handled: true, orderId: pending?.orderId };
}
if (status === 'fail') {
toast(info.extraData?.errormsg || '微信确认收货失败');
return { handled: true, orderId: pending?.orderId };
}
if (status !== 'success' || !pending?.orderId) {
return { handled: true };
}
try {
await request(`/trade/orders/${pending.orderId}/confirm-receive`, {
method: 'POST',
data: { source: 'WECHAT_COMPONENT' },
});
toast('确认收货成功', 'success');
return {
handled: true,
orderId: pending.orderId,
redirectUrl: pending.redirectUrl,
};
} catch (e) {
toast(e instanceof Error ? e.message : '同步订单失败');
return { handled: true, orderId: pending.orderId };
}
}
async function confirmLocally(opts: {
orderId: string;
onSitePickup?: boolean;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'local'> {
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
method: 'POST',
data: {
onSitePickup: !!opts.onSitePickup,
source: 'USER',
},
});
await opts.onLocalSuccess?.();
return 'local';
}
/**
* 统一入口:
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
* - Mock / H5:本地确认
*/
export async function confirmOrderReceive(opts: {
orderId: string;
wechatConfirm?: WechatConfirmPayload | null;
onSitePickup?: boolean;
redirectUrl?: string;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'wechat' | 'local'> {
const isWeapp = process.env.TARO_ENV === 'weapp';
if (!isWeapp) {
return confirmLocally(opts);
}
let mockPay = false;
try {
const cfg = await fetchClientConfig();
mockPay = !!cfg.mockPay;
} catch {
mockPay = false;
}
if (mockPay) {
return confirmLocally(opts);
}
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
const mode = await openWechatOrderConfirm({
orderId: opts.orderId,
payload,
redirectUrl: opts.redirectUrl,
});
if (mode === 'opened') return 'wechat';
if (mode === 'missing_pay_ref') {
throw new Error('缺少微信支付单号,无法打开确认收货组件');
}
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
}
+107
View File
@@ -0,0 +1,107 @@
import Taro from '@tarojs/taro';
import { BRAND_LOGO_URL } from '@dukang/shared-types';
import type { WechatShareData } from '@dukang/weixin-sdk';
import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk';
import { toast } from './api';
import { isWechatEnv, weixinSdk } from './weixin';
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
export function getDefaultShareImageUrl(): string {
return BRAND_LOGO_URL;
}
export function buildDefaultShareData(
overrides?: Partial<WechatShareData>,
): WechatShareData {
let link = overrides?.link;
if (!link) {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
link = getWechatShareLink();
} else {
link = '';
}
}
return {
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
link,
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
};
}
/** 配置 H5 微信内右上角分享卡片 */
export async function applyWechatShare(
overrides?: Partial<WechatShareData>,
): Promise<void> {
if (process.env.TARO_ENV !== 'h5') return;
if (!isWechatBrowser()) return;
await weixinSdk.setShare(buildDefaultShareData(overrides));
}
export type PageSharePayload = {
title?: string;
desc?: string;
/** 小程序分享 path,如 /pages/product-detail/index?id=1 */
path?: string;
imgUrl?: string;
/** H5 自定义分享 link,默认当前页 */
link?: string;
};
/**
* 点击「分享」按钮。
* H5:走微信 JSSDK 配置分享卡片,并尝试 invoke 调起面板;否则返回需展示引导蒙层。
*/
export async function handleShareButtonClick(
payload?: PageSharePayload,
): Promise<{ showGuide: boolean }> {
if (process.env.TARO_ENV === 'weapp' || isMiniProgram()) {
try {
await Taro.showShareMenu({ withShareTicket: true, showShareItems: ['shareAppMessage', 'shareTimeline'] });
} catch {
try {
await Taro.showShareMenu({ withShareTicket: true });
} catch {
/* ignore */
}
}
toast(WECHAT_SHARE_HINT);
return { showGuide: false };
}
if (!isWechatEnv()) {
toast('请在微信内打开后分享');
return { showGuide: false };
}
const data = buildDefaultShareData({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
});
try {
const result = await weixinSdk.share(data);
if (result.invoked) {
return { showGuide: false };
}
toast(WECHAT_SHARE_HINT);
return { showGuide: true };
} catch (e) {
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
return { showGuide: true };
}
}
/** 供 useShareAppMessage 使用的标题/路径/图 */
export function toWeappShareMessage(payload?: PageSharePayload) {
return {
title: payload?.title || DEFAULT_SHARE_TITLE,
path: payload?.path || '/pages/home/index',
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
};
}
+16
View File
@@ -0,0 +1,16 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
import { API_BASE, CLIENT_APP, getToken } from './api';
/**
* H5 与业务 API 共用 CLIENT_APP
* - H5 → USER_H5(公众号 OAuth openId + 公众号支付 appId
* - 小程序 → USER_MINI
* 须与 JWT 签发一致,才能同时避免「反复授权」与「appid/openid 不匹配」。
*/
export const weixinSdk = createWeixinSdk({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getAccessToken: () => getToken() || null,
});
export { isWechatEnv };