feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
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}` });
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/** 格式化为 Asia/Shanghai:2026-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())}`;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/** 球面距离(米) */
|
||||
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`;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 首页商品列表「当次登录」会话 —— 切 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 */
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 微信小程序基础库无 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 {};
|
||||
@@ -1,157 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** 金额展示(不依赖 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}`;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* H5:Taro 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;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 };
|
||||
@@ -1,23 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/** 商品履约能力(与 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;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/** 商品无图时的占位(小程序端无本地兜底图时用空串由 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';
|
||||
@@ -1,129 +0,0 @@
|
||||
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 {
|
||||
/* 静默失败,不阻断浏览 */
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** H5:Data 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;
|
||||
@@ -1,166 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 门店列表「当次登录」会话 —— 用 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 */
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/** 微信小程序基础库未内置 TextEncoder,qrcode 库依赖它编码 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 {};
|
||||
@@ -1,11 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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/wechat;H5:公众号 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 };
|
||||
@@ -1,299 +0,0 @@
|
||||
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('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user