liuq hai 1 semana
pai
achega
bd38fc2633

+ 5 - 1
components/chat/NotificationBubble.vue

@@ -22,6 +22,7 @@
 						:name="appName"
 						:size="64"
 						unit="rpx"
+						:src="senderAvatar"
 					/>
 				</view>
 			</view>
@@ -73,6 +74,10 @@ const props = defineProps({
 		type: String,
 		default: ''
 	},
+	senderAvatar: {
+		type: String,
+		default: ''
+	},
 	showDateLabel: {
 		type: Boolean,
 		default: false
@@ -233,4 +238,3 @@ function onOpenNotificationUrl() {
 	color: #9ca3af;
 }
 </style>
-

+ 3 - 2
components/chat/PrivateMessageBubble.vue

@@ -20,10 +20,11 @@
 			<view class="side-left">
 				<view v-if="!msg.isMe" class="avatar-box">
 					<SystemAvatar
-						v-if="isAppSession && !senderAvatar"
+						v-if="isAppSession"
 						:name="senderName"
 						:size="64"
 						unit="rpx"
+						:src="senderAvatar"
 					/>
 					<UserAvatar
 						v-else
@@ -147,7 +148,7 @@ const props = defineProps({
 	},
 	senderId: { type: String, default: '' },
 	senderAvatar: { type: String, default: '' },
-	/** 应用/系统会话:无自定义头像时用 SystemAvatar,与消息列表、应用中心渐变一致 */
+	/** 应用/系统会话:用 SystemAvatar 展示自定义图标或默认图标,与消息列表一致 */
 	isAppSession: { type: Boolean, default: false },
 	meName: { type: String, default: '我' },
 	meId: { type: String, default: '' },

+ 12 - 1
composables/useContacts.js

@@ -3,6 +3,7 @@
  */
 import { getContacts, getToken } from '../utils/api'
 import { chatStore } from '../store/chat'
+import { getCachedIconPath } from '../utils/iconCache'
 
 /**
  * 拉取会话列表
@@ -20,10 +21,19 @@ export async function fetchContactsList(opts = {}) {
 	try {
 		const data = await getContacts(token)
 		const list = Array.isArray(data) ? data : (data.list || data.items || data.conversations || [])
+		const iconPathByKey = new Map(
+			(chatStore.contacts || [])
+				.filter(c => c && c.icon_object_key && c.iconPath && !/^https?:\/\//i.test(String(c.iconPath)))
+				.map(c => [String(c.icon_object_key), c.iconPath])
+		)
 		chatStore.setContacts(
 			list.map((c) => {
 				const id = String(c.user_id ?? c.userId ?? c.id ?? '')
 				const unreadCount = Number(c.unread_count ?? c.unreadCount ?? 0) || 0
+				const iconObjectKey = c.icon_object_key ?? ''
+				const iconPath = iconObjectKey
+					? (iconPathByKey.get(String(iconObjectKey)) || getCachedIconPath(iconObjectKey))
+					: ''
 				return {
 					id,
 					user_id: c.user_id ?? c.userId ?? c.id,
@@ -39,7 +49,8 @@ export async function fetchContactsList(opts = {}) {
 					app_id: c.app_id ?? null,
 					app_name: c.app_name ?? '',
 					icon_url: c.icon_url ?? '',
-					icon_object_key: c.icon_object_key ?? '',
+					icon_object_key: iconObjectKey,
+					iconPath,
 					application_app_id: c.application_app_id ?? ''
 				}
 			})

+ 2 - 2
manifest.json

@@ -2,8 +2,8 @@
     "name" : "韫珠IM",
     "appid" : "__UNI__6801EE3",
     "description" : "",
-    "versionName" : "1.1.4",
-    "versionCode" : 114,
+    "versionName" : "1.1.9",
+    "versionCode" : 119,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yunzhu-im-mobile",
-  "version": "1.0.0",
+  "version": "1.1.9",
   "description": "",
   "main": "main.js",
   "scripts": {

+ 30 - 163
pages/app-center/index.vue

@@ -113,6 +113,7 @@
 	import UserAvatar from '../../components/UserAvatar.vue'
 	import { getToken, getLaunchpadApps, ssoLogin, getUserIdFromToken } from '../../utils/api'
 	import { openEmbeddedOrSystemBrowser } from '../../utils/openUrlPreference'
+	import { getCachedIconPath, processIconCache } from '../../utils/iconCache'
 
 	const USER_KEY = 'current_user'
 	const RECENT_APPS_KEY_PREFIX = 'launchpad_recent_apps_v1_'
@@ -157,88 +158,6 @@
 		return pair ? `linear-gradient(135deg, ${pair[0]} 0%, ${pair[1]} 100%)` : ''
 	}
 
-	// ---- 图标本地缓存 ----
-	const CACHE_DIR = '_app_icons_'
-	const CACHE_MAP_KEY = 'app_icon_cache_map'
-
-	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 hasFileSystem() {
-		return typeof uni.getFileSystemManager === 'function'
-	}
-
-	// 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 downloadAsBase64(url) {
-		return new Promise((resolve, reject) => {
-			uni.request({
-				url,
-				responseType: 'arraybuffer',
-				success: (res) => {
-					if (res.statusCode === 200 && res.data) {
-						const base64 = arrayBufferToBase64(res.data)
-						resolve('data:image/png;base64,' + base64)
-					} else {
-						reject(new Error('request failed: ' + res.statusCode))
-					}
-				},
-				fail: reject
-			})
-		})
-	}
-
-	async function downloadAndCacheIcon(iconUrl, iconObjectKey) {
-		if (!hasFileSystem()) {
-			try {
-				console.log('[AppIconCache] H5下载并缓存(base64):', iconObjectKey)
-				const base64 = await downloadAsBase64(iconUrl)
-				console.log('[AppIconCache] H5缓存完成:', iconObjectKey, base64.substring(0, 50) + '...')
-				return base64
-			} catch (e) {
-				console.error('[AppIconCache] H5下载异常:', iconObjectKey, e)
-			}
-			return ''
-		}
-
-		try {
-			const fs = uni.getFileSystemManager()
-			try { fs.accessSync(CACHE_DIR) } catch (e) { fs.mkdirSync(CACHE_DIR) }
-
-			const localPath = `${CACHE_DIR}/${encodeURIComponent(iconObjectKey)}.png`
-			try { fs.unlinkSync(localPath) } catch (e) {}
-
-			console.log('[AppIconCache] 开始下载图标:', iconObjectKey)
-			const res = await uni.downloadFile({ url: iconUrl })
-			if (res.statusCode === 200) {
-				fs.copyFileSync(res.tempFilePath, localPath)
-				console.log('[AppIconCache] 下载完成并缓存:', iconObjectKey, '->', localPath)
-				return localPath
-			} else {
-				console.warn('[AppIconCache] 下载失败, statusCode:', res.statusCode, iconObjectKey)
-			}
-		} catch (e) {
-			console.error('[AppIconCache] 下载异常:', iconObjectKey, e)
-		}
-		return ''
-	}
-
 	export default {
 		components: { UserAvatar },
 		data() {
@@ -404,13 +323,17 @@
 						const categoryId = it.category_id == null || it.category_id === '' ? 'uncat' : String(it.category_id)
 						const iconUrl = it.icon_url || ''
 						const iconObjectKey = it.icon_object_key || ''
+						const cachedIconPath = iconObjectKey ? getCachedIconPath(iconObjectKey) : ''
+						const hasCustomIcon = !!(iconUrl && iconObjectKey)
 						return {
 							id: it.app_id,
 							name: it.app_name,
-							iconPath: iconUrl || '/static/icons/application.svg',
-							iconBg: iconUrl ? '#f3f4f6' : getGradientBgByName(it.app_name),
+							iconPath: cachedIconPath || '/static/icons/application.svg',
+							iconUrl,
+							iconBg: cachedIconPath ? '#f3f4f6' : getGradientBgByName(it.app_name),
 							iconObjectKey,
-							useRealIcon: !!(iconUrl && iconObjectKey),
+							useRealIcon: !!cachedIconPath,
+							hasCustomIcon,
 
 							category: categoryId,
 							categoryName: it.category_name || '未分类',
@@ -424,86 +347,30 @@
 
 					this.allApps = appList
 
-						// --- 图标缓存处理:用 icon_object_key 做缓存键,key 不变时不重复下载 ---
-						const cacheMap = getIconCacheMap()
-						const newCacheMap = {}
-						const iconDownloadTasks = []
-
-						for (const app of appList) {
-							if (app.useRealIcon && app.iconObjectKey) {
-								const key = String(app.iconObjectKey)
-								newCacheMap[key] = cacheMap[key] || ''
-
-								if (!hasFileSystem()) {
-									// H5 环境:用 base64 数据 URL 做缓存,存在 uni.Storage 中
-									// 排除 App 端遗留的文件路径(不以 data:image/ 开头)
-									const isBase64 = cacheMap[key] && String(cacheMap[key]).startsWith('data:image/')
-									if (isBase64) {
-										app.iconPath = cacheMap[key]
-										console.log('[AppIconCache] H5缓存命中:', key)
-									} else {
-										if (cacheMap[key]) {
-											console.log('[AppIconCache] H5缓存值无效(非base64),重新下载:', key)
-										} else {
-											console.log('[AppIconCache] H5首次下载:', key)
-										}
-										iconDownloadTasks.push(
-											downloadAndCacheIcon(app.iconPath, key).then(base64 => {
-												if (base64) {
-													app.iconPath = base64
-													newCacheMap[key] = base64
-												}
-											})
-										)
-									}
-								} else if (cacheMap[key]) {
-									// 校验缓存文件未被系统清理
-									let fileExists = false
-									try {
-										const fs = uni.getFileSystemManager()
-										fs.accessSync(cacheMap[key])
-										fileExists = true
-									} catch (e) {}
-
-									if (fileExists) {
-										app.iconPath = cacheMap[key]
-										console.log('[AppIconCache] 命中缓存:', key)
-									} else {
-										console.log('[AppIconCache] 缓存文件被清理,重新下载:', key)
-										iconDownloadTasks.push(
-											downloadAndCacheIcon(app.iconPath, key).then(localPath => {
-												if (localPath) {
-													app.iconPath = localPath
-													newCacheMap[key] = localPath
-												}
-											})
-										)
-									}
-								} else if (hasFileSystem()) {
-									console.log('[AppIconCache] 首次下载:', key)
-									iconDownloadTasks.push(
-										downloadAndCacheIcon(app.iconPath, key).then(localPath => {
-											if (localPath) {
-												app.iconPath = localPath
-												newCacheMap[key] = localPath
-											}
-										})
-									)
+					// --- 图标缓存处理:用 icon_object_key 做缓存键,key 不变时不重复下载 ---
+					const iconItems = appList
+						.filter(app => app.hasCustomIcon && app.iconObjectKey && app.iconUrl)
+						.map(app => ({
+							useRealIcon: true,
+							iconObjectKey: app.iconObjectKey,
+							iconUrl: app.iconUrl,
+							iconPath: '',
+							_app: app
+						}))
+
+					if (iconItems.length > 0) {
+						processIconCache(iconItems, () => {
+							for (const item of iconItems) {
+								if (item._app && item.iconPath) {
+									item._app.iconPath = item.iconPath
+									item._app.useRealIcon = true
+									item._app.iconBg = '#f3f4f6'
 								}
 							}
-						}
-
-						if (iconDownloadTasks.length > 0) {
-							console.log(`[AppIconCache] 共 ${iconDownloadTasks.length} 个图标需要下载`)
-							Promise.all(iconDownloadTasks).then(() => {
-								setIconCacheMap(newCacheMap)
-								this.quickApps = [...this.quickApps]
-								this.allApps = [...this.allApps]
-								console.log("[AppIconCache] 全部下载完成,UI已刷新")
-							})
-						} else {
-							console.log("[AppIconCache] 所有图标均已缓存,无需下载")
-						}
+							this.quickApps = [...this.quickApps]
+							this.allApps = [...this.allApps]
+						})
+					}
 
 					// 从本地最近列表拼出顶部“最近使用”
 					const recentList = this.loadRecentApps()

+ 4 - 1
pages/chat/index.vue

@@ -50,6 +50,7 @@
 					v-else
 					:msg="msg"
 					:sender-name="getSenderName(msg)"
+					:sender-avatar="contactAvatar"
 					:show-date-label="shouldShowDateLabel(index)"
 					@open-notification-url="openNotificationUrl"
 				/>
@@ -326,7 +327,9 @@ const currentUserId = ref('')
 const currentUserAvatar = ref('')
 const contactAvatar = computed(() => {
 	const contact = (chatStore.contacts || []).find((c) => String(c.user_id || c.id) === String(otherUserId.value))
-	return (contact && contact.avatar) ? contact.avatar : ''
+	if (!contact) return ''
+	if (isAppSession.value) return contact.iconPath || ''
+	return contact.avatar || ''
 })
 
 function previewImage(url) {

+ 14 - 9
pages/index/index.vue

@@ -86,7 +86,7 @@
 	import { chatStore } from '../../store/chat'
 	import { getToken } from '../../utils/api'
 	import { setupAppNotifications } from '../../utils/notificationSetup'
-import { processIconCache } from '../../utils/iconCache'
+	import { processIconCache } from '../../utils/iconCache'
 
 	const USER_KEY = 'current_user'
 
@@ -155,19 +155,24 @@ import { processIconCache } from '../../utils/iconCache'
 						useRealIcon: true,
 						iconObjectKey: c.icon_object_key,
 						iconUrl: c.icon_url,
-						iconPath: '',
-						// 保留引用以便回写
-						_contact: c
+						iconPath: ''
 					}))
 				if (items.length === 0) return
 				processIconCache(items, () => {
-					// 回写 iconPath 到 store,触发列表刷新
+					const iconPathByKey = new Map()
 					for (const item of items) {
-						if (item._contact && item.iconPath) {
-							item._contact.iconPath = item.iconPath
-						}
+						if (item.iconObjectKey && item.iconPath) iconPathByKey.set(String(item.iconObjectKey), item.iconPath)
 					}
-					chatStore.contacts = [...chatStore.contacts]
+					if (iconPathByKey.size === 0) return
+
+					let changed = false
+					const nextContacts = (chatStore.contacts || []).map((contact) => {
+						const iconPath = iconPathByKey.get(String(contact.icon_object_key || ''))
+						if (!iconPath || contact.iconPath === iconPath) return contact
+						changed = true
+						return { ...contact, iconPath }
+					})
+					if (changed) chatStore.contacts = nextContacts
 				})
 			}
 

+ 521 - 97
utils/iconCache.js

@@ -1,9 +1,31 @@
 // ---- 图标本地缓存(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
 
-function hasFileSystem() {
-	return typeof uni.getFileSystemManager === 'function'
+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() {
@@ -17,7 +39,32 @@ function setIconCacheMap(map) {
 	try { uni.setStorageSync(CACHE_MAP_KEY, map || {}) } catch (e) {}
 }
 
-// H5 环境:用 uni.request 下载图片转 base64 缓存到 uni.Storage
+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)
@@ -27,15 +74,119 @@ function arrayBufferToBase64(buffer) {
 	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: (res) => {
+			success: async (res) => {
 				if (res.statusCode === 200 && res.data) {
-					const base64 = arrayBufferToBase64(res.data)
-					resolve('data:image/png;base64,' + base64)
+					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))
 				}
@@ -45,6 +196,267 @@ function downloadAsBase64(url) {
 	})
 }
 
+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
@@ -53,129 +465,141 @@ function downloadAsBase64(url) {
  */
 export async function downloadAndCacheIcon(iconUrl, iconObjectKey) {
 	const LOG = '[IconCache]'
+	const url = String(iconUrl || '').trim()
+	const key = String(iconObjectKey || '').trim()
+	if (!url || !key) return ''
 
-	if (!hasFileSystem()) {
+	const pendingKey = getCacheRuntime() + ':' + key
+	if (pendingIconDownloads.has(pendingKey)) {
+		return pendingIconDownloads.get(pendingKey)
+	}
+
+	const task = (async () => {
 		try {
-			console.log(LOG + ' H5下载并缓存(base64):', iconObjectKey)
-			const base64 = await downloadAsBase64(iconUrl)
-			console.log(LOG + ' H5缓存完成:', iconObjectKey, base64.substring(0, 50) + '...')
-			return base64
+			if (isH5CacheEnabled()) return await downloadAndCacheH5Icon(url, key)
+			if (isAppCacheEnabled()) return await downloadAndCacheAppIcon(url, key)
 		} catch (e) {
-			console.error(LOG + ' H5下载异常:', iconObjectKey, e)
+			console.error(LOG + ' 下载异常:', key, e)
 		}
 		return ''
-	}
+	})()
 
-	const CACHE_DIR = '_app_icons_'
+	pendingIconDownloads.set(pendingKey, task)
 	try {
-		const fs = uni.getFileSystemManager()
-		try { fs.accessSync(CACHE_DIR) } catch (e) { fs.mkdirSync(CACHE_DIR) }
-
-		const localPath = CACHE_DIR + '/' + encodeURIComponent(iconObjectKey) + '.png'
-		try { fs.unlinkSync(localPath) } catch (e) {}
-
-		console.log(LOG + ' 开始下载图标:', iconObjectKey)
-		const res = await uni.downloadFile({ url: iconUrl })
-		if (res.statusCode === 200) {
-			fs.copyFileSync(res.tempFilePath, localPath)
-			console.log(LOG + ' 下载完成并缓存:', iconObjectKey, '->', localPath)
-			return localPath
-		} else {
-			console.warn(LOG + ' 下载失败, statusCode:', res.statusCode, iconObjectKey)
+		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: [] })
 		}
-	} catch (e) {
-		console.error(LOG + ' 下载异常:', iconObjectKey, e)
+		const group = groups.get(key)
+		if (!group.iconUrl && iconUrl) group.iconUrl = iconUrl
+		group.items.push(item)
 	}
-	return ''
+	return Array.from(groups.values())
 }
 
 /**
  * 为一批图标处理缓存:命中则使用缓存,未命中则下载
  * @param {Array} items - 待处理列表,每项需含 iconUrl / iconObjectKey / useRealIcon
- * @param {Function} onUpdate - 全部下载完成后回调,用于触发 UI 刷新
+ * @param {Function} onUpdate - 缓存命中或下载完成后回调,用于触发 UI 刷新
  */
-export function processIconCache(items, onUpdate) {
+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 = {}
+	const newCacheMap = { ...cacheMap }
 	const iconDownloadTasks = []
+	let shouldNotifyUpdate = false
+	let shouldSaveMap = false
 
-	for (const item of items) {
-		if (!item.useRealIcon || !item.iconObjectKey) {
-			if (!item.useRealIcon) {
-				// 没有真实图标的项,不做任何处理
-			}
-			continue
-		}
+	for (const group of groups) {
+		const key = group.key
+		const cachedPath = cacheMap[key]
+		newCacheMap[key] = cachedPath || ''
 
-		const key = String(item.iconObjectKey)
-		newCacheMap[key] = cacheMap[key] || ''
-
-		if (!hasFileSystem()) {
-			// H5:检查缓存值是否为 base64 data URL
-			const isBase64 = cacheMap[key] && String(cacheMap[key]).startsWith('data:image/')
-			if (isBase64) {
-				item.iconPath = cacheMap[key]
-				console.log(LOG + ' H5缓存命中:', key)
-			} else {
-				if (cacheMap[key]) {
-					console.log(LOG + ' H5缓存值无效(非base64),重新下载:', key)
-				} else {
-					console.log(LOG + ' H5首次下载:', key)
-				}
+		if (isH5CacheEnabled() && cachedPath && String(cachedPath).startsWith('data:image/')) {
+			applyIconPath(group.items, cachedPath)
+			shouldNotifyUpdate = true
+			if (shouldRecompressCachedDataUrl(cachedPath)) {
+				console.log(LOG + ' H5缓存较大,重新压缩:', key)
 				iconDownloadTasks.push(
-					downloadAndCacheIcon(item.iconUrl || item.iconPath, key).then(result => {
+					recompressCachedDataUrl(cachedPath).then((result) => {
 						if (result) {
-							item.iconPath = result
+							applyIconPath(group.items, result)
 							newCacheMap[key] = result
+							shouldSaveMap = true
 						}
 					})
 				)
 			}
-		} else if (cacheMap[key]) {
-			// App:检查缓存文件是否存在
-			let fileExists = false
-			try {
-				const fs = uni.getFileSystemManager()
-				fs.accessSync(cacheMap[key])
-				fileExists = true
-			} catch (e) {}
-
-			if (fileExists) {
-				item.iconPath = cacheMap[key]
-				console.log(LOG + ' 命中缓存:', key)
-			} else {
-				console.log(LOG + ' 缓存文件被清理,重新下载:', key)
-				iconDownloadTasks.push(
-					downloadAndCacheIcon(item.iconUrl || item.iconPath, key).then(result => {
-						if (result) {
-							item.iconPath = result
-							newCacheMap[key] = result
-						}
-					})
-				)
+			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
 			}
-		} else if (hasFileSystem()) {
-			console.log(LOG + ' 首次下载:', key)
-			iconDownloadTasks.push(
-				downloadAndCacheIcon(item.iconUrl || item.iconPath, key).then(result => {
-					if (result) {
-						item.iconPath = result
-						newCacheMap[key] = result
-					}
-				})
-			)
+			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 + ' 个图标需要下载')
-		Promise.all(iconDownloadTasks).then(() => {
-			setIconCacheMap(newCacheMap)
-			if (onUpdate) onUpdate()
-			console.log(LOG + ' 全部下载完成,UI已刷新')
-		})
-	} else {
-		console.log(LOG + ' 所有图标均已缓存,无需下载')
+		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 + ' 所有图标均已缓存,无需下载')
 }