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 : ''
|
}
|