hejianhao
2026-08-28 16a57a8fc6868cea9171a1119698572407a2fc03
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
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 : ''
}