const SAVE_IMAGE_TIMEOUT_MS = 15000 /** * 保存图片到系统相册,APP 端优先走下载/本地文件链路,避免云打包后 getImageInfo 卡住。 */ export const saveImage = (url) => { const normalizedUrl = normalizeOptionalText(url) if (!normalizedUrl) { uni.showToast({ title: '图片地址为空', icon: 'none' }) return } uni.showLoading({ title: '正在保存图片...' }) // #ifdef APP-PLUS saveImageInApp(normalizedUrl) return // #endif saveImageWithAlbumAuth(normalizedUrl) } /** * APP 环境保存图片,远程图片先下载,base64 图片先写入本地临时文件。 */ const saveImageInApp = async (url) => { try { const filePath = await resolveAppImageFilePath(url) await withTimeout(saveImageToAlbum(filePath), '保存超时') showSaveImageSuccess() } catch (error) { showSaveImageFailure(error) } } /** * 非 APP 环境按原授权流程保存图片。 */ const saveImageWithAlbumAuth = (url) => { if (url.startsWith('data:image/')) { uni.hideLoading() uni.showToast({ title: '当前图片暂不支持直接保存,请稍后重试!', icon: 'none' }) return } uni.getSetting({ success: async (res) => { if (!res.authSetting['scope.writePhotosAlbum']) { uni.authorize({ scope: 'scope.writePhotosAlbum', success: async () => { await saveImageFile(url) }, fail: () => { uni.hideLoading() uni.showModal({ title: '权限未授权', content: '请前往设置中开启相册权限', success: (res) => { if (res.confirm) { uni.openSetting() } } }) } }) } else { await saveImageFile(url) } }, fail: () => { uni.hideLoading() uni.showToast({ title: '获取权限失败!', icon: 'error' }) } }) } /** * 根据图片类型解析 APP 端可保存的本地文件路径。 */ const resolveAppImageFilePath = async (url) => { if (url.startsWith('data:image/')) { return writeBase64ImageToAppFile(url) } if (/^https?:\/\//i.test(url)) { return downloadImageToTempFile(url) } return url } /** * 下载远程图片到临时文件,避免 APP 端 getImageInfo 不回调导致 loading 卡住。 */ const downloadImageToTempFile = (url) => { return withTimeout(new Promise((resolve, reject) => { uni.downloadFile({ url, success: (result) => { if (result.statusCode !== 200 || !result.tempFilePath) { reject(new Error('二维码下载失败')) return } resolve(result.tempFilePath) }, fail: () => reject(new Error('二维码下载失败')) }) }), '二维码下载超时') } /** * 将 dataURL 图片写入 APP 私有临时文件。 */ const writeBase64ImageToAppFile = (dataUrl) => { return withTimeout(new Promise((resolve, reject) => { const matchedData = dataUrl.match(/^data:image\/(png|jpeg|jpg);base64,(.+)$/i) if (!matchedData) { reject(new Error('二维码图片格式不正确')) return } // #ifdef APP-PLUS const extension = matchedData[1].toLowerCase() === 'jpg' ? 'jpg' : matchedData[1].toLowerCase() const fileName = `share-qrcode-${Date.now()}.${extension}` const bitmap = new plus.nativeObj.Bitmap(fileName) bitmap.loadBase64Data(dataUrl, () => { const filePath = `_doc/${fileName}` bitmap.save(filePath, {}, () => { bitmap.clear() resolve(filePath) }, () => { bitmap.clear() reject(new Error('二维码图片写入失败')) }) }, () => { bitmap.clear() reject(new Error('二维码图片写入失败')) }) // #endif // #ifndef APP-PLUS reject(new Error('当前环境暂不支持保存该图片')) // #endif }), '二维码图片写入超时') } /** * 保存本地图片文件到系统相册。 */ const saveImageToAlbum = (filePath) => { return new Promise((resolve, reject) => { uni.saveImageToPhotosAlbum({ filePath, success: resolve, fail: () => reject(new Error('保存失败,请检查相册权限')) }) }) } /** * 下载图片并写入系统相册。 */ const saveImageFile = async (url) => { try { const imageInfo = await withTimeout(uni.getImageInfo({ src: url }), '图片加载超时') await withTimeout(saveImageToAlbum(imageInfo.path), '保存超时') showSaveImageSuccess() } catch (error) { showSaveImageFailure(error) } } /** * 为异步保存步骤增加超时保护,避免云打包后回调丢失导致 loading 常驻。 */ const withTimeout = (promise, message) => { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(message || '操作超时')) }, SAVE_IMAGE_TIMEOUT_MS) Promise.resolve(promise) .then((value) => { clearTimeout(timer) resolve(value) }) .catch((error) => { clearTimeout(timer) reject(error) }) }) } /** * 展示保存成功状态。 */ const showSaveImageSuccess = () => { uni.hideLoading() uni.showToast({ title: '保存成功!' }) } /** * 展示保存失败状态,确保任何失败路径都会关闭 loading。 */ const showSaveImageFailure = (error) => { uni.hideLoading() uni.showToast({ title: normalizeOptionalText(error && error.message) || '保存失败,请重试!', icon: 'none' }) } /** * 将可选文本归一化为空字符串。 */ export const normalizeOptionalText = (value) => { return typeof value === 'string' ? value.trim() : '' } /** * 将外部唤醒参数归一化为可解析文本,兼容 openinstall 对象、JSON 和 URL。 */ export const normalizeInvitePayloadText = (value) => { if (!value) { return '' } if (typeof value === 'string') { return value.trim() } if (typeof value !== 'object') { return '' } const candidateKeys = ['bindData', 'data', 'channelData', 'params', 'wakeUpData'] for (const key of candidateKeys) { const nestedValue = value[key] if (nestedValue) { const nestedText = normalizeInvitePayloadText(nestedValue) if (nestedText) { return nestedText } } } try { return JSON.stringify(value) } catch { return '' } } /** * 从 URL、纯文本、对象或扫码内容中提取 6 位邀请码。 */ export const extractInviteCode = (value) => { const directInviteCode = extractObjectFieldFromAny(value, 'inviteCode') if (directInviteCode) { return normalizeInviteCodeText(directInviteCode) } const normalizedValue = normalizeInvitePayloadText(value) if (!normalizedValue) { return '' } const objectInviteCode = extractObjectField(normalizedValue, 'inviteCode') if (objectInviteCode) { return normalizeInviteCodeText(objectInviteCode) } const queryMatchedInviteCode = normalizedValue.match(/(?:^|[?&])inviteCode=([^&#]+)/i) if (queryMatchedInviteCode && queryMatchedInviteCode[1]) { return normalizeInviteCodeText(decodeURIComponent(queryMatchedInviteCode[1])) } return normalizeInviteCodeText(normalizedValue) } /** * 从 URL、对象或 JSON 文本中提取 bindTicket 参数。 */ export const extractBindTicket = (value) => { const normalizedValue = normalizeInvitePayloadText(value) if (!normalizedValue) { return '' } const objectBindTicket = extractObjectField(normalizedValue, 'bindTicket') if (objectBindTicket) { return objectBindTicket.trim() } const queryMatchedBindTicket = normalizedValue.match(/(?:^|[?&])bindTicket=([^&#]+)/i) if (!queryMatchedBindTicket || !queryMatchedBindTicket[1]) { return '' } return decodeURIComponent(queryMatchedBindTicket[1]).trim() } /** * 从对象或 JSON 字符串中读取指定字段。 */ const extractObjectFieldFromAny = (value, fieldName) => { if (!value) { return '' } if (typeof value === 'string') { return extractObjectField(value, fieldName) } if (typeof value !== 'object') { return '' } const directValue = value[fieldName] if (typeof directValue === 'string') { return directValue } const candidateKeys = ['bindData', 'data', 'channelData', 'params', 'wakeUpData'] for (const key of candidateKeys) { const nestedValue = value[key] if (!nestedValue || nestedValue === value) { continue } const nestedFieldValue = extractObjectFieldFromAny(nestedValue, fieldName) if (nestedFieldValue) { return nestedFieldValue } } return '' } /** * 从 JSON 字符串中读取指定字段。 */ const extractObjectField = (value, fieldName) => { try { return extractObjectFieldFromAny(JSON.parse(value), fieldName) } catch { return '' } } /** * 标准化邀请码文本。 */ const normalizeInviteCodeText = (value) => { const inviteCode = normalizeOptionalText(value).toUpperCase() return /^[A-Z0-9]{6}$/.test(inviteCode) ? inviteCode : '' }