瀏覽代碼

V2.5.1 图标缓存

liuq 1 月之前
父節點
當前提交
4297f7b89d

+ 18 - 1
backend/app/api/v1/endpoints/messages.py

@@ -20,7 +20,7 @@ from app.schemas.message import (
 )
 from app.core.websocket_manager import manager
 from app.core.config import settings
-from app.core.minio import minio_storage
+from app.core.minio import minio_storage, resolve_application_icon_url
 from app.services.ticket_service import TicketService
 from datetime import datetime
 from urllib.parse import quote, urlparse, parse_qs, urlencode, urlunparse
@@ -414,6 +414,14 @@ def get_conversations(
                     is_system = True
                     app_id = msg.app_id
                     app_name = msg.app.app_name
+                    raw_icon = (msg.app.icon_url or "").strip()
+                    icon_object_key = (
+                        raw_icon
+                        if raw_icon and not raw_icon.lower().startswith(("http://", "https://"))
+                        else None
+                    )
+                    icon_display = resolve_application_icon_url(msg.app.icon_url)
+                    application_app_id = msg.app.app_id
                 else:
                     # 老的统一系统通知
                     username = "System"
@@ -421,6 +429,9 @@ def get_conversations(
                     is_system = True
                     app_id = None
                     app_name = None
+                    icon_object_key = None
+                    icon_display = None
+                    application_app_id = None
             else:
                 # 普通用户会话
                 username = other_user.mobile  # User has mobile, not username
@@ -428,6 +439,9 @@ def get_conversations(
                 is_system = False
                 app_id = None
                 app_name = None
+                icon_object_key = None
+                icon_display = None
+                application_app_id = None
 
             conversations_map[other_id] = {
                 "user_id": other_id,
@@ -440,6 +454,9 @@ def get_conversations(
                 "is_system": is_system,
                 "app_id": app_id,
                 "app_name": app_name,
+                "application_app_id": application_app_id,
+                "icon_url": icon_display,
+                "icon_object_key": icon_object_key,
                 "remarks": _conversation_remarks(msg, other_user),
             }
         

+ 7 - 0
backend/app/api/v1/endpoints/simple_auth.py

@@ -534,6 +534,12 @@ def get_launchpad_apps(
     result = []
     for m in mappings:
         app = m.application
+        raw_s = ((app.icon_url or "").strip() if app else "")
+        icon_object_key = (
+            raw_s
+            if raw_s and not raw_s.lower().startswith(("http://", "https://"))
+            else None
+        )
         icon_display = resolve_application_icon_url(app.icon_url) if app else None
         result.append(LaunchpadAppResponse(
             app_name=app.app_name if app else "Unknown",
@@ -546,6 +552,7 @@ def get_launchpad_apps(
             category_id=app.category_id if app else None,
             category_name=app.category.name if app and app.category else None,
             icon_url=icon_display,
+            icon_object_key=icon_object_key,
         ))
     
     return {"total": len(result), "items": result}

+ 4 - 0
backend/app/schemas/message.py

@@ -83,6 +83,10 @@ class ConversationResponse(BaseModel):
     is_system: bool = False
     app_id: Optional[int] = None
     app_name: Optional[str] = None
+    # 业务侧 Application.app_id 字符串(与 SSO / 客户端缓存键一致);仅按应用拆分的系统会话可能有值
+    application_app_id: Optional[str] = None
+    icon_url: Optional[str] = None
+    icon_object_key: Optional[str] = None
 
     # 列表副文案:有 app 的应用通知为「应用通知」;无 app 的旧系统会话为空;用户会话为对端组织名,无组织为空
     remarks: Optional[str] = None

+ 1 - 0
backend/app/schemas/simple_auth.py

@@ -190,6 +190,7 @@ class LaunchpadAppResponse(BaseModel):
     category_id: Optional[int] = None  # 分类ID
     category_name: Optional[str] = None  # 分类名称
     icon_url: Optional[str] = None  # 解析后的 Logo 地址(预签名或直链)
+    icon_object_key: Optional[str] = None  # MinIO object key(非 http(s)),供前端 IndexedDB 稳定缓存键
 
 class LaunchpadAppsResponse(BaseModel):
     """快捷导航应用列表响应"""

+ 28 - 3
frontend/public/docs/client_api_guide.md

@@ -97,6 +97,9 @@ Content-Type: application/json
 | `is_system` | boolean | 是否系统/应用通知会话。 |
 | `app_id` | number \| null | 应用主键(通知会话时可能有)。 |
 | `app_name` | string \| null | 应用名称。 |
+| `application_app_id` | string \| null | 业务侧应用 ID(与 SSO、快捷导航一致);仅在与应用绑定的系统通知会话(通常 `user_id < 0`)时有值,私信等为 `null`。 |
+| `icon_url` | string \| null | 应用 Logo 展示地址:对象存储场景下多为短时预签名 GET URL;库里为完整 HTTP(S) 直链则一致;无私信会话 Logo。 |
+| `icon_object_key` | string \| null | Logo 存于平台对象存储时为桶内路径(稳定,不随预签名轮换);直链或未配置为 `null`。可与 `application_app_id` 作客户端本地缓存键,语义同 §7.1「快捷导航」中 `items[].icon_object_key`。 |
 | `remarks` | string \| null | 列表副文案:有 `app_id` 的应用通知会话为 `"应用通知"`;`NOTIFICATION` 且无 `app_id` 的旧会话为 `null`;与用户的私信会话为**对端用户**所属组织名称,对端无组织则为 `null`。 |
 
 请求与响应示例:
@@ -119,7 +122,26 @@ Authorization: Bearer <JWT_TOKEN>
     "is_system": true,
     "app_id": 101,
     "app_name": "OA系统",
+    "application_app_id": "oa_system",
+    "icon_url": "https://files.example.com/presigned/app_icon/oa.png?X-Amz-Expires=3600",
+    "icon_object_key": "app_icon/101/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png",
     "remarks": "应用通知"
+  },
+  {
+    "user_id": 2048,
+    "username": "13800138000",
+    "full_name": "张三",
+    "unread_count": 1,
+    "last_message": "你好",
+    "last_message_type": "TEXT",
+    "updated_at": "2026-03-18T09:20:00",
+    "is_system": false,
+    "app_id": null,
+    "app_name": null,
+    "application_app_id": null,
+    "icon_url": null,
+    "icon_object_key": null,
+    "remarks": "某某科技有限公司"
   }
 ]
 ```
@@ -1001,7 +1023,8 @@ Authorization: Bearer <JWT_TOKEN>
 | `items[].description` | string \| null | 应用描述 |
 | `items[].category_id` | integer \| null | 应用所属分类 ID |
 | `items[].category_name` | string \| null | 应用所属分类名称 |
-| `items[].icon_url` | string \| null | 应用 Logo 展示地址(预签名 URL 或直链,依平台存储配置而定) |
+| `items[].icon_url` | string \| null | Logo 展示用地址:**对象存储**场景下一般为**短时预签名 GET** URL(签名与过期等查询参数会轮换);库内若为完整 HTTP(S) 直链则与该直链一致 |
+| `items[].icon_object_key` | string \| null | Logo 存于平台对象存储时为**桶内对象路径**(稳定,不随预签名轮换);直链或未配置时为 `null`。客户端可将 `items[].app_id` 与本品组合用作**本地缓存键**,用当期 `icon_url` 下载;Logo 更换后通常为**新路径**,便于失效旧缓存 |
 
 #### 7.1.3 响应示例
 
@@ -1019,7 +1042,8 @@ Authorization: Bearer <JWT_TOKEN>
       "description": "办公自动化系统",
       "category_id": 1,
       "category_name": "办公协同",
-      "icon_url": "https://files.example.com/presigned/app_icon/oa.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600"
+      "icon_url": "https://files.example.com/presigned/app_icon/oa.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600",
+      "icon_object_key": "app_icon/42/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
     },
     {
       "app_name": "CRM客户管理",
@@ -1031,7 +1055,8 @@ Authorization: Bearer <JWT_TOKEN>
       "description": null,
       "category_id": null,
       "category_name": null,
-      "icon_url": null
+      "icon_url": null,
+      "icon_object_key": null
     }
   ]
 }

+ 91 - 21
frontend/src/components/PlatformIcon.vue

@@ -6,7 +6,7 @@
   >
     <div
       class="platform-icon"
-      :class="{ 'has-remote-icon': effectiveIconUrl && !imageLoadFailed }"
+      :class="{ 'has-remote-icon': displayResolvedUrl && !imageLoadFailed }"
       :style="{
         width: size + 'px',
         height: size + 'px',
@@ -15,9 +15,9 @@
       }"
     >
       <img
-        v-if="effectiveIconUrl && !imageLoadFailed"
+        v-if="displayResolvedUrl && !imageLoadFailed"
         class="icon-image"
-        :src="effectiveIconUrl"
+        :src="displayResolvedUrl"
         alt=""
         @error="onImgError"
       />
@@ -41,7 +41,7 @@
       </div>
     </div>
     <div
-      v-if="effectiveIconUrl && !imageLoadFailed"
+      v-if="showCaption && displayResolvedUrl && !imageLoadFailed"
       class="platform-icon-caption"
     >
       {{ text }}
@@ -50,43 +50,113 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
 import { stringToColor, calculateFontSize } from '../utils/color'
+import { isBlobDisplayUrl, resolveLogoDisplayUrl } from '../utils/appIconCache'
 
-const props = defineProps<{
-  text: string
-  size: number
-  /** 解析后的 Logo URL;加载失败时回退为文字渐变图标 */
-  iconUrl?: string | null
-}>()
+const props = withDefaults(
+  defineProps<{
+    text: string
+    size: number
+    /** 解析后的 Logo URL;加载失败时回退为文字渐变图标 */
+    iconUrl?: string | null
+    /** 与应用缓存键前缀一起使用(MinIO icon 时需传) */
+    appId?: string | null
+    /** 对象存储 object key(非 http(s)) */
+    iconObjectKey?: string | null
+    /** 是否在图标下方展示应用名称 */
+    showCaption?: boolean
+    /** rounded:快捷导航类圆角矩形;circle:正圆(消息中心等) */
+    shape?: 'rounded' | 'circle'
+  }>(),
+  {
+    showCaption: true,
+    shape: 'rounded',
+  }
+)
 
 defineEmits(['click'])
 
 const imageLoadFailed = ref(false)
+const displayResolvedUrl = ref('')
+let resolveGeneration = 0
 
-const effectiveIconUrl = computed(() => {
-  const u = props.iconUrl?.trim()
-  return u || ''
-})
+function revokeIfBlob(url: string) {
+  if (isBlobDisplayUrl(url)) {
+    URL.revokeObjectURL(url)
+  }
+}
 
-watch(
-  () => props.iconUrl,
-  () => {
-    imageLoadFailed.value = false
+function resetDisplayResolved() {
+  revokeIfBlob(displayResolvedUrl.value)
+  displayResolvedUrl.value = ''
+}
+
+async function refreshDisplayUrl() {
+  const gen = ++resolveGeneration
+  imageLoadFailed.value = false
+
+  const raw = props.iconUrl?.trim() ?? ''
+  resetDisplayResolved()
+
+  if (!raw) {
+    return
   }
+
+  if (!props.iconObjectKey?.trim()) {
+    displayResolvedUrl.value = raw
+    return
+  }
+
+  try {
+    const url = await resolveLogoDisplayUrl({
+      appId: props.appId?.trim() || '_',
+      iconObjectKey: props.iconObjectKey,
+      iconUrl: raw,
+    })
+    if (gen !== resolveGeneration) {
+      revokeIfBlob(url)
+      return
+    }
+    displayResolvedUrl.value = url
+  } catch {
+    if (gen !== resolveGeneration) {
+      return
+    }
+    displayResolvedUrl.value = raw
+  }
+}
+
+watch(
+  () => [
+    props.iconUrl,
+    props.iconObjectKey,
+    props.appId,
+  ],
+  refreshDisplayUrl,
+  { immediate: true }
 )
 
+onBeforeUnmount(() => {
+  ++resolveGeneration
+  resetDisplayResolved()
+})
+
 const onImgError = () => {
+  revokeIfBlob(displayResolvedUrl.value)
+  displayResolvedUrl.value = ''
   imageLoadFailed.value = true
 }
 
 const showFallback = computed(
-  () => !effectiveIconUrl.value || imageLoadFailed.value
+  () => !displayResolvedUrl.value || imageLoadFailed.value
 )
 
 const backgroundColor = computed(() => stringToColor(props.text))
 
-const borderRadius = computed(() => props.size * 0.22)
+const borderRadius = computed(() =>
+  props.shape === 'circle' ? props.size / 2 : props.size * 0.22
+)
 
 const fontSize = computed(() => calculateFontSize(props.size, props.text.length))
 </script>

+ 101 - 0
frontend/src/utils/appIconCache.ts

@@ -0,0 +1,101 @@
+const DB_NAME = 'uan-app-icons'
+const STORE = 'blobs'
+const DB_VERSION = 1
+
+let dbPromise: Promise<IDBDatabase> | null = null
+
+/** 稳定缓存键:`app_id` + 对象存储路径(不含预签名) */
+export function getStableKey(appId: string, objectKey: string): string {
+  return `${appId}::${objectKey}`
+}
+
+export function isBlobDisplayUrl(url: string): boolean {
+  return !!url && url.startsWith('blob:')
+}
+
+function openDb(): Promise<IDBDatabase> {
+  if (typeof indexedDB === 'undefined') {
+    return Promise.reject(new Error('indexedDB unsupported'))
+  }
+  if (!dbPromise) {
+    dbPromise = new Promise((resolve, reject) => {
+      const req = indexedDB.open(DB_NAME, DB_VERSION)
+      req.onerror = () => reject(req.error)
+      req.onsuccess = () => resolve(req.result)
+      req.onupgradeneeded = () => {
+        const db = req.result
+        if (!db.objectStoreNames.contains(STORE)) {
+          db.createObjectStore(STORE)
+        }
+      }
+    })
+  }
+  return dbPromise
+}
+
+async function getCachedBlob(appId: string, objectKey: string): Promise<Blob | undefined> {
+  try {
+    const db = await openDb()
+    return await new Promise((resolve, reject) => {
+      const tx = db.transaction(STORE, 'readonly')
+      const req = tx.objectStore(STORE).get(getStableKey(appId, objectKey))
+      req.onerror = () => reject(req.error)
+      req.onsuccess = () => resolve(req.result as Blob | undefined)
+    })
+  } catch {
+    return undefined
+  }
+}
+
+async function putCachedBlob(appId: string, objectKey: string, blob: Blob): Promise<void> {
+  const db = await openDb()
+  await new Promise<void>((resolve, reject) => {
+    const tx = db.transaction(STORE, 'readwrite')
+    tx.oncomplete = () => resolve()
+    tx.onerror = () => reject(tx.error)
+    tx.onabort = () => reject(tx.error)
+    tx.objectStore(STORE).put(blob, getStableKey(appId, objectKey))
+  })
+}
+
+/**
+ * 返回用于展示的 URL:`https` 直链或可 revoke 的 `blob:` URL(带 object_key 时在 IndexedDB 中复用)。
+ */
+export async function resolveLogoDisplayUrl(opts: {
+  appId: string
+  iconObjectKey?: string | null
+  iconUrl?: string | null
+}): Promise<string> {
+  const u = opts.iconUrl?.trim() ?? ''
+  if (!u) {
+    return ''
+  }
+
+  const key = opts.iconObjectKey?.trim()
+  if (!key) {
+    return u
+  }
+
+  const appId = opts.appId?.trim() || '_'
+
+  const cached = await getCachedBlob(appId, key)
+  if (cached) {
+    return URL.createObjectURL(cached)
+  }
+
+  try {
+    const res = await fetch(u, { mode: 'cors' })
+    if (!res.ok) {
+      return u
+    }
+    const blob = await res.blob()
+    try {
+      await putCachedBlob(appId, key, blob)
+    } catch {
+      /* 存储失败仍展示本次下载 */
+    }
+    return URL.createObjectURL(blob)
+  } catch {
+    return u
+  }
+}

+ 7 - 0
frontend/src/views/PlatformLaunchpad.vue

@@ -30,6 +30,8 @@
               :text="app.app_name"
               :size="iconSize"
               :icon-url="app.icon_url"
+              :app-id="app.app_id"
+              :icon-object-key="app.icon_object_key"
               @click="handleAppClick(app)"
             />
           </div>
@@ -55,6 +57,8 @@
                 :text="app.app_name"
                 :size="iconSize"
                 :icon-url="app.icon_url"
+                :app-id="app.app_id"
+                :icon-object-key="app.icon_object_key"
                 @click="handleAppClick(app)"
               />
             </div>
@@ -76,6 +80,8 @@
               :text="app.app_name"
               :size="iconSize"
               :icon-url="app.icon_url"
+              :app-id="app.app_id"
+              :icon-object-key="app.icon_object_key"
               @click="handleAppClick(app)"
             />
           </div>
@@ -124,6 +130,7 @@ interface Mapping {
   category_name?: string
   /** 解析后的 Logo URL */
   icon_url?: string | null
+  icon_object_key?: string | null
 }
 
 interface CategoryGroup {

+ 23 - 12
frontend/src/views/apps/AppList.vue

@@ -23,12 +23,16 @@
       <el-table-column prop="id" label="ID" width="80" />
       <el-table-column label="Logo" width="76" align="center">
         <template #default="scope">
-          <img
-            v-if="scope.row.icon_url"
-            :src="scope.row.icon_url"
-            class="app-logo-thumb"
-            alt=""
-          />
+          <div v-if="scope.row.icon_url" class="logo-cell">
+            <PlatformIcon
+              :text="scope.row.app_name"
+              :size="40"
+              :icon-url="scope.row.icon_url"
+              :app-id="scope.row.app_id"
+              :icon-object-key="scope.row.icon_object_key"
+              :show-caption="false"
+            />
+          </div>
           <span v-else class="logo-placeholder">—</span>
         </template>
       </el-table-column>
@@ -375,6 +379,7 @@ import { searchUsers, getUserById, User } from '../../api/users'
 import { sendImportVerificationCode } from '../../api/mapping'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ArrowDown, Search } from '@element-plus/icons-vue'
+import PlatformIcon from '../../components/PlatformIcon.vue'
 
 const router = useRouter()
 const authStore = useAuthStore()
@@ -954,14 +959,20 @@ onMounted(() => {
   border-radius: 4px;
   border: 1px dashed #dcdfe6;
 }
-.app-logo-thumb {
-  width: 40px;
-  height: 40px;
-  object-fit: cover;
-  border-radius: 8px;
+.logo-cell {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.logo-cell :deep(.platform-icon-cell) {
+  cursor: default;
+}
+
+.logo-cell :deep(.platform-icon) {
   border: 1px solid #eee;
-  vertical-align: middle;
 }
+
 .logo-placeholder {
   color: #c0c4cc;
   font-size: 14px;

+ 6 - 3
frontend/src/views/help/AccountManagement.vue

@@ -86,7 +86,8 @@
           <tr><td><code>items[].mapped_key</code> / <code>mapped_email</code></td><td>映射账号、邮箱(可空)</td></tr>
           <tr><td><code>items[].description</code></td><td>应用描述(可空)</td></tr>
           <tr><td><code>items[].category_id</code> / <code>category_name</code></td><td>分类(可空)</td></tr>
-          <tr><td><code>items[].icon_url</code></td><td>Logo URL(可空,可能为预签名链接)</td></tr>
+          <tr><td><code>items[].icon_url</code></td><td>Logo 展示 URL(可空;对象存储时为预签名短链)</td></tr>
+          <tr><td><code>items[].icon_object_key</code></td><td>对象存储时的稳定对象路径(可空);可与 <code>app_id</code> 作客户端缓存键,详见《客户端接口 API 指南》§8</td></tr>
         </tbody>
       </table>
       <h5>3.1.2 请求示例</h5>
@@ -117,7 +118,8 @@ curl -sS -X GET "{{API_BASE_URL}}/simple/me/launchpad-apps" \
       "description": "办公自动化系统",
       "category_id": 1,
       "category_name": "办公协同",
-      "icon_url": "https://files.example.com/presigned/app_icon/oa.png"
+      "icon_url": "https://files.example.com/presigned/app_icon/oa.png",
+      "icon_object_key": "app_icon/42/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
     },
     {
       "app_name": "CRM客户管理",
@@ -129,7 +131,8 @@ curl -sS -X GET "{{API_BASE_URL}}/simple/me/launchpad-apps" \
       "description": null,
       "category_id": null,
       "category_name": null,
-      "icon_url": null
+      "icon_url": null,
+      "icon_object_key": null
     }
   ]
 }

+ 15 - 3
frontend/src/views/help/ClientApi.vue

@@ -112,6 +112,9 @@ Content-Type: application/json
           <tr><td><code>is_system</code></td><td>boolean</td><td>是否系统/应用通知会话。</td></tr>
           <tr><td><code>app_id</code></td><td>number | null</td><td>应用主键(通知会话时有值)。</td></tr>
           <tr><td><code>app_name</code></td><td>string | null</td><td>应用名称。</td></tr>
+          <tr><td><code>application_app_id</code></td><td>string | null</td><td>业务侧应用 ID(与 SSO、快捷导航一致);按应用拆分的系统通知会话(通常 <code>user_id &lt; 0</code>)时有值,私信等为 <code>null</code>。</td></tr>
+          <tr><td><code>icon_url</code></td><td>string | null</td><td>应用 Logo:对象存储时为短时预签名 URL;直链或未配置为可空。</td></tr>
+          <tr><td><code>icon_object_key</code></td><td>string | null</td><td>对象存储时为稳定对象路径;可与 <code>application_app_id</code> 作本地缓存键,说明同 §8「快捷导航」<code>icon_object_key</code>。</td></tr>
           <tr><td><code>remarks</code></td><td>string | null</td><td>列表副文案:有 <code>app_id</code> 的应用通知为「应用通知」;无 <code>app_id</code> 的旧系统通知为 <code>null</code>;私信为对端组织名,无组织为 <code>null</code>。</td></tr>
         </tbody>
       </table>
@@ -137,6 +140,9 @@ Content-Type: application/json
     "is_system": true,
     "app_id": 101,
     "app_name": "OA系统",
+    "application_app_id": "oa_system",
+    "icon_url": "https://files.example.com/presigned/app_icon/oa.png?X-Amz-Expires=3600",
+    "icon_object_key": "app_icon/101/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png",
     "remarks": "应用通知"
   },
   {
@@ -150,6 +156,9 @@ Content-Type: application/json
     "is_system": false,
     "app_id": null,
     "app_name": null,
+    "application_app_id": null,
+    "icon_url": null,
+    "icon_object_key": null,
     "remarks": "某某科技有限公司"
   }
 ]</pre>
@@ -727,7 +736,8 @@ Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
           <tr><td><code>items[].description</code></td><td>string | null</td><td>应用描述</td></tr>
           <tr><td><code>items[].category_id</code></td><td>number | null</td><td>分类 ID</td></tr>
           <tr><td><code>items[].category_name</code></td><td>string | null</td><td>分类名称</td></tr>
-          <tr><td><code>items[].icon_url</code></td><td>string | null</td><td>Logo 地址(预签名 URL 或直链)</td></tr>
+          <tr><td><code>items[].icon_url</code></td><td>string | null</td><td>Logo 展示用地址:<strong>对象存储场景下</strong>为<strong>短时预签名 GET</strong> URL(签名与过期参数会轮换);若库内写的是完整 HTTP(S) 直链则与原值一致。</td></tr>
+          <tr><td><code>items[].icon_object_key</code></td><td>string | null</td><td>当 Logo 存于平台对象存储时为<strong>桶内对象路径</strong>(稳定,不随签名变化);直链或未配置 Logo 时为 <code>null</code>。自建 Web/移动端可用 <code>app_id</code> + <code>icon_object_key</code> 作本地缓存键,用当期 <code>icon_url</code> 拉取二进制;Logo 替换后后端会换新路径,缓存键也随之更新。</td></tr>
         </tbody>
       </table>
 
@@ -764,7 +774,8 @@ curl -sS -X GET "https://your-host.example.com/api/v1/simple/me/launchpad-apps"
       "description": "办公自动化系统",
       "category_id": 1,
       "category_name": "办公协同",
-      "icon_url": "https://files.example.com/presigned/app_icon/oa.png?X-Amz-Expires=3600"
+      "icon_url": "https://files.example.com/presigned/app_icon/oa.png?X-Amz-Expires=3600",
+      "icon_object_key": "app_icon/42/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
     },
     {
       "app_name": "CRM客户管理",
@@ -776,7 +787,8 @@ curl -sS -X GET "https://your-host.example.com/api/v1/simple/me/launchpad-apps"
       "description": null,
       "category_id": null,
       "category_name": null,
-      "icon_url": null
+      "icon_url": null,
+      "icon_object_key": null
     }
   ]
 }</pre>

+ 81 - 5
frontend/src/views/message/index.vue

@@ -32,10 +32,22 @@
           :class="{ active: currentChatId === chat.user_id }"
           @click="selectChat(chat)"
         >
-          <UserAvatar 
-            :name="chat.full_name || chat.username || '未知'" 
-            :userId="chat.user_id" 
-            :size="40" 
+          <PlatformIcon
+            v-if="hasSystemAppLogo(chat)"
+            class="msg-conv-avatar"
+            :text="chat.app_name || chat.full_name || chat.username || '应用'"
+            :size="40"
+            shape="circle"
+            :icon-url="chat.icon_url"
+            :app-id="chat.application_app_id"
+            :icon-object-key="chat.icon_object_key"
+            :show-caption="false"
+          />
+          <UserAvatar
+            v-else
+            :name="chat.full_name || chat.username || '未知'"
+            :userId="chat.user_id"
+            :size="40"
           />
           
           <div class="chat-info">
@@ -70,6 +82,17 @@
       <template v-if="currentChatId !== null">
         <!-- 顶部标题 -->
         <header class="chat-header-bar">
+          <PlatformIcon
+            v-if="hasSystemAppLogo(currentChatUser)"
+            class="chat-header-app-icon"
+            :text="currentChatUser.app_name || currentChatUser.full_name || '应用'"
+            :size="36"
+            shape="circle"
+            :icon-url="currentChatUser.icon_url"
+            :app-id="currentChatUser.application_app_id"
+            :icon-object-key="currentChatUser.icon_object_key"
+            :show-caption="false"
+          />
           <div class="chat-header-titles">
             <h3>
               <!-- 系统会话优先显示应用名 -->
@@ -96,7 +119,18 @@
               <div class="notification-card">
                 <!-- 发送者信息 -->
                 <div v-if="msg.app_id || msg.type === 'NOTIFICATION'" class="notification-sender">
-                  <el-icon class="sender-icon"><House /></el-icon>
+                  <PlatformIcon
+                    v-if="hasSystemAppLogo(currentChatUser)"
+                    class="notification-sender-logo"
+                    :text="getAppName(msg) || currentChatUser?.app_name || '应用'"
+                    :size="22"
+                    shape="circle"
+                    :icon-url="currentChatUser?.icon_url"
+                    :app-id="currentChatUser?.application_app_id"
+                    :icon-object-key="currentChatUser?.icon_object_key"
+                    :show-caption="false"
+                  />
+                  <el-icon v-else class="sender-icon"><House /></el-icon>
                   <span class="sender-name">{{ getAppName(msg) || '系统通知' }}</span>
                 </div>
                 
@@ -283,6 +317,7 @@
 <script setup lang="ts">
 import { ref, onMounted, onUnmounted, onActivated, computed, nextTick } from 'vue'
 import UserAvatar from '@/components/UserAvatar.vue'
+import PlatformIcon from '@/components/PlatformIcon.vue'
 import { Picture, Document, House, ArrowRight } from '@element-plus/icons-vue'
 import api from '@/utils/request'
 import { useAuthStore } from '@/store/auth'
@@ -311,6 +346,14 @@ const userOptions = ref<any[]>([])
 const searchingUsers = ref(false)
 const markingAllRead = ref(false)
 
+/** 按应用拆分的系统会话(user_id < 0)且有 Logo URL 或 object key */
+const hasSystemAppLogo = (c: Record<string, unknown> | null | undefined) => {
+  if (!c || !c.is_system || typeof c.user_id !== 'number' || c.user_id >= 0) return false
+  const u = String((c.icon_url as string) || '').trim()
+  const k = String((c.icon_object_key as string) || '').trim()
+  return !!(u || k)
+}
+
 // 应用名称缓存
 const appNameCache = ref<Record<number, string>>({})
 
@@ -713,6 +756,38 @@ const handleNotificationAction = async (msg: any) => {
   transition: background 0.2s;
 }
 
+.msg-conv-avatar {
+  flex-shrink: 0;
+}
+
+.msg-conv-avatar :deep(.platform-icon-cell) {
+  cursor: inherit;
+}
+
+.chat-header-app-icon {
+  flex-shrink: 0;
+}
+
+.chat-header-app-icon :deep(.platform-icon-cell) {
+  cursor: default;
+}
+
+.notification-sender-logo {
+  flex-shrink: 0;
+  margin-right: 6px;
+}
+
+.notification-sender-logo :deep(.platform-icon-cell) {
+  cursor: default;
+}
+
+.notification-sender-logo :deep(.platform-icon:hover) {
+  transform: none;
+  box-shadow:
+    0 4px 6px -1px rgba(0, 0, 0, 0.1),
+    0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
 .chat-item:hover {
   background: #e9e9e9;
 }
@@ -794,6 +869,7 @@ const handleNotificationAction = async (msg: any) => {
   padding: 10px 20px;
   display: flex;
   align-items: center;
+  gap: 12px;
   background: #f5f5f5;
 }