feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
function normalizeBase(base?: string): string {
|
||||
if (!base || base === '/') return '';
|
||||
return base.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** React Router basename(来自 Vite `base`) */
|
||||
export function getRouterBasename(baseUrl?: string): string | undefined {
|
||||
const base = normalizeBase(baseUrl ?? import.meta.env?.BASE_URL);
|
||||
return base || undefined;
|
||||
}
|
||||
|
||||
/** 拼接带 Vite base 前缀的应用内路径(用于 window.location 跳转) */
|
||||
export function toAppPath(path: string, baseUrl?: string): string {
|
||||
const base = normalizeBase(baseUrl ?? import.meta.env?.BASE_URL);
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${base}${normalized}`;
|
||||
}
|
||||
|
||||
export function isOnAppPath(path: string, baseUrl?: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const target = toAppPath(path, baseUrl);
|
||||
return window.location.pathname === target || window.location.pathname.startsWith(`${target}/`);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
/** 非微信内置浏览器时提示(与 C 端 LoginPage 文案一致) */
|
||||
export const WECHAT_INAPP_REQUIRED_MSG = '请在微信内打开以使用微信一键授权';
|
||||
|
||||
const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
|
||||
|
||||
function randomState() {
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
config: WeixinSdkConfig,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': config.clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const token = config.getAccessToken?.();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const apiBase = config.apiBase ?? '/api/v1';
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
/** 获取公众号 OAuth 授权跳转 URL */
|
||||
export async function getWechatOAuthUrl(
|
||||
config: WeixinSdkConfig,
|
||||
redirectUri: string,
|
||||
scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo',
|
||||
): Promise<string> {
|
||||
const state = randomState();
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
const qs = new URLSearchParams({ redirectUri, scope, state });
|
||||
const data = await apiRequest<{ url: string }>(config, `/common/wechat/oauth-url?${qs}`);
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
|
||||
export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise<void> {
|
||||
if (!isWechatBrowser()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const uri = redirectUri ?? window.location.href.split('#')[0];
|
||||
const url = await getWechatOAuthUrl(config, uri);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
/** 小程序 wx.login 获取 code */
|
||||
export function getMiniProgramLoginCode(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.wx?.login) {
|
||||
reject(new Error('wx.login 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.login({
|
||||
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到 code'))),
|
||||
fail: (err) => reject(new Error(err.errMsg || 'wx.login 失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 用 code 完成微信登录(自动识别 H5 / 小程序) */
|
||||
export async function loginWithWechatCode(
|
||||
config: WeixinSdkConfig,
|
||||
code: string,
|
||||
platform?: WechatLoginPlatform,
|
||||
): Promise<WechatLoginResult> {
|
||||
const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5');
|
||||
const loginPath = config.wechatLoginPath ?? '/auth/login/wechat';
|
||||
return apiRequest<WechatLoginResult>(config, loginPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code, platform: resolvedPlatform }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 处理 OAuth 回调 URL 中的 code 参数并登录 */
|
||||
export async function handleWechatOAuthCallback(
|
||||
config: WeixinSdkConfig,
|
||||
searchParams?: URLSearchParams,
|
||||
): Promise<WechatLoginResult | null> {
|
||||
const params = searchParams ?? new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
|
||||
const state = params.get('state');
|
||||
const saved = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (saved && state && saved !== state) {
|
||||
throw new Error('OAuth state 校验失败');
|
||||
}
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
|
||||
return loginWithWechatCode(config, code, 'h5');
|
||||
}
|
||||
|
||||
/** 微信首登绑定手机号 */
|
||||
export async function bindWechatPhone(
|
||||
config: WeixinSdkConfig,
|
||||
payload: { wxSessionKey: string; phone: string; code: string },
|
||||
): Promise<WechatLoginResult> {
|
||||
return apiRequest<WechatLoginResult>(config, '/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */
|
||||
export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise<string> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform !== 'mini') {
|
||||
throw new Error('H5 环境请使用短信验证码绑定手机号');
|
||||
}
|
||||
const phoneCode = await new Promise<string>((resolve, reject) => {
|
||||
if (!window.wx?.getPhoneNumber) {
|
||||
reject(new Error('getPhoneNumber 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.getPhoneNumber({
|
||||
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到手机号 code'))),
|
||||
fail: (err) => reject(new Error(err.errMsg || '获取手机号失败')),
|
||||
});
|
||||
});
|
||||
const data = await apiRequest<{ phone: string }>(config, '/common/wechat/phone-number', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: phoneCode, platform: 'mini' }),
|
||||
});
|
||||
return data.phone;
|
||||
}
|
||||
|
||||
/** 一键微信登录(自动选择 OAuth 或 wx.login) */
|
||||
export async function wechatLogin(config: WeixinSdkConfig): Promise<WechatLoginResult | void> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform === 'mini') {
|
||||
const code = await getMiniProgramLoginCode();
|
||||
return loginWithWechatCode(config, code, 'mini');
|
||||
}
|
||||
if (platform === 'wechat-h5') {
|
||||
await startWechatOAuthLogin(config);
|
||||
return;
|
||||
}
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||
import {
|
||||
ensureJssdkReady,
|
||||
getJssdkSignUrl,
|
||||
initWechatJssdk,
|
||||
normalizeJssdkPageUrl,
|
||||
resetJssdkConfig,
|
||||
} from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type ChooseWechatImageOptions = {
|
||||
count?: number;
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
};
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isChooseImageCancelMessage(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg.trim());
|
||||
}
|
||||
|
||||
/** 将微信 chooseImage / getLocalImgData fail 的 errMsg 转为用户可读文案 */
|
||||
export function formatChooseImageFailMessage(errMsg: string): string {
|
||||
const msg = errMsg.trim() || '无法打开相册';
|
||||
if (isChooseImageCancelMessage(msg)) return '';
|
||||
|
||||
if (/offline verifying|permission value is offline/i.test(msg)) {
|
||||
return '微信权限验证中,请稍候再试或刷新页面后重新选择图片';
|
||||
}
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败,请刷新页面后重试';
|
||||
}
|
||||
|
||||
if (/photo.*denied|photos.*denied|相册.*权限|无法访问相册|无相册权限/i.test(msg)) {
|
||||
return '相册权限未开启,请在 iPhone「设置 → 隐私与安全性 → 照片」中允许微信访问相册';
|
||||
}
|
||||
|
||||
if (/system.*camera|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) {
|
||||
return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头';
|
||||
}
|
||||
|
||||
if (/permission|auth|denied|授权|拒绝/i.test(msg)) {
|
||||
return `微信选图接口未就绪(${msg}),请刷新页面后重试`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function reportChooseImageEvent(
|
||||
config: WeixinSdkConfig,
|
||||
payload: {
|
||||
status: 'fail';
|
||||
errMsg: string;
|
||||
sourceType?: string;
|
||||
stage: 'jssdk' | 'choose' | 'read' | 'empty';
|
||||
},
|
||||
) {
|
||||
if (isChooseImageCancelMessage(payload.errMsg)) return;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': config.clientApp,
|
||||
};
|
||||
const token = config.getAccessToken?.();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
await fetch(`${config.apiBase ?? '/api/v1'}/common/wechat/choose-image`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
status: 'fail',
|
||||
errMsg: payload.errMsg,
|
||||
sourceType: payload.sourceType,
|
||||
stage: payload.stage,
|
||||
pageUrl: typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : undefined,
|
||||
signUrl: typeof window !== 'undefined' ? getJssdkSignUrl() : undefined,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* 上报失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
function base64ToFile(base64: string, fileName: string): File {
|
||||
const normalized = base64.startsWith('data:')
|
||||
? base64
|
||||
: `data:image/jpeg;base64,${base64.replace(/\s/g, '')}`;
|
||||
const [header, body] = normalized.split(',');
|
||||
const mime = header.match(/:(.*?);/)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(body);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new File([bytes], fileName, { type: mime });
|
||||
}
|
||||
|
||||
function localIdToFile(localId: string): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.wx?.getLocalImgData) {
|
||||
reject(new Error('微信 getLocalImgData 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.getLocalImgData({
|
||||
localId,
|
||||
success: (res) => {
|
||||
try {
|
||||
resolve(base64ToFile(res.localData, `wx-${Date.now()}.jpg`));
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error('图片解析失败'));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
const raw = err.errMsg || '读取图片失败';
|
||||
const formatted = formatChooseImageFailMessage(raw);
|
||||
reject(new Error(formatted || raw));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkJsApi(apiName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.wx?.checkJsApi) {
|
||||
resolve(!!window.wx?.chooseImage);
|
||||
return;
|
||||
}
|
||||
window.wx.checkJsApi({
|
||||
jsApiList: [apiName],
|
||||
success: (res) => resolve(!!res.checkResult?.[apiName]),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureChooseImageJssdk(config: WeixinSdkConfig): Promise<void> {
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
|
||||
await ensureJssdkReady(jssdkOptions);
|
||||
|
||||
let ready = await checkJsApi('chooseImage');
|
||||
if (!ready) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
ready = await checkJsApi('chooseImage');
|
||||
}
|
||||
if (!ready) {
|
||||
throw new Error('微信选图接口未授权,请刷新页面后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function isPermissionDeniedError(msg: string): boolean {
|
||||
return /permission|denied|invalid signature|config:fail|signature|接口未就绪|offline verifying/i.test(msg);
|
||||
}
|
||||
|
||||
function invokeChooseImage(
|
||||
count: number,
|
||||
sourceType: Array<'album' | 'camera'>,
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(hangTimer);
|
||||
fn();
|
||||
};
|
||||
|
||||
// 极端机型取消后无回调:最长等待后按取消解锁,避免业务侧 uploading 永久卡住
|
||||
const hangTimer = window.setTimeout(() => {
|
||||
finish(() => resolve([]));
|
||||
}, 180_000);
|
||||
|
||||
window.wx!.chooseImage!({
|
||||
count,
|
||||
sizeType: ['compressed'],
|
||||
sourceType,
|
||||
success: (res) => finish(() => resolve(res.localIds ?? [])),
|
||||
fail: (err) => {
|
||||
const raw = err.errMsg || '无法打开相册';
|
||||
if (isChooseImageCancelMessage(raw)) {
|
||||
finish(() => resolve([]));
|
||||
return;
|
||||
}
|
||||
const formatted = formatChooseImageFailMessage(raw);
|
||||
finish(() => reject(new Error(formatted || raw)));
|
||||
},
|
||||
// 部分微信版本取消只走 complete
|
||||
complete: () => {
|
||||
window.setTimeout(() => {
|
||||
finish(() => resolve([]));
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 微信内选图(相册/拍照),返回 File 列表;非微信环境返回 null */
|
||||
export async function chooseWechatImages(
|
||||
config: WeixinSdkConfig,
|
||||
options: ChooseWechatImageOptions = {},
|
||||
): Promise<File[] | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform === 'browser') return null;
|
||||
|
||||
const count = options.count ?? 1;
|
||||
const sourceType = options.sourceType ?? ['album', 'camera'];
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
const sourceTypeKey = sourceType.join(',');
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
try {
|
||||
await ensureChooseImageJssdk(config);
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : 'JSSDK 初始化失败';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' });
|
||||
throw e;
|
||||
}
|
||||
if (!window.wx?.chooseImage) {
|
||||
const errMsg = '微信选图接口不可用,请刷新页面后重试';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' });
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
if (isIosDevice() && !isWechatDevTools()) {
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
let localIds: string[] = [];
|
||||
const maxAttempts = 2;
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
if (lastError && isPermissionDeniedError(lastError.message)) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
}
|
||||
await delay(800);
|
||||
}
|
||||
try {
|
||||
localIds = await invokeChooseImage(count, sourceType);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e : new Error('无法打开相册');
|
||||
const raw = lastError.message;
|
||||
const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw) || isPermissionDeniedError(raw);
|
||||
if (attempt < maxAttempts - 1 && retryable) continue;
|
||||
void reportChooseImageEvent(config, {
|
||||
status: 'fail',
|
||||
errMsg: raw,
|
||||
sourceType: sourceTypeKey,
|
||||
stage: 'choose',
|
||||
});
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
|
||||
if (!localIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: File[] = [];
|
||||
for (const localId of localIds) {
|
||||
try {
|
||||
files.push(await localIdToFile(localId));
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : '读取图片失败';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'read' });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canUseWechatChooseImage(): boolean {
|
||||
return getRuntimePlatform() !== 'browser';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** 是否 iOS 设备(不含微信开发者工具模拟器特殊处理) */
|
||||
export function isIosDevice(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as Window & { MSStream?: unknown }).MSStream;
|
||||
}
|
||||
|
||||
/** 是否微信开发者工具 */
|
||||
export function isWechatDevTools(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /wechatdevtools/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/** 是否微信内置浏览器 */
|
||||
export function isWechatBrowser(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/** 是否微信小程序 web-view 或独立小程序环境 */
|
||||
export function isMiniProgram(): boolean {
|
||||
const g = globalThis as typeof globalThis & { wx?: { requestPayment?: unknown } };
|
||||
if (g.wx?.requestPayment) return true;
|
||||
if (typeof window === 'undefined') return false;
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
return ua.includes('miniprogram') || (window as Window & { __wxjs_environment?: string }).__wxjs_environment === 'miniprogram';
|
||||
}
|
||||
|
||||
export function getRuntimePlatform(): 'mini' | 'wechat-h5' | 'browser' {
|
||||
if (isMiniProgram()) return 'mini';
|
||||
if (isWechatBrowser()) return 'wechat-h5';
|
||||
return 'browser';
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
||||
export { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
|
||||
export {
|
||||
initWechatJssdk,
|
||||
ensureJssdkReady,
|
||||
isJssdkReady,
|
||||
normalizeJssdkPageUrl,
|
||||
getJssdkSignUrl,
|
||||
captureIosJssdkEntryUrl,
|
||||
resetJssdkConfig,
|
||||
stripOAuthParamsFromLocation,
|
||||
} from './jssdk';
|
||||
export { formatScanFailMessage } from './scan';
|
||||
export {
|
||||
getWechatLocation,
|
||||
getWechatLocationDetailed,
|
||||
canUseWechatLocation,
|
||||
isWechatEnv,
|
||||
} from './location';
|
||||
export type { WechatLocationOutcome } from './location';
|
||||
export { scanQrCode } from './scan';
|
||||
export { invokeWechatPay } from './pay';
|
||||
export { chooseWechatImages, canUseWechatChooseImage, formatChooseImageFailMessage } from './chooseImage';
|
||||
export type { ChooseWechatImageOptions } from './chooseImage';
|
||||
export {
|
||||
setWechatShareData,
|
||||
canUseWechatShare,
|
||||
getWechatShareLink,
|
||||
shareViaWechatSdk,
|
||||
tryInvokeSharePanel,
|
||||
} from './share';
|
||||
export type { WechatShareData } from './share';
|
||||
export {
|
||||
getWechatOAuthUrl,
|
||||
startWechatOAuthLogin,
|
||||
getMiniProgramLoginCode,
|
||||
loginWithWechatCode,
|
||||
handleWechatOAuthCallback,
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
wechatLogin,
|
||||
WECHAT_INAPP_REQUIRED_MSG,
|
||||
} from './auth';
|
||||
export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
|
||||
export { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
import { initWechatJssdk, resetJssdkConfig } from './jssdk';
|
||||
import { getWechatLocation, getWechatLocationDetailed } from './location';
|
||||
import { scanQrCode } from './scan';
|
||||
import { invokeWechatPay } from './pay';
|
||||
import { chooseWechatImages } from './chooseImage';
|
||||
import { setWechatShareData, shareViaWechatSdk } from './share';
|
||||
import {
|
||||
wechatLogin,
|
||||
handleWechatOAuthCallback,
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
} from './auth';
|
||||
|
||||
/** 微信 SDK 门面(按端注入 apiBase / clientApp) */
|
||||
export function createWeixinSdk(config: WeixinSdkConfig) {
|
||||
return {
|
||||
init: () => initWechatJssdk({ ...config, apiBase: config.apiBase ?? '/api/v1' }),
|
||||
reset: () => resetJssdkConfig(),
|
||||
login: () => wechatLogin(config),
|
||||
handleOAuthCallback: (params?: URLSearchParams) => handleWechatOAuthCallback(config, params),
|
||||
bindPhone: (payload: { wxSessionKey: string; phone: string; code: string }) =>
|
||||
bindWechatPhone(config, payload),
|
||||
getPhoneNumber: () => getWechatPhoneNumber(config),
|
||||
getLocation: () => getWechatLocation(config),
|
||||
getLocationDetailed: () => getWechatLocationDetailed(config),
|
||||
scanQrCode: (options?: Parameters<typeof scanQrCode>[1]) => scanQrCode(config, options),
|
||||
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
||||
chooseWechatImages(config, options),
|
||||
pay: (prepay: Parameters<typeof invokeWechatPay>[0]) =>
|
||||
invokeWechatPay(prepay, {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
}),
|
||||
setShare: (data: Parameters<typeof setWechatShareData>[1]) =>
|
||||
setWechatShareData(config, data),
|
||||
share: (data: Parameters<typeof shareViaWechatSdk>[1]) => shareViaWechatSdk(config, data),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
||||
import { isWechatBrowser, isWechatDevTools } from './env';
|
||||
import { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let configured = false;
|
||||
let configuredUrl: string | null = null;
|
||||
|
||||
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
|
||||
export function resetJssdkConfig(): void {
|
||||
configured = false;
|
||||
configuredUrl = null;
|
||||
}
|
||||
|
||||
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
|
||||
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
url.hash = '';
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
const noHash = rawUrl.split('#')[0];
|
||||
try {
|
||||
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
return noHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function signUrlChanged(prev: string | null, current: string): boolean {
|
||||
return prev !== current;
|
||||
}
|
||||
|
||||
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isWechatBrowser() || isWechatDevTools()) return;
|
||||
const current = normalizeJssdkPageUrl(window.location.href);
|
||||
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
|
||||
if (!existing) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
return;
|
||||
}
|
||||
if (signUrlChanged(existing, current)) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
resetJssdkConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */
|
||||
export function getJssdkSignUrl(rawUrl?: string): string {
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
|
||||
}
|
||||
|
||||
function isJssdkDebugEnabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('wxdebug') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function stripOAuthParamsFromLocation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
const next = `${url.pathname}${query ? `?${query}` : ''}${url.hash}`;
|
||||
window.history.replaceState({}, '', next);
|
||||
}
|
||||
|
||||
function loadScript(): Promise<void> {
|
||||
if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境'));
|
||||
if (window.wx) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[data-dukang-wx-jssdk]');
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败')));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = JSSDK_URL;
|
||||
script.async = true;
|
||||
script.dataset.dukangWxJssdk = '1';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('微信 JSSDK 加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) {
|
||||
const headers: Record<string, string> = { 'X-Client-App': clientApp };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const qs = new URLSearchParams({ url });
|
||||
const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败');
|
||||
return json.data as WechatJssdkConfig;
|
||||
}
|
||||
|
||||
/** 初始化微信 JSSDK(H5 公众号内) */
|
||||
export async function initWechatJssdk(options: {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
url?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const { apiBase, clientApp, getAccessToken } = options;
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
await loadScript();
|
||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||
|
||||
const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.());
|
||||
const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST];
|
||||
|
||||
configured = false;
|
||||
configuredUrl = null;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
window.wx!.config({
|
||||
...config,
|
||||
jsApiList,
|
||||
debug: isJssdkDebugEnabled(),
|
||||
});
|
||||
window.wx!.ready(() => {
|
||||
configured = true;
|
||||
configuredUrl = pageUrl;
|
||||
resolve();
|
||||
});
|
||||
window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败')));
|
||||
});
|
||||
}
|
||||
|
||||
export function isJssdkReady(): boolean {
|
||||
const signUrl = getJssdkSignUrl();
|
||||
return configured && !!window.wx && configuredUrl === signUrl;
|
||||
}
|
||||
|
||||
export async function ensureJssdkReady(options: {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
url?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
|
||||
resetJssdkConfig();
|
||||
}
|
||||
if (!isJssdkReady()) {
|
||||
await initWechatJssdk({ ...options, url: pageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
captureIosJssdkEntryUrl();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { WechatGpsLocation } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type WechatLocationOutcome = {
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
location: WechatGpsLocation | null;
|
||||
errMsg?: string;
|
||||
};
|
||||
|
||||
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
||||
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
|
||||
const outcome = await getWechatLocationDetailed(config);
|
||||
return outcome.location;
|
||||
}
|
||||
|
||||
export async function getWechatLocationDetailed(
|
||||
config?: WeixinSdkConfig,
|
||||
): Promise<WechatLocationOutcome> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.getLocation) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5' && config) {
|
||||
try {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
if (window.wx?.getLocation) {
|
||||
const jsApiOk = await new Promise<boolean>((resolve) => {
|
||||
window.wx!.checkJsApi?.({
|
||||
jsApiList: ['getLocation'],
|
||||
success: (res) => resolve(!!res.checkResult?.getLocation),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!jsApiOk) {
|
||||
return { sdk: 'jssdk', location: null, errMsg: 'getLocation 未授权或不可用' };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||
});
|
||||
});
|
||||
}
|
||||
return { sdk: 'jssdk', location: null, errMsg: '微信 JSSDK getLocation 不可用' };
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return { sdk: 'jssdk', location: null, errMsg };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
return { sdk: 'geolocation', location: null, errMsg: '浏览器不支持定位' };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({
|
||||
sdk: 'geolocation',
|
||||
location: {
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
},
|
||||
}),
|
||||
(err) =>
|
||||
resolve({
|
||||
sdk: 'geolocation',
|
||||
location: null,
|
||||
errMsg: err.message || '定位失败',
|
||||
}),
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function canUseWechatLocation(): boolean {
|
||||
return getRuntimePlatform() !== 'browser' || typeof navigator?.geolocation !== 'undefined';
|
||||
}
|
||||
|
||||
export function isWechatEnv(): boolean {
|
||||
return isWechatBrowser() || getRuntimePlatform() === 'mini';
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { MiniProgramWx } from './types';
|
||||
|
||||
export type WechatPayInvokeConfig = {
|
||||
apiBase?: string;
|
||||
clientApp?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
/** 显式指定运行端;Taro weapp 须传 mini */
|
||||
platform?: 'mini' | 'wechat-h5' | 'browser';
|
||||
};
|
||||
|
||||
/** wx.chooseWXPay 要求 timestamp 小写;服务端签名与 Bridge 使用 timeStamp */
|
||||
function toChooseWxPayOptions(prepay: WechatJsapiPrepayParams) {
|
||||
return {
|
||||
timestamp: prepay.timeStamp,
|
||||
nonceStr: prepay.nonceStr,
|
||||
package: prepay.package,
|
||||
signType: prepay.signType,
|
||||
paySign: prepay.paySign,
|
||||
};
|
||||
}
|
||||
|
||||
function getMiniProgramWx(): MiniProgramWx | undefined {
|
||||
const g = globalThis as typeof globalThis & { wx?: MiniProgramWx };
|
||||
if (g.wx?.requestPayment) return g.wx;
|
||||
if (typeof window !== 'undefined' && window.wx?.requestPayment) return window.wx;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function invokeBridgePay(params: WechatJsapiPrepayParams): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bridge = window.WeixinJSBridge;
|
||||
if (!bridge) {
|
||||
reject(new Error('WeixinJSBridge 不可用'));
|
||||
return;
|
||||
}
|
||||
bridge.invoke(
|
||||
'getBrandWCPayRequest',
|
||||
{
|
||||
appId: params.appId,
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign,
|
||||
},
|
||||
(res) => {
|
||||
const msg = res.err_msg ?? '';
|
||||
if (msg.includes('ok')) resolve();
|
||||
else if (msg.includes('cancel')) reject(new Error('用户取消支付'));
|
||||
else reject(new Error(msg || '支付失败'));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWeixinBridge(): Promise<void> {
|
||||
if (window.WeixinJSBridge) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (window.WeixinJSBridge) resolve();
|
||||
else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function invokeMiniProgramPay(prepay: WechatJsapiPrepayParams): Promise<void> {
|
||||
const wxApi = getMiniProgramWx();
|
||||
if (!wxApi?.requestPayment) {
|
||||
return Promise.reject(new Error('当前环境不支持微信支付'));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
wxApi.requestPayment!({
|
||||
timeStamp: prepay.timeStamp,
|
||||
nonceStr: prepay.nonceStr,
|
||||
package: prepay.package,
|
||||
signType: prepay.signType,
|
||||
paySign: prepay.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (err) => {
|
||||
const msg = err.errMsg || '支付失败';
|
||||
if (msg.includes('cancel')) reject(new Error('用户取消支付'));
|
||||
else reject(new Error(msg));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 调起微信支付 */
|
||||
export async function invokeWechatPay(
|
||||
prepay: WechatJsapiPrepayParams,
|
||||
config?: WechatPayInvokeConfig,
|
||||
): Promise<void> {
|
||||
const platform = config?.platform ?? getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini') {
|
||||
return invokeMiniProgramPay(prepay);
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
if (config?.clientApp) {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
url: typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined,
|
||||
jsApiList: ['chooseWXPay'],
|
||||
});
|
||||
}
|
||||
|
||||
// Bridge 参数与商户签名一致,优先于 chooseWXPay
|
||||
await waitForWeixinBridge();
|
||||
if (window.WeixinJSBridge) {
|
||||
return invokeBridgePay(prepay);
|
||||
}
|
||||
|
||||
if (window.wx?.chooseWXPay) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.chooseWXPay!({
|
||||
...toChooseWxPayOptions(prepay),
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(new Error(err.errMsg || '支付失败')),
|
||||
cancel: () => reject(new Error('用户取消支付')),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('当前环境不支持微信支付');
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||
import {
|
||||
ensureJssdkReady,
|
||||
getJssdkSignUrl,
|
||||
initWechatJssdk,
|
||||
resetJssdkConfig,
|
||||
} from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 将微信 scanQRCode fail 的 errMsg 转为用户可读文案(避免误判为系统相机权限) */
|
||||
export function formatScanFailMessage(errMsg: string): string {
|
||||
const msg = errMsg.trim() || '扫码失败';
|
||||
if (/cancel/i.test(msg)) return '';
|
||||
|
||||
// iOS / 授权回调后常见:JSSDK 权限离线校验尚未完成,并非系统相机权限
|
||||
if (/offline verifying|permission value is offline/i.test(msg)) {
|
||||
return '微信权限验证中,请稍候再试或刷新页面后重新扫码';
|
||||
}
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败,请刷新页面后重试';
|
||||
}
|
||||
|
||||
// 系统级相机权限(iPhone 设置 → 隐私 → 相机 → 微信)
|
||||
if (/system.*camera|NotAllowedError|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) {
|
||||
return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头';
|
||||
}
|
||||
|
||||
// JSSDK 接口授权失败(含 permission,但不等于相机权限)
|
||||
if (/permission|auth|denied|授权|拒绝/i.test(msg)) {
|
||||
return `微信扫码接口未就绪(${msg}),请刷新页面后重试`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
function isScanPermissionWarmupError(msg: string): boolean {
|
||||
return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
|
||||
msg,
|
||||
);
|
||||
}
|
||||
|
||||
function checkJsApi(apiName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.wx?.checkJsApi) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
window.wx.checkJsApi({
|
||||
jsApiList: [apiName],
|
||||
success: (res) => resolve(!!res.checkResult?.[apiName]),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureScanJssdk(config: WeixinSdkConfig): Promise<void> {
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['scanQRCode', 'checkJsApi'],
|
||||
};
|
||||
|
||||
await ensureJssdkReady(jssdkOptions);
|
||||
|
||||
let ready = await checkJsApi('scanQRCode');
|
||||
if (!ready) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
ready = await checkJsApi('scanQRCode');
|
||||
}
|
||||
if (!ready) {
|
||||
throw new Error('微信扫码接口未授权,请刷新页面后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function invokeScanQrCode(): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.scanQRCode!({
|
||||
needResult: 1,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => resolve(res.resultStr || null),
|
||||
fail: (res) => {
|
||||
const msg = res.errMsg || '扫码失败';
|
||||
if (/cancel/i.test(msg)) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const formatted = formatScanFailMessage(msg);
|
||||
reject(new Error(formatted || msg));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type ScanQrCodeOptions = {
|
||||
/** OAuth / 首次绑定后权限离线校验更慢,加长预热 */
|
||||
postAuthWarmup?: boolean;
|
||||
};
|
||||
|
||||
/** 调起扫码(返回二维码/条码内容,取消返回 null) */
|
||||
export async function scanQrCode(
|
||||
config: WeixinSdkConfig,
|
||||
options: ScanQrCodeOptions = {},
|
||||
): Promise<string | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.scanCode) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.scanCode!({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => resolve(res.result || null),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['scanQRCode', 'checkJsApi'],
|
||||
};
|
||||
|
||||
await ensureScanJssdk(config);
|
||||
if (!window.wx?.scanQRCode) {
|
||||
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
||||
}
|
||||
|
||||
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显
|
||||
const warmupMs = options.postAuthWarmup
|
||||
? 1200
|
||||
: isIosDevice() && !isWechatDevTools()
|
||||
? 800
|
||||
: 300;
|
||||
await delay(warmupMs);
|
||||
|
||||
const maxAttempts = options.postAuthWarmup ? 3 : 2;
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
if (lastError && isScanPermissionWarmupError(lastError.message)) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
await checkJsApi('scanQRCode');
|
||||
}
|
||||
await delay(700 + attempt * 500);
|
||||
}
|
||||
try {
|
||||
return await invokeScanQrCode();
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e : new Error('扫码失败');
|
||||
const retryable = isScanPermissionWarmupError(lastError.message);
|
||||
if (attempt < maxAttempts - 1 && retryable) continue;
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('扫码失败');
|
||||
}
|
||||
|
||||
throw new Error('请在微信内打开以使用扫码核销');
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type WechatShareData = {
|
||||
title: string;
|
||||
desc: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
};
|
||||
|
||||
export function canUseWechatShare(): boolean {
|
||||
return isWechatBrowser() || getRuntimePlatform() === 'mini';
|
||||
}
|
||||
|
||||
function applyShareData(data: WechatShareData): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wx = window.wx as
|
||||
| (NonNullable<typeof window.wx> & {
|
||||
onMenuShareAppMessage?: (opts: Record<string, unknown>) => void;
|
||||
onMenuShareTimeline?: (opts: Record<string, unknown>) => void;
|
||||
invoke?: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb?: (res: { err_msg?: string; errMsg?: string }) => void,
|
||||
) => void;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
if (!wx) {
|
||||
reject(new Error('微信 JSSDK 不可用'));
|
||||
return;
|
||||
}
|
||||
|
||||
let pending = 0;
|
||||
let failed = false;
|
||||
const done = (err?: Error) => {
|
||||
if (err && !failed) {
|
||||
failed = true;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
pending -= 1;
|
||||
if (pending === 0 && !failed) resolve();
|
||||
};
|
||||
|
||||
const payload = {
|
||||
title: data.title,
|
||||
desc: data.desc,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
};
|
||||
|
||||
if (wx.updateAppMessageShareData) {
|
||||
pending += 1;
|
||||
wx.updateAppMessageShareData({
|
||||
...payload,
|
||||
success: () => done(),
|
||||
fail: (res) => done(new Error(res.errMsg || '设置分享给朋友失败')),
|
||||
});
|
||||
}
|
||||
|
||||
if (wx.updateTimelineShareData) {
|
||||
pending += 1;
|
||||
wx.updateTimelineShareData({
|
||||
title: data.title,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
success: () => done(),
|
||||
fail: (res) => done(new Error(res.errMsg || '设置分享到朋友圈失败')),
|
||||
});
|
||||
}
|
||||
|
||||
// 兼容旧微信:同时挂旧版菜单分享(设置即生效,无 success 回调也可继续)
|
||||
if (wx.onMenuShareAppMessage) {
|
||||
wx.onMenuShareAppMessage({
|
||||
...payload,
|
||||
success: () => {},
|
||||
cancel: () => {},
|
||||
});
|
||||
}
|
||||
if (wx.onMenuShareTimeline) {
|
||||
wx.onMenuShareTimeline({
|
||||
title: data.title,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
success: () => {},
|
||||
cancel: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
if (pending === 0) {
|
||||
if (wx.onMenuShareAppMessage || wx.onMenuShareTimeline) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error('当前微信版本不支持分享,请升级微信后重试'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 尽量调起系统分享面板(部分微信版本支持;失败则仅完成卡片配置) */
|
||||
export function tryInvokeSharePanel(data: WechatShareData): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const wx = window.wx as
|
||||
| {
|
||||
invoke?: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb?: (res: { err_msg?: string; errMsg?: string }) => void,
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
if (!wx?.invoke) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wx.invoke(
|
||||
'shareAppMessage',
|
||||
{
|
||||
title: data.title,
|
||||
desc: data.desc,
|
||||
link: data.link,
|
||||
img_url: data.imgUrl,
|
||||
imgUrl: data.imgUrl,
|
||||
},
|
||||
(res) => {
|
||||
const msg = `${res?.err_msg || res?.errMsg || ''}`;
|
||||
resolve(/:ok\b/i.test(msg));
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 配置微信内分享卡片(好友 / 朋友圈) */
|
||||
export async function setWechatShareData(
|
||||
config: WeixinSdkConfig,
|
||||
data: WechatShareData,
|
||||
): Promise<void> {
|
||||
if (!canUseWechatShare()) return;
|
||||
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
|
||||
await applyShareData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置分享并尝试调起分享面板。
|
||||
* @returns invoked=true 表示已弹出系统分享;否则需引导用户点右上角
|
||||
*/
|
||||
export async function shareViaWechatSdk(
|
||||
config: WeixinSdkConfig,
|
||||
data: WechatShareData,
|
||||
): Promise<{ invoked: boolean }> {
|
||||
await setWechatShareData(config, data);
|
||||
const invoked = await tryInvokeSharePanel(data);
|
||||
return { invoked };
|
||||
}
|
||||
|
||||
/** 获取当前页分享链接(去掉 hash、OAuth 回跳参数) */
|
||||
export function getWechatShareLink(rawUrl?: string): string {
|
||||
if (typeof window === 'undefined') return rawUrl ?? '';
|
||||
return normalizeJssdkPageUrl(rawUrl ?? window.location.href);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { WechatGpsLocation, WechatJsapiPrepayParams, WechatJssdkConfig } from '@dukang/shared-types';
|
||||
|
||||
export type WeixinSdkConfig = {
|
||||
/** API 根路径,默认 /api/v1 */
|
||||
apiBase?: string;
|
||||
/** X-Client-App 请求头 */
|
||||
clientApp: string;
|
||||
/** 获取 access token(可选,登录后自动带) */
|
||||
getAccessToken?: () => string | null;
|
||||
/** 微信 code 登录接口,默认 /auth/login/wechat */
|
||||
wechatLoginPath?: string;
|
||||
};
|
||||
|
||||
export type WxInvokeResult<T> = {
|
||||
errMsg: string;
|
||||
} & T;
|
||||
|
||||
export type WxApi = {
|
||||
config: (options: WechatJssdkConfig & { debug?: boolean }) => void;
|
||||
ready: (cb: () => void) => void;
|
||||
error: (cb: (res: { errMsg: string }) => void) => void;
|
||||
checkJsApi: (options: {
|
||||
jsApiList: string[];
|
||||
success?: (res: { checkResult?: Record<string, boolean> }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getLocation: (options: {
|
||||
type?: string;
|
||||
success?: (res: WechatGpsLocation) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
scanQRCode: (options: {
|
||||
needResult?: 0 | 1;
|
||||
scanType?: Array<'qrCode' | 'barCode'>;
|
||||
success?: (res: { resultStr: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
chooseWXPay: (options: {
|
||||
timestamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
cancel?: () => void;
|
||||
}) => void;
|
||||
chooseImage: (options: {
|
||||
count?: number;
|
||||
sizeType?: Array<'original' | 'compressed'>;
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
success?: (res: { localIds: string[] }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
complete?: (res?: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
getLocalImgData: (options: {
|
||||
localId: string;
|
||||
success?: (res: { localData: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
updateAppMessageShareData: (options: {
|
||||
title: string;
|
||||
desc: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
updateTimelineShareData: (options: {
|
||||
title: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type MiniProgramWx = {
|
||||
login: (options: {
|
||||
success?: (res: { code: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getPhoneNumber: (options: {
|
||||
success?: (res: { code: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getLocation: WxApi['getLocation'];
|
||||
scanCode: (options: {
|
||||
onlyFromCamera?: boolean;
|
||||
scanType?: Array<'qrCode' | 'barCode'>;
|
||||
success?: (res: { result: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
requestPayment: (options: {
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wx?: WxApi & MiniProgramWx;
|
||||
WeixinJSBridge?: {
|
||||
invoke: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb: (res: { err_msg?: string }) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_JS_API_LIST = [
|
||||
'getLocation',
|
||||
'scanQRCode',
|
||||
'chooseWXPay',
|
||||
'chooseImage',
|
||||
'getLocalImgData',
|
||||
'updateAppMessageShareData',
|
||||
'updateTimelineShareData',
|
||||
'onMenuShareAppMessage',
|
||||
'onMenuShareTimeline',
|
||||
] as const;
|
||||
Reference in New Issue
Block a user