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 { 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 = { '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 { 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 { 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 { 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 { 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 { 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'; }