076455944e
录店页 ?step= 变化时重新 wx.config,微信内上传仅走 chooseImage 接口。 Co-authored-by: Cursor <cursoragent@cursor.com>
270 lines
8.4 KiB
TypeScript
270 lines
8.4 KiB
TypeScript
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));
|
|
}
|
|
|
|
/** 将微信 chooseImage / getLocalImgData fail 的 errMsg 转为用户可读文案 */
|
|
export function formatChooseImageFailMessage(errMsg: string): string {
|
|
const msg = errMsg.trim() || '无法打开相册';
|
|
if (/cancel/i.test(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 (/cancel/i.test(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) => {
|
|
window.wx!.chooseImage!({
|
|
count,
|
|
sizeType: ['compressed'],
|
|
sourceType,
|
|
success: (res) => resolve(res.localIds ?? []),
|
|
fail: (err) => {
|
|
const raw = err.errMsg || '无法打开相册';
|
|
if (/cancel/i.test(raw)) {
|
|
resolve([]);
|
|
return;
|
|
}
|
|
const formatted = formatChooseImageFailMessage(raw);
|
|
reject(new Error(formatted || raw));
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 微信内选图(相册/拍照),返回 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';
|
|
}
|