| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605 |
- // ---- 图标本地缓存(App 端文件系统 + H5 端 base64/uni.Storage) ----
- const CACHE_MAP_KEY = 'app_icon_cache_map'
- const APP_ICON_DIR_NAME = 'app-icons'
- const APP_ICON_CACHE_DIR = '_doc/' + APP_ICON_DIR_NAME
- const H5_ICON_MAX_SIZE = 128
- const H5_ICON_WEBP_QUALITY = 0.82
- const H5_RECOMPRESS_MIN_LENGTH = 32 * 1024
- const pendingIconDownloads = new Map()
- function getCacheRuntime() {
- let runtime = 'other'
- // #ifdef H5
- runtime = 'h5'
- // #endif
- // #ifdef APP-PLUS
- runtime = 'app'
- // #endif
- return runtime
- }
- function isH5CacheEnabled() {
- return getCacheRuntime() === 'h5'
- }
- function isAppCacheEnabled() {
- return getCacheRuntime() === 'app'
- }
- function getIconCacheMap() {
- try {
- const raw = uni.getStorageSync(CACHE_MAP_KEY)
- return (raw && typeof raw === 'object') ? raw : {}
- } catch (e) { return {} }
- }
- function setIconCacheMap(map) {
- try { uni.setStorageSync(CACHE_MAP_KEY, map || {}) } catch (e) {}
- }
- function isAppLocalIconPath(path) {
- const value = String(path || '')
- return value.startsWith('_doc/')
- || value.startsWith('file://')
- || value.startsWith('/')
- || /^[a-zA-Z]:\\/.test(value)
- }
- function isValidCachedPathForRuntime(path) {
- const value = String(path || '')
- if (!value) return false
- if (isH5CacheEnabled()) return value.startsWith('data:image/')
- if (isAppCacheEnabled()) return isAppLocalIconPath(value)
- return false
- }
- export function getCachedIconPath(iconObjectKey) {
- const key = String(iconObjectKey || '')
- if (!key) return ''
- const cacheMap = getIconCacheMap()
- const cachedPath = cacheMap[key]
- return isValidCachedPathForRuntime(cachedPath) ? String(cachedPath) : ''
- }
- // H5 环境:用 uni.request 下载图片,先压缩再转成 base64 缓存到 uni.Storage
- function arrayBufferToBase64(buffer) {
- let binary = ''
- const bytes = new Uint8Array(buffer)
- for (let i = 0; i < bytes.byteLength; i++) {
- binary += String.fromCharCode(bytes[i])
- }
- return btoa(binary)
- }
- function getImageMimeType(res) {
- const headers = res.header || res.headers || {}
- const contentType = headers['content-type'] || headers['Content-Type'] || ''
- const mimeType = String(contentType).split(';')[0].trim()
- return mimeType.startsWith('image/') ? mimeType : 'image/png'
- }
- function canUseCanvasCompressor() {
- return typeof window !== 'undefined'
- && typeof document !== 'undefined'
- && typeof Blob !== 'undefined'
- && typeof URL !== 'undefined'
- && typeof URL.createObjectURL === 'function'
- && typeof URL.revokeObjectURL === 'function'
- && typeof Image !== 'undefined'
- }
- function getRawDataUrl(buffer, mimeType) {
- return 'data:' + mimeType + ';base64,' + arrayBufferToBase64(buffer)
- }
- function getCanvasOutputType(canvas) {
- const webp = canvas.toDataURL('image/webp', H5_ICON_WEBP_QUALITY)
- return webp.startsWith('data:image/webp') ? 'image/webp' : 'image/png'
- }
- function compressImageSourceToDataUrl(src, rawDataUrl) {
- return new Promise((resolve) => {
- if (!canUseCanvasCompressor()) {
- resolve(rawDataUrl)
- return
- }
- const img = new Image()
- img.onload = () => {
- try {
- const sourceWidth = img.naturalWidth || img.width
- const sourceHeight = img.naturalHeight || img.height
- if (!sourceWidth || !sourceHeight) {
- resolve(rawDataUrl)
- return
- }
- const scale = Math.min(1, H5_ICON_MAX_SIZE / Math.max(sourceWidth, sourceHeight))
- const targetWidth = Math.max(1, Math.round(sourceWidth * scale))
- const targetHeight = Math.max(1, Math.round(sourceHeight * scale))
- const canvas = document.createElement('canvas')
- canvas.width = targetWidth
- canvas.height = targetHeight
- const ctx = canvas.getContext('2d')
- if (!ctx) {
- resolve(rawDataUrl)
- return
- }
- ctx.clearRect(0, 0, targetWidth, targetHeight)
- ctx.drawImage(img, 0, 0, targetWidth, targetHeight)
- const outputType = getCanvasOutputType(canvas)
- const compressed = outputType === 'image/webp'
- ? canvas.toDataURL(outputType, H5_ICON_WEBP_QUALITY)
- : canvas.toDataURL(outputType)
- resolve(compressed && compressed.length < rawDataUrl.length ? compressed : rawDataUrl)
- } catch (e) {
- resolve(rawDataUrl)
- }
- }
- img.onerror = () => resolve(rawDataUrl)
- img.src = src
- })
- }
- async function compressImageBufferToDataUrl(buffer, mimeType) {
- const rawDataUrl = getRawDataUrl(buffer, mimeType)
- if (!canUseCanvasCompressor()) return rawDataUrl
- const blob = new Blob([buffer], { type: mimeType })
- const objectUrl = URL.createObjectURL(blob)
- try {
- return await compressImageSourceToDataUrl(objectUrl, rawDataUrl)
- } finally {
- URL.revokeObjectURL(objectUrl)
- }
- }
- function shouldRecompressCachedDataUrl(dataUrl) {
- const value = String(dataUrl || '')
- return canUseCanvasCompressor()
- && value.length > H5_RECOMPRESS_MIN_LENGTH
- && value.startsWith('data:image/')
- && !value.startsWith('data:image/webp')
- }
- function recompressCachedDataUrl(dataUrl) {
- const value = String(dataUrl || '')
- return compressImageSourceToDataUrl(value, value)
- }
- function downloadAsBase64(url) {
- return new Promise((resolve, reject) => {
- uni.request({
- url,
- responseType: 'arraybuffer',
- success: async (res) => {
- if (res.statusCode === 200 && res.data) {
- try {
- const mimeType = getImageMimeType(res)
- const base64 = await compressImageBufferToDataUrl(res.data, mimeType)
- resolve(base64)
- } catch (e) {
- reject(e)
- }
- } else {
- reject(new Error('request failed: ' + res.statusCode))
- }
- },
- fail: reject
- })
- })
- }
- function formatError(err) {
- if (err == null) return 'unknown error'
- if (typeof err === 'string') return err
- if (err instanceof Error && err.message) return err.message
- if (typeof err.errMsg === 'string' && err.errMsg) return err.errMsg
- if (typeof err.message === 'string' && err.message) return err.message
- try {
- return JSON.stringify(err)
- } catch (e) {
- return String(err)
- }
- }
- function canUsePlusIo() {
- return isAppCacheEnabled() && typeof plus !== 'undefined' && plus.io
- }
- function normalizePlusPath(path) {
- const value = String(path || '').trim()
- if (!value) return ''
- if (/^file:\/\//i.test(value)) return value
- if (/^_(doc|downloads|www)/i.test(value)) return value
- if (/^\//.test(value)) return 'file://' + value
- return value
- }
- function buildPathResolveCandidates(path) {
- const raw = String(path || '').trim()
- const set = new Set()
- const add = (value) => {
- const s = String(value || '').trim()
- if (s) set.add(s)
- }
- add(normalizePlusPath(raw))
- add(raw)
- if (canUsePlusIo() && typeof plus.io.convertLocalFileSystemURL === 'function') {
- try {
- if (/^_(doc|downloads|www)/i.test(raw)) {
- const converted = plus.io.convertLocalFileSystemURL(raw)
- add(converted)
- add(normalizePlusPath(converted))
- }
- const noProtocol = raw.replace(/^file:\/\//i, '')
- if (noProtocol !== raw && /^(?:\/|_)/.test(noProtocol)) {
- const converted = plus.io.convertLocalFileSystemURL(noProtocol)
- add(converted)
- add(normalizePlusPath(converted))
- }
- } catch (e) {}
- }
- return Array.from(set)
- }
- function savedFileExists(filePath) {
- return new Promise((resolve) => {
- if (typeof uni.getSavedFileInfo !== 'function') {
- resolve(false)
- return
- }
- uni.getSavedFileInfo({
- filePath,
- success: () => resolve(true),
- fail: () => resolve(false)
- })
- })
- }
- function appFileExists(filePath) {
- return new Promise((resolve) => {
- const raw = String(filePath || '').trim()
- if (!isAppCacheEnabled() || !raw) {
- resolve(false)
- return
- }
- if (!canUsePlusIo()) {
- savedFileExists(raw).then(resolve)
- return
- }
- const candidates = buildPathResolveCandidates(raw)
- let index = 0
- const tryNext = () => {
- if (index >= candidates.length) {
- savedFileExists(raw).then(resolve)
- return
- }
- plus.io.resolveLocalFileSystemURL(
- candidates[index++],
- (entry) => resolve(!!(entry && entry.isFile)),
- () => tryNext()
- )
- }
- tryNext()
- })
- }
- function unlinkAppFileIfExists(filePath) {
- return new Promise((resolve) => {
- const raw = String(filePath || '').trim()
- if (!canUsePlusIo() || !raw) {
- resolve()
- return
- }
- plus.io.resolveLocalFileSystemURL(
- normalizePlusPath(raw),
- (entry) => {
- if (entry && entry.isFile) {
- entry.remove(() => resolve(), () => resolve())
- } else {
- resolve()
- }
- },
- () => resolve()
- )
- })
- }
- function ensureAppIconDir() {
- return new Promise((resolve, reject) => {
- if (!canUsePlusIo()) {
- reject(new Error('plus.io unavailable'))
- return
- }
- plus.io.resolveLocalFileSystemURL(
- '_doc',
- (rootEntry) => {
- rootEntry.getDirectory(
- APP_ICON_DIR_NAME,
- { create: true },
- resolve,
- (e) => reject(new Error(formatError(e) || 'create icon cache dir failed'))
- )
- },
- (e) => reject(new Error(formatError(e) || 'resolve _doc failed'))
- )
- })
- }
- function hashIconKey(key) {
- const value = String(key || '')
- let hash = 5381
- for (let i = 0; i < value.length; i++) {
- hash = ((hash << 5) + hash + value.charCodeAt(i)) >>> 0
- }
- return hash.toString(36)
- }
- function getIconExtension(iconUrl) {
- try {
- const clean = String(iconUrl || '').split('?')[0].split('#')[0]
- const match = clean.match(/\.([a-zA-Z0-9]{2,5})$/)
- const ext = match ? match[1].toLowerCase() : ''
- if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return ext
- } catch (e) {}
- return 'png'
- }
- function getAppIconFileName(iconObjectKey, iconUrl) {
- const key = String(iconObjectKey || '')
- const suffix = key.replace(/[^a-zA-Z0-9_-]/g, '_').slice(-36)
- const stem = suffix ? hashIconKey(key) + '-' + suffix : hashIconKey(key)
- return stem + '.' + getIconExtension(iconUrl)
- }
- function getAppIconPath(iconObjectKey, iconUrl) {
- return APP_ICON_CACHE_DIR + '/' + getAppIconFileName(iconObjectKey, iconUrl)
- }
- function downloadToTempFile(iconUrl) {
- return new Promise((resolve, reject) => {
- uni.downloadFile({
- url: iconUrl,
- success: (res) => {
- if (res.statusCode === 200 && res.tempFilePath) {
- resolve(res.tempFilePath)
- return
- }
- reject(new Error('download failed: ' + (res.statusCode || 'unknown')))
- },
- fail: reject
- })
- })
- }
- function copyTempFileToAppCache(tempFilePath, destPath) {
- return new Promise((resolve, reject) => {
- if (!canUsePlusIo()) {
- reject(new Error('plus.io unavailable'))
- return
- }
- const fileName = destPath.substring(destPath.lastIndexOf('/') + 1)
- plus.io.resolveLocalFileSystemURL(
- normalizePlusPath(tempFilePath),
- (srcEntry) => {
- if (!srcEntry || !srcEntry.isFile) {
- reject(new Error('temp icon file invalid'))
- return
- }
- ensureAppIconDir()
- .then((dirEntry) => {
- srcEntry.copyTo(
- dirEntry,
- fileName,
- () => resolve(destPath),
- (e) => reject(new Error(formatError(e) || 'copy icon file failed'))
- )
- })
- .catch(reject)
- },
- (e) => reject(new Error(formatError(e) || 'resolve temp icon file failed'))
- )
- })
- }
- function saveTempFile(tempFilePath) {
- return new Promise((resolve, reject) => {
- if (typeof uni.saveFile !== 'function') {
- reject(new Error('uni.saveFile unavailable'))
- return
- }
- uni.saveFile({
- tempFilePath,
- success: (res) => resolve(res.savedFilePath || res.filePath || ''),
- fail: reject
- })
- })
- }
- async function downloadAndCacheH5Icon(iconUrl, iconObjectKey) {
- const LOG = '[IconCache]'
- console.log(LOG + ' H5下载并缓存(base64):', iconObjectKey)
- const base64 = await downloadAsBase64(iconUrl)
- console.log(LOG + ' H5缓存完成:', iconObjectKey, base64.substring(0, 50) + '...')
- return base64
- }
- async function downloadAndCacheAppIcon(iconUrl, iconObjectKey) {
- const LOG = '[IconCache]'
- const localPath = getAppIconPath(iconObjectKey, iconUrl)
- console.log(LOG + ' App下载并缓存文件:', iconObjectKey)
- const tempFilePath = await downloadToTempFile(iconUrl)
- try {
- await unlinkAppFileIfExists(localPath)
- const copiedPath = await copyTempFileToAppCache(tempFilePath, localPath)
- console.log(LOG + ' App缓存完成:', iconObjectKey, '->', copiedPath)
- return copiedPath
- } catch (e) {
- console.warn(LOG + ' App文件缓存失败,尝试 saveFile:', iconObjectKey, e)
- const savedPath = await saveTempFile(tempFilePath)
- if (savedPath) {
- console.log(LOG + ' App缓存完成(saveFile):', iconObjectKey, '->', savedPath)
- return savedPath
- }
- throw new Error('save icon file failed')
- }
- }
- /**
- * 下载图标并缓存
- * @param {string} iconUrl - 远程图标 URL
- * @param {string} iconObjectKey - 缓存键
- * @returns {Promise<string>} 本地文件路径(App)或 base64 data URL(H5),失败返回空字符串
- */
- export async function downloadAndCacheIcon(iconUrl, iconObjectKey) {
- const LOG = '[IconCache]'
- const url = String(iconUrl || '').trim()
- const key = String(iconObjectKey || '').trim()
- if (!url || !key) return ''
- const pendingKey = getCacheRuntime() + ':' + key
- if (pendingIconDownloads.has(pendingKey)) {
- return pendingIconDownloads.get(pendingKey)
- }
- const task = (async () => {
- try {
- if (isH5CacheEnabled()) return await downloadAndCacheH5Icon(url, key)
- if (isAppCacheEnabled()) return await downloadAndCacheAppIcon(url, key)
- } catch (e) {
- console.error(LOG + ' 下载异常:', key, e)
- }
- return ''
- })()
- pendingIconDownloads.set(pendingKey, task)
- try {
- return await task
- } finally {
- pendingIconDownloads.delete(pendingKey)
- }
- }
- function applyIconPath(items, iconPath) {
- for (const item of items) {
- item.iconPath = iconPath
- }
- }
- function getCacheGroups(items) {
- const groups = new Map()
- for (const item of Array.isArray(items) ? items : []) {
- if (!item || !item.iconObjectKey) continue
- const key = String(item.iconObjectKey)
- if (!key) continue
- const useRealIcon = item.useRealIcon !== false
- const iconUrl = String(item.iconUrl || item.remoteIconUrl || '').trim()
- if (!useRealIcon && !iconUrl) continue
- if (!groups.has(key)) {
- groups.set(key, { key, iconUrl, items: [] })
- }
- const group = groups.get(key)
- if (!group.iconUrl && iconUrl) group.iconUrl = iconUrl
- group.items.push(item)
- }
- return Array.from(groups.values())
- }
- /**
- * 为一批图标处理缓存:命中则使用缓存,未命中则下载
- * @param {Array} items - 待处理列表,每项需含 iconUrl / iconObjectKey / useRealIcon
- * @param {Function} onUpdate - 缓存命中或下载完成后回调,用于触发 UI 刷新
- */
- export async function processIconCache(items, onUpdate) {
- const LOG = '[IconCache]'
- const groups = getCacheGroups(items)
- if (groups.length === 0) return
- if (!isH5CacheEnabled() && !isAppCacheEnabled()) return
- const cacheMap = getIconCacheMap()
- const newCacheMap = { ...cacheMap }
- const iconDownloadTasks = []
- let shouldNotifyUpdate = false
- let shouldSaveMap = false
- for (const group of groups) {
- const key = group.key
- const cachedPath = cacheMap[key]
- newCacheMap[key] = cachedPath || ''
- if (isH5CacheEnabled() && cachedPath && String(cachedPath).startsWith('data:image/')) {
- applyIconPath(group.items, cachedPath)
- shouldNotifyUpdate = true
- if (shouldRecompressCachedDataUrl(cachedPath)) {
- console.log(LOG + ' H5缓存较大,重新压缩:', key)
- iconDownloadTasks.push(
- recompressCachedDataUrl(cachedPath).then((result) => {
- if (result) {
- applyIconPath(group.items, result)
- newCacheMap[key] = result
- shouldSaveMap = true
- }
- })
- )
- }
- console.log(LOG + ' H5缓存命中:', key)
- continue
- }
- if (isAppCacheEnabled() && cachedPath && isAppLocalIconPath(cachedPath)) {
- if (await appFileExists(cachedPath)) {
- applyIconPath(group.items, cachedPath)
- shouldNotifyUpdate = true
- console.log(LOG + ' App缓存命中:', key)
- continue
- }
- console.log(LOG + ' App缓存文件丢失,重新下载:', key)
- delete newCacheMap[key]
- shouldSaveMap = true
- } else if (cachedPath) {
- console.log(LOG + ' 缓存值不适用于当前端,重新下载:', key)
- delete newCacheMap[key]
- shouldSaveMap = true
- }
- if (!group.iconUrl) continue
- console.log(LOG + ' 首次下载:', key)
- iconDownloadTasks.push(
- downloadAndCacheIcon(group.iconUrl, key).then((result) => {
- if (result) {
- applyIconPath(group.items, result)
- newCacheMap[key] = result
- shouldSaveMap = true
- }
- })
- )
- }
- if (iconDownloadTasks.length > 0) {
- console.log(LOG + ' 共 ' + iconDownloadTasks.length + ' 个图标需要下载')
- if (shouldNotifyUpdate && onUpdate) onUpdate()
- await Promise.all(iconDownloadTasks)
- setIconCacheMap(newCacheMap)
- if (onUpdate) onUpdate()
- console.log(LOG + ' 全部下载完成,UI已刷新')
- return
- }
- if (shouldSaveMap) setIconCacheMap(newCacheMap)
- if (shouldNotifyUpdate && onUpdate) onUpdate()
- console.log(LOG + ' 所有图标均已缓存,无需下载')
- }
|