iconCache.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. // ---- 图标本地缓存(App 端文件系统 + H5 端 base64/uni.Storage) ----
  2. const CACHE_MAP_KEY = 'app_icon_cache_map'
  3. const APP_ICON_DIR_NAME = 'app-icons'
  4. const APP_ICON_CACHE_DIR = '_doc/' + APP_ICON_DIR_NAME
  5. const H5_ICON_MAX_SIZE = 128
  6. const H5_ICON_WEBP_QUALITY = 0.82
  7. const H5_RECOMPRESS_MIN_LENGTH = 32 * 1024
  8. const pendingIconDownloads = new Map()
  9. function getCacheRuntime() {
  10. let runtime = 'other'
  11. // #ifdef H5
  12. runtime = 'h5'
  13. // #endif
  14. // #ifdef APP-PLUS
  15. runtime = 'app'
  16. // #endif
  17. return runtime
  18. }
  19. function isH5CacheEnabled() {
  20. return getCacheRuntime() === 'h5'
  21. }
  22. function isAppCacheEnabled() {
  23. return getCacheRuntime() === 'app'
  24. }
  25. function getIconCacheMap() {
  26. try {
  27. const raw = uni.getStorageSync(CACHE_MAP_KEY)
  28. return (raw && typeof raw === 'object') ? raw : {}
  29. } catch (e) { return {} }
  30. }
  31. function setIconCacheMap(map) {
  32. try { uni.setStorageSync(CACHE_MAP_KEY, map || {}) } catch (e) {}
  33. }
  34. function isAppLocalIconPath(path) {
  35. const value = String(path || '')
  36. return value.startsWith('_doc/')
  37. || value.startsWith('file://')
  38. || value.startsWith('/')
  39. || /^[a-zA-Z]:\\/.test(value)
  40. }
  41. function isValidCachedPathForRuntime(path) {
  42. const value = String(path || '')
  43. if (!value) return false
  44. if (isH5CacheEnabled()) return value.startsWith('data:image/')
  45. if (isAppCacheEnabled()) return isAppLocalIconPath(value)
  46. return false
  47. }
  48. export function getCachedIconPath(iconObjectKey) {
  49. const key = String(iconObjectKey || '')
  50. if (!key) return ''
  51. const cacheMap = getIconCacheMap()
  52. const cachedPath = cacheMap[key]
  53. return isValidCachedPathForRuntime(cachedPath) ? String(cachedPath) : ''
  54. }
  55. // H5 环境:用 uni.request 下载图片,先压缩再转成 base64 缓存到 uni.Storage
  56. function arrayBufferToBase64(buffer) {
  57. let binary = ''
  58. const bytes = new Uint8Array(buffer)
  59. for (let i = 0; i < bytes.byteLength; i++) {
  60. binary += String.fromCharCode(bytes[i])
  61. }
  62. return btoa(binary)
  63. }
  64. function getImageMimeType(res) {
  65. const headers = res.header || res.headers || {}
  66. const contentType = headers['content-type'] || headers['Content-Type'] || ''
  67. const mimeType = String(contentType).split(';')[0].trim()
  68. return mimeType.startsWith('image/') ? mimeType : 'image/png'
  69. }
  70. function canUseCanvasCompressor() {
  71. return typeof window !== 'undefined'
  72. && typeof document !== 'undefined'
  73. && typeof Blob !== 'undefined'
  74. && typeof URL !== 'undefined'
  75. && typeof URL.createObjectURL === 'function'
  76. && typeof URL.revokeObjectURL === 'function'
  77. && typeof Image !== 'undefined'
  78. }
  79. function getRawDataUrl(buffer, mimeType) {
  80. return 'data:' + mimeType + ';base64,' + arrayBufferToBase64(buffer)
  81. }
  82. function getCanvasOutputType(canvas) {
  83. const webp = canvas.toDataURL('image/webp', H5_ICON_WEBP_QUALITY)
  84. return webp.startsWith('data:image/webp') ? 'image/webp' : 'image/png'
  85. }
  86. function compressImageSourceToDataUrl(src, rawDataUrl) {
  87. return new Promise((resolve) => {
  88. if (!canUseCanvasCompressor()) {
  89. resolve(rawDataUrl)
  90. return
  91. }
  92. const img = new Image()
  93. img.onload = () => {
  94. try {
  95. const sourceWidth = img.naturalWidth || img.width
  96. const sourceHeight = img.naturalHeight || img.height
  97. if (!sourceWidth || !sourceHeight) {
  98. resolve(rawDataUrl)
  99. return
  100. }
  101. const scale = Math.min(1, H5_ICON_MAX_SIZE / Math.max(sourceWidth, sourceHeight))
  102. const targetWidth = Math.max(1, Math.round(sourceWidth * scale))
  103. const targetHeight = Math.max(1, Math.round(sourceHeight * scale))
  104. const canvas = document.createElement('canvas')
  105. canvas.width = targetWidth
  106. canvas.height = targetHeight
  107. const ctx = canvas.getContext('2d')
  108. if (!ctx) {
  109. resolve(rawDataUrl)
  110. return
  111. }
  112. ctx.clearRect(0, 0, targetWidth, targetHeight)
  113. ctx.drawImage(img, 0, 0, targetWidth, targetHeight)
  114. const outputType = getCanvasOutputType(canvas)
  115. const compressed = outputType === 'image/webp'
  116. ? canvas.toDataURL(outputType, H5_ICON_WEBP_QUALITY)
  117. : canvas.toDataURL(outputType)
  118. resolve(compressed && compressed.length < rawDataUrl.length ? compressed : rawDataUrl)
  119. } catch (e) {
  120. resolve(rawDataUrl)
  121. }
  122. }
  123. img.onerror = () => resolve(rawDataUrl)
  124. img.src = src
  125. })
  126. }
  127. async function compressImageBufferToDataUrl(buffer, mimeType) {
  128. const rawDataUrl = getRawDataUrl(buffer, mimeType)
  129. if (!canUseCanvasCompressor()) return rawDataUrl
  130. const blob = new Blob([buffer], { type: mimeType })
  131. const objectUrl = URL.createObjectURL(blob)
  132. try {
  133. return await compressImageSourceToDataUrl(objectUrl, rawDataUrl)
  134. } finally {
  135. URL.revokeObjectURL(objectUrl)
  136. }
  137. }
  138. function shouldRecompressCachedDataUrl(dataUrl) {
  139. const value = String(dataUrl || '')
  140. return canUseCanvasCompressor()
  141. && value.length > H5_RECOMPRESS_MIN_LENGTH
  142. && value.startsWith('data:image/')
  143. && !value.startsWith('data:image/webp')
  144. }
  145. function recompressCachedDataUrl(dataUrl) {
  146. const value = String(dataUrl || '')
  147. return compressImageSourceToDataUrl(value, value)
  148. }
  149. function downloadAsBase64(url) {
  150. return new Promise((resolve, reject) => {
  151. uni.request({
  152. url,
  153. responseType: 'arraybuffer',
  154. success: async (res) => {
  155. if (res.statusCode === 200 && res.data) {
  156. try {
  157. const mimeType = getImageMimeType(res)
  158. const base64 = await compressImageBufferToDataUrl(res.data, mimeType)
  159. resolve(base64)
  160. } catch (e) {
  161. reject(e)
  162. }
  163. } else {
  164. reject(new Error('request failed: ' + res.statusCode))
  165. }
  166. },
  167. fail: reject
  168. })
  169. })
  170. }
  171. function formatError(err) {
  172. if (err == null) return 'unknown error'
  173. if (typeof err === 'string') return err
  174. if (err instanceof Error && err.message) return err.message
  175. if (typeof err.errMsg === 'string' && err.errMsg) return err.errMsg
  176. if (typeof err.message === 'string' && err.message) return err.message
  177. try {
  178. return JSON.stringify(err)
  179. } catch (e) {
  180. return String(err)
  181. }
  182. }
  183. function canUsePlusIo() {
  184. return isAppCacheEnabled() && typeof plus !== 'undefined' && plus.io
  185. }
  186. function normalizePlusPath(path) {
  187. const value = String(path || '').trim()
  188. if (!value) return ''
  189. if (/^file:\/\//i.test(value)) return value
  190. if (/^_(doc|downloads|www)/i.test(value)) return value
  191. if (/^\//.test(value)) return 'file://' + value
  192. return value
  193. }
  194. function buildPathResolveCandidates(path) {
  195. const raw = String(path || '').trim()
  196. const set = new Set()
  197. const add = (value) => {
  198. const s = String(value || '').trim()
  199. if (s) set.add(s)
  200. }
  201. add(normalizePlusPath(raw))
  202. add(raw)
  203. if (canUsePlusIo() && typeof plus.io.convertLocalFileSystemURL === 'function') {
  204. try {
  205. if (/^_(doc|downloads|www)/i.test(raw)) {
  206. const converted = plus.io.convertLocalFileSystemURL(raw)
  207. add(converted)
  208. add(normalizePlusPath(converted))
  209. }
  210. const noProtocol = raw.replace(/^file:\/\//i, '')
  211. if (noProtocol !== raw && /^(?:\/|_)/.test(noProtocol)) {
  212. const converted = plus.io.convertLocalFileSystemURL(noProtocol)
  213. add(converted)
  214. add(normalizePlusPath(converted))
  215. }
  216. } catch (e) {}
  217. }
  218. return Array.from(set)
  219. }
  220. function savedFileExists(filePath) {
  221. return new Promise((resolve) => {
  222. if (typeof uni.getSavedFileInfo !== 'function') {
  223. resolve(false)
  224. return
  225. }
  226. uni.getSavedFileInfo({
  227. filePath,
  228. success: () => resolve(true),
  229. fail: () => resolve(false)
  230. })
  231. })
  232. }
  233. function appFileExists(filePath) {
  234. return new Promise((resolve) => {
  235. const raw = String(filePath || '').trim()
  236. if (!isAppCacheEnabled() || !raw) {
  237. resolve(false)
  238. return
  239. }
  240. if (!canUsePlusIo()) {
  241. savedFileExists(raw).then(resolve)
  242. return
  243. }
  244. const candidates = buildPathResolveCandidates(raw)
  245. let index = 0
  246. const tryNext = () => {
  247. if (index >= candidates.length) {
  248. savedFileExists(raw).then(resolve)
  249. return
  250. }
  251. plus.io.resolveLocalFileSystemURL(
  252. candidates[index++],
  253. (entry) => resolve(!!(entry && entry.isFile)),
  254. () => tryNext()
  255. )
  256. }
  257. tryNext()
  258. })
  259. }
  260. function unlinkAppFileIfExists(filePath) {
  261. return new Promise((resolve) => {
  262. const raw = String(filePath || '').trim()
  263. if (!canUsePlusIo() || !raw) {
  264. resolve()
  265. return
  266. }
  267. plus.io.resolveLocalFileSystemURL(
  268. normalizePlusPath(raw),
  269. (entry) => {
  270. if (entry && entry.isFile) {
  271. entry.remove(() => resolve(), () => resolve())
  272. } else {
  273. resolve()
  274. }
  275. },
  276. () => resolve()
  277. )
  278. })
  279. }
  280. function ensureAppIconDir() {
  281. return new Promise((resolve, reject) => {
  282. if (!canUsePlusIo()) {
  283. reject(new Error('plus.io unavailable'))
  284. return
  285. }
  286. plus.io.resolveLocalFileSystemURL(
  287. '_doc',
  288. (rootEntry) => {
  289. rootEntry.getDirectory(
  290. APP_ICON_DIR_NAME,
  291. { create: true },
  292. resolve,
  293. (e) => reject(new Error(formatError(e) || 'create icon cache dir failed'))
  294. )
  295. },
  296. (e) => reject(new Error(formatError(e) || 'resolve _doc failed'))
  297. )
  298. })
  299. }
  300. function hashIconKey(key) {
  301. const value = String(key || '')
  302. let hash = 5381
  303. for (let i = 0; i < value.length; i++) {
  304. hash = ((hash << 5) + hash + value.charCodeAt(i)) >>> 0
  305. }
  306. return hash.toString(36)
  307. }
  308. function getIconExtension(iconUrl) {
  309. try {
  310. const clean = String(iconUrl || '').split('?')[0].split('#')[0]
  311. const match = clean.match(/\.([a-zA-Z0-9]{2,5})$/)
  312. const ext = match ? match[1].toLowerCase() : ''
  313. if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return ext
  314. } catch (e) {}
  315. return 'png'
  316. }
  317. function getAppIconFileName(iconObjectKey, iconUrl) {
  318. const key = String(iconObjectKey || '')
  319. const suffix = key.replace(/[^a-zA-Z0-9_-]/g, '_').slice(-36)
  320. const stem = suffix ? hashIconKey(key) + '-' + suffix : hashIconKey(key)
  321. return stem + '.' + getIconExtension(iconUrl)
  322. }
  323. function getAppIconPath(iconObjectKey, iconUrl) {
  324. return APP_ICON_CACHE_DIR + '/' + getAppIconFileName(iconObjectKey, iconUrl)
  325. }
  326. function downloadToTempFile(iconUrl) {
  327. return new Promise((resolve, reject) => {
  328. uni.downloadFile({
  329. url: iconUrl,
  330. success: (res) => {
  331. if (res.statusCode === 200 && res.tempFilePath) {
  332. resolve(res.tempFilePath)
  333. return
  334. }
  335. reject(new Error('download failed: ' + (res.statusCode || 'unknown')))
  336. },
  337. fail: reject
  338. })
  339. })
  340. }
  341. function copyTempFileToAppCache(tempFilePath, destPath) {
  342. return new Promise((resolve, reject) => {
  343. if (!canUsePlusIo()) {
  344. reject(new Error('plus.io unavailable'))
  345. return
  346. }
  347. const fileName = destPath.substring(destPath.lastIndexOf('/') + 1)
  348. plus.io.resolveLocalFileSystemURL(
  349. normalizePlusPath(tempFilePath),
  350. (srcEntry) => {
  351. if (!srcEntry || !srcEntry.isFile) {
  352. reject(new Error('temp icon file invalid'))
  353. return
  354. }
  355. ensureAppIconDir()
  356. .then((dirEntry) => {
  357. srcEntry.copyTo(
  358. dirEntry,
  359. fileName,
  360. () => resolve(destPath),
  361. (e) => reject(new Error(formatError(e) || 'copy icon file failed'))
  362. )
  363. })
  364. .catch(reject)
  365. },
  366. (e) => reject(new Error(formatError(e) || 'resolve temp icon file failed'))
  367. )
  368. })
  369. }
  370. function saveTempFile(tempFilePath) {
  371. return new Promise((resolve, reject) => {
  372. if (typeof uni.saveFile !== 'function') {
  373. reject(new Error('uni.saveFile unavailable'))
  374. return
  375. }
  376. uni.saveFile({
  377. tempFilePath,
  378. success: (res) => resolve(res.savedFilePath || res.filePath || ''),
  379. fail: reject
  380. })
  381. })
  382. }
  383. async function downloadAndCacheH5Icon(iconUrl, iconObjectKey) {
  384. const LOG = '[IconCache]'
  385. console.log(LOG + ' H5下载并缓存(base64):', iconObjectKey)
  386. const base64 = await downloadAsBase64(iconUrl)
  387. console.log(LOG + ' H5缓存完成:', iconObjectKey, base64.substring(0, 50) + '...')
  388. return base64
  389. }
  390. async function downloadAndCacheAppIcon(iconUrl, iconObjectKey) {
  391. const LOG = '[IconCache]'
  392. const localPath = getAppIconPath(iconObjectKey, iconUrl)
  393. console.log(LOG + ' App下载并缓存文件:', iconObjectKey)
  394. const tempFilePath = await downloadToTempFile(iconUrl)
  395. try {
  396. await unlinkAppFileIfExists(localPath)
  397. const copiedPath = await copyTempFileToAppCache(tempFilePath, localPath)
  398. console.log(LOG + ' App缓存完成:', iconObjectKey, '->', copiedPath)
  399. return copiedPath
  400. } catch (e) {
  401. console.warn(LOG + ' App文件缓存失败,尝试 saveFile:', iconObjectKey, e)
  402. const savedPath = await saveTempFile(tempFilePath)
  403. if (savedPath) {
  404. console.log(LOG + ' App缓存完成(saveFile):', iconObjectKey, '->', savedPath)
  405. return savedPath
  406. }
  407. throw new Error('save icon file failed')
  408. }
  409. }
  410. /**
  411. * 下载图标并缓存
  412. * @param {string} iconUrl - 远程图标 URL
  413. * @param {string} iconObjectKey - 缓存键
  414. * @returns {Promise<string>} 本地文件路径(App)或 base64 data URL(H5),失败返回空字符串
  415. */
  416. export async function downloadAndCacheIcon(iconUrl, iconObjectKey) {
  417. const LOG = '[IconCache]'
  418. const url = String(iconUrl || '').trim()
  419. const key = String(iconObjectKey || '').trim()
  420. if (!url || !key) return ''
  421. const pendingKey = getCacheRuntime() + ':' + key
  422. if (pendingIconDownloads.has(pendingKey)) {
  423. return pendingIconDownloads.get(pendingKey)
  424. }
  425. const task = (async () => {
  426. try {
  427. if (isH5CacheEnabled()) return await downloadAndCacheH5Icon(url, key)
  428. if (isAppCacheEnabled()) return await downloadAndCacheAppIcon(url, key)
  429. } catch (e) {
  430. console.error(LOG + ' 下载异常:', key, e)
  431. }
  432. return ''
  433. })()
  434. pendingIconDownloads.set(pendingKey, task)
  435. try {
  436. return await task
  437. } finally {
  438. pendingIconDownloads.delete(pendingKey)
  439. }
  440. }
  441. function applyIconPath(items, iconPath) {
  442. for (const item of items) {
  443. item.iconPath = iconPath
  444. }
  445. }
  446. function getCacheGroups(items) {
  447. const groups = new Map()
  448. for (const item of Array.isArray(items) ? items : []) {
  449. if (!item || !item.iconObjectKey) continue
  450. const key = String(item.iconObjectKey)
  451. if (!key) continue
  452. const useRealIcon = item.useRealIcon !== false
  453. const iconUrl = String(item.iconUrl || item.remoteIconUrl || '').trim()
  454. if (!useRealIcon && !iconUrl) continue
  455. if (!groups.has(key)) {
  456. groups.set(key, { key, iconUrl, items: [] })
  457. }
  458. const group = groups.get(key)
  459. if (!group.iconUrl && iconUrl) group.iconUrl = iconUrl
  460. group.items.push(item)
  461. }
  462. return Array.from(groups.values())
  463. }
  464. /**
  465. * 为一批图标处理缓存:命中则使用缓存,未命中则下载
  466. * @param {Array} items - 待处理列表,每项需含 iconUrl / iconObjectKey / useRealIcon
  467. * @param {Function} onUpdate - 缓存命中或下载完成后回调,用于触发 UI 刷新
  468. */
  469. export async function processIconCache(items, onUpdate) {
  470. const LOG = '[IconCache]'
  471. const groups = getCacheGroups(items)
  472. if (groups.length === 0) return
  473. if (!isH5CacheEnabled() && !isAppCacheEnabled()) return
  474. const cacheMap = getIconCacheMap()
  475. const newCacheMap = { ...cacheMap }
  476. const iconDownloadTasks = []
  477. let shouldNotifyUpdate = false
  478. let shouldSaveMap = false
  479. for (const group of groups) {
  480. const key = group.key
  481. const cachedPath = cacheMap[key]
  482. newCacheMap[key] = cachedPath || ''
  483. if (isH5CacheEnabled() && cachedPath && String(cachedPath).startsWith('data:image/')) {
  484. applyIconPath(group.items, cachedPath)
  485. shouldNotifyUpdate = true
  486. if (shouldRecompressCachedDataUrl(cachedPath)) {
  487. console.log(LOG + ' H5缓存较大,重新压缩:', key)
  488. iconDownloadTasks.push(
  489. recompressCachedDataUrl(cachedPath).then((result) => {
  490. if (result) {
  491. applyIconPath(group.items, result)
  492. newCacheMap[key] = result
  493. shouldSaveMap = true
  494. }
  495. })
  496. )
  497. }
  498. console.log(LOG + ' H5缓存命中:', key)
  499. continue
  500. }
  501. if (isAppCacheEnabled() && cachedPath && isAppLocalIconPath(cachedPath)) {
  502. if (await appFileExists(cachedPath)) {
  503. applyIconPath(group.items, cachedPath)
  504. shouldNotifyUpdate = true
  505. console.log(LOG + ' App缓存命中:', key)
  506. continue
  507. }
  508. console.log(LOG + ' App缓存文件丢失,重新下载:', key)
  509. delete newCacheMap[key]
  510. shouldSaveMap = true
  511. } else if (cachedPath) {
  512. console.log(LOG + ' 缓存值不适用于当前端,重新下载:', key)
  513. delete newCacheMap[key]
  514. shouldSaveMap = true
  515. }
  516. if (!group.iconUrl) continue
  517. console.log(LOG + ' 首次下载:', key)
  518. iconDownloadTasks.push(
  519. downloadAndCacheIcon(group.iconUrl, key).then((result) => {
  520. if (result) {
  521. applyIconPath(group.items, result)
  522. newCacheMap[key] = result
  523. shouldSaveMap = true
  524. }
  525. })
  526. )
  527. }
  528. if (iconDownloadTasks.length > 0) {
  529. console.log(LOG + ' 共 ' + iconDownloadTasks.length + ' 个图标需要下载')
  530. if (shouldNotifyUpdate && onUpdate) onUpdate()
  531. await Promise.all(iconDownloadTasks)
  532. setIconCacheMap(newCacheMap)
  533. if (onUpdate) onUpdate()
  534. console.log(LOG + ' 全部下载完成,UI已刷新')
  535. return
  536. }
  537. if (shouldSaveMap) setIconCacheMap(newCacheMap)
  538. if (shouldNotifyUpdate && onUpdate) onUpdate()
  539. console.log(LOG + ' 所有图标均已缓存,无需下载')
  540. }