feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
-25
View File
@@ -1,25 +0,0 @@
/// <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}/`);
}
-149
View File
@@ -1,149 +0,0 @@
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);
}
-292
View File
@@ -1,292 +0,0 @@
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';
}
-32
View File
@@ -1,32 +0,0 @@
/** 是否 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';
}
-86
View File
@@ -1,86 +0,0 @@
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),
};
}
-178
View File
@@ -1,178 +0,0 @@
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;
}
/** 初始化微信 JSSDKH5 公众号内) */
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();
}
-97
View File
@@ -1,97 +0,0 @@
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';
}
-131
View File
@@ -1,131 +0,0 @@
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('当前环境不支持微信支付');
}
-169
View File
@@ -1,169 +0,0 @@
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('请在微信内打开以使用扫码核销');
}
-172
View File
@@ -1,172 +0,0 @@
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);
}
-128
View File
@@ -1,128 +0,0 @@
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;