浏览代码

应用中心自定义图标

liuq 4 周之前
父节点
当前提交
6057745728

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yunzhu-im",
-  "version": "1.0.13",
+  "version": "1.1.6",
   "main": "./out/main/index.js",
   "author": "example.com",
   "license": "MIT",

+ 244 - 2
src/main/index.ts

@@ -1,9 +1,9 @@
-import { app, shell, session, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, dialog, screen, type WebContents } from 'electron'
+import { app, shell, session, protocol, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, dialog, screen, type WebContents } from 'electron'
 import { basename, extname, join } from 'path'
 import { fileURLToPath } from 'url'
 import { autoUpdater } from 'electron-updater'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
-import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync } from 'fs'
+import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs'
 import { writeFile } from 'fs/promises'
 // --- 日志管理 ---
 let logDir: string = ''
@@ -30,6 +30,19 @@ function writeLog(message: string) {
   }
 }
 
+/** 渲染进程 `<img>` 加载 userData 下启动台图标;须在 app.ready 前登记 */
+protocol.registerSchemesAsPrivileged([
+  {
+    scheme: 'launchpad-cache',
+    privileges: {
+      standard: true,
+      secure: true,
+      supportFetchAPI: true,
+      corsEnabled: true
+    }
+  }
+])
+
 ipcMain.on('log-message', (_, message) => {
   writeLog(message)
 })
@@ -61,6 +74,214 @@ function isHttpUrl(url: string): boolean {
   }
 }
 
+const LAUNCHPAD_ICON_SUBDIR = 'launchpad-icons'
+
+function getLaunchpadIconCacheDir(): string {
+  return join(app.getPath('userData'), LAUNCHPAD_ICON_SUBDIR)
+}
+
+function toSafeLaunchpadAppId(raw: string): string | null {
+  const s = String(raw || '').trim()
+  if (s.length === 0 || s.length > 128) return null
+  if (!/^[a-zA-Z0-9_-]+$/.test(s)) return null
+  return s
+}
+
+interface LaunchpadIconMeta {
+  icon_object_key: string | null
+  last_icon_url: string
+  imageBasename: string
+  savedAt: string
+}
+
+function extFromContentType(ct: string | null | undefined): string {
+  if (!ct) return '.bin'
+  const low = ct.split(';')[0].trim().toLowerCase()
+  if (low.includes('png')) return '.png'
+  if (low.includes('jpeg') || low.includes('jpg')) return '.jpg'
+  if (low.includes('gif')) return '.gif'
+  if (low.includes('webp')) return '.webp'
+  if (low.includes('svg')) return '.svg'
+  return '.bin'
+}
+
+function extFromImageBuffer(buf: Buffer): string {
+  if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return '.png'
+  if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return '.jpg'
+  const head = buf.length >= 6 ? buf.toString('ascii', 0, 6) : ''
+  if (head === 'GIF87a' || head === 'GIF89a') return '.gif'
+  if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return '.webp'
+  if (buf.length >= 100) {
+    const sniff = buf.toString('utf8', 0, Math.min(200, buf.length)).toLowerCase()
+    if (sniff.includes('<svg')) return '.svg'
+  }
+  return '.bin'
+}
+
+function clearLaunchpadIconFilesSync(safeId: string): void {
+  const dir = getLaunchpadIconCacheDir()
+  if (!existsSync(dir)) return
+  for (const f of readdirSync(dir)) {
+    if (f === `${safeId}.meta.json` || (f.startsWith(`${safeId}.`) && f !== `${safeId}.meta.json`)) {
+      try {
+        unlinkSync(join(dir, f))
+      } catch {
+        // ignore
+      }
+    }
+  }
+}
+
+function isLaunchpadCacheCurrent(
+  meta: LaunchpadIconMeta | null,
+  iconUrl: string,
+  iconObjectKey: string | null
+): boolean {
+  if (!meta) return false
+  if (iconObjectKey != null) {
+    return (meta.icon_object_key || null) === iconObjectKey
+  }
+  return (meta.last_icon_url || '') === iconUrl
+}
+
+type LaunchpadIconResolveResult = { kind: 'custom'; url: string } | { kind: 'placeholder' }
+
+const launchpadIconInflight = new Map<string, Promise<LaunchpadIconResolveResult>>()
+
+function launchpadIconInflightKey(safeId: string, iconUrl: string, iconObjectKey: string | null): string {
+  return `${safeId}\n${iconUrl}\n${iconObjectKey ?? ''}`
+}
+
+async function resolveLaunchpadIconOnce(
+  safeId: string,
+  iconUrl: string,
+  iconObjectKey: string | null
+): Promise<LaunchpadIconResolveResult> {
+  const dir = getLaunchpadIconCacheDir()
+  mkdirSync(dir, { recursive: true })
+  const metaPath = join(dir, `${safeId}.meta.json`)
+
+  let meta: LaunchpadIconMeta | null = null
+  try {
+    if (existsSync(metaPath)) {
+      meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as LaunchpadIconMeta
+    }
+  } catch {
+    meta = null
+  }
+
+  const base = meta?.imageBasename
+  const imagePath =
+    base && !base.includes('..') && !base.includes('/') && !base.includes('\\') && base.startsWith(`${safeId}.`)
+      ? join(dir, base)
+      : null
+
+  if (imagePath && existsSync(imagePath) && isLaunchpadCacheCurrent(meta, iconUrl, iconObjectKey)) {
+    return { kind: 'custom', url: `launchpad-cache://${safeId}/icon` }
+  }
+
+  clearLaunchpadIconFilesSync(safeId)
+
+  const res = await fetch(iconUrl)
+  if (!res.ok) {
+    throw new Error(`download icon HTTP ${res.status}`)
+  }
+  const buf = Buffer.from(await res.arrayBuffer())
+  let ext = extFromContentType(res.headers.get('content-type'))
+  if (ext === '.bin') ext = extFromImageBuffer(buf)
+  const imageBasename = `${safeId}${ext}`
+  const outPath = join(dir, imageBasename)
+  await writeFile(outPath, buf)
+
+  const nextMeta: LaunchpadIconMeta = {
+    icon_object_key: iconObjectKey,
+    last_icon_url: iconUrl,
+    imageBasename,
+    savedAt: new Date().toISOString()
+  }
+  writeFileSync(metaPath, JSON.stringify(nextMeta), 'utf-8')
+
+  return { kind: 'custom', url: `launchpad-cache://${safeId}/icon` }
+}
+
+async function launchpadIconResolveHandler(
+  appId: string,
+  iconUrlRaw: string | null | undefined,
+  iconObjectKeyRaw: string | null | undefined
+): Promise<LaunchpadIconResolveResult> {
+  const iconUrl = typeof iconUrlRaw === 'string' ? iconUrlRaw.trim() : ''
+  if (!iconUrl || !isHttpUrl(iconUrl)) {
+    return { kind: 'placeholder' }
+  }
+  const safeId = toSafeLaunchpadAppId(appId)
+  if (!safeId) {
+    return { kind: 'placeholder' }
+  }
+  const iconObjectKey =
+    typeof iconObjectKeyRaw === 'string' && iconObjectKeyRaw.trim() ? iconObjectKeyRaw.trim() : null
+
+  const ikey = launchpadIconInflightKey(safeId, iconUrl, iconObjectKey)
+  const existing = launchpadIconInflight.get(ikey)
+  if (existing) return existing
+
+  const p = resolveLaunchpadIconOnce(safeId, iconUrl, iconObjectKey).finally(() => {
+    if (launchpadIconInflight.get(ikey) === p) {
+      launchpadIconInflight.delete(ikey)
+    }
+  })
+  launchpadIconInflight.set(ikey, p)
+  return p
+}
+
+function registerLaunchpadIconProtocol(): void {
+  const dir = getLaunchpadIconCacheDir()
+  mkdirSync(dir, { recursive: true })
+
+  try {
+    if (session.defaultSession.protocol.isProtocolRegistered('launchpad-cache')) {
+      session.defaultSession.protocol.unregisterProtocol('launchpad-cache')
+    }
+  } catch {
+    // ignore
+  }
+
+  session.defaultSession.protocol.registerFileProtocol('launchpad-cache', (request, callback) => {
+    try {
+      const parsed = new URL(request.url)
+      const safeId = parsed.hostname
+      if (!toSafeLaunchpadAppId(safeId)) {
+        callback({ error: -6 })
+        return
+      }
+      const metaPath = join(dir, `${safeId}.meta.json`)
+      if (!existsSync(metaPath)) {
+        callback({ error: -6 })
+        return
+      }
+      let meta: LaunchpadIconMeta
+      try {
+        meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as LaunchpadIconMeta
+      } catch {
+        callback({ error: -6 })
+        return
+      }
+      const base = meta.imageBasename
+      if (!base || base.includes('..') || base.includes('/') || base.includes('\\') || !base.startsWith(`${safeId}.`)) {
+        callback({ error: -6 })
+        return
+      }
+      const full = join(dir, base)
+      if (!existsSync(full)) {
+        callback({ error: -6 })
+        return
+      }
+      callback({ path: full })
+    } catch {
+      callback({ error: -6 })
+    }
+  })
+}
+
 const STARTUP_PREFERENCE_FILE = 'startup-preference.json'
 
 interface StartupPreference {
@@ -1004,6 +1225,27 @@ app.whenReady().then(() => {
     return on
   })
 
+  registerLaunchpadIconProtocol()
+
+  ipcMain.handle('launchpad-icon-resolve', async (_, payload: unknown) => {
+    const p = payload as {
+      app_id?: unknown
+      icon_url?: unknown
+      icon_object_key?: unknown
+    }
+    const appId = typeof p?.app_id === 'string' ? p.app_id : ''
+    try {
+      return await launchpadIconResolveHandler(
+        appId,
+        p.icon_url as string | null | undefined,
+        p.icon_object_key as string | null | undefined
+      )
+    } catch (e) {
+      console.error('launchpad-icon-resolve', e)
+      return { kind: 'placeholder' as const }
+    }
+  })
+
   // 生产环境自动更新(自建服务器)
   if (!is.dev) {
     autoUpdater.checkForUpdatesAndNotify()

+ 1 - 1
src/renderer/index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <title>韫珠IM</title>
-    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-src *; connect-src 'self' https: ws: wss:;">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: launchpad-cache:; frame-src *; connect-src 'self' https: ws: wss:;">
   </head>
   <body>
     <div id="root"></div>

+ 219 - 252
src/renderer/src/components/AppMappingList.tsx

@@ -3,7 +3,7 @@ import { BsSearch, BsGrid3X3Gap, BsGlobe } from 'react-icons/bs'
 import { api, AppMapping } from '../services/api'
 import { logger } from '../utils/logger'
 import { normalizeExternalUrl } from '../utils/urlUtils'
-import { getAvatarPlaceholder } from '../utils/avatarUtils'
+import { LaunchpadAppIcon } from './LaunchpadAppIcon'
 import '../index.css'
 
 interface AppMappingListProps {
@@ -24,16 +24,22 @@ const RECENT_APPS_MAX = 5
 const SHOW_CATEGORY_KEY = 'launchpad-show-category'
 const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
 
+const OPENABLE_PROTOCOLS: AppMapping['protocol_type'][] = ['SIMPLE_API', 'OIDC', 'NONE']
+
+function canOpenLaunch(mapping: AppMapping): boolean {
+  return mapping.is_active && OPENABLE_PROTOCOLS.includes(mapping.protocol_type)
+}
+
+function launchLoadingLabel(protocol: AppMapping['protocol_type']): string {
+  return protocol === 'NONE' ? '打开中...' : '登录中...'
+}
+
 const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId }) => {
   const [mappings, setMappings] = useState<AppMapping[]>([])
-  const mappingsRef = useRef<AppMapping[]>([])
   const [searchKeyword, setSearchKeyword] = useState('')
   const [isLoading, setIsLoading] = useState(false)
   const [isRefreshing, setIsRefreshing] = useState(false)
-  const [isLoadingMore, setIsLoadingMore] = useState(false)
   const isLoadingRef = useRef(false)
-  const isLoadingMoreRef = useRef(false)
-  const [hasMore, setHasMore] = useState(true)
   const [error, setError] = useState<string | null>(null)
   const [loginLoading, setLoginLoading] = useState<Record<string, boolean>>({})
   const [showCategory, setShowCategory] = useState(() => {
@@ -59,35 +65,13 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
     [currentUserId]
   )
 
-  // 分页状态
-  const [skip, setSkip] = useState(0)
-  const skipRef = useRef(0)
-  const pageSize = 20
-  const [total, setTotal] = useState(0)
-
   // 滚动容器引用
   const scrollContainerRef = useRef<HTMLDivElement>(null)
-  const searchTimerRef = useRef<NodeJS.Timeout | null>(null)
-  // 请求版本号,避免慢请求覆盖新搜索结果
   const loadIdRef = useRef(0)
-  const searchKeywordRef = useRef(searchKeyword)
-  searchKeywordRef.current = searchKeyword
-
-  // 同步 skipRef
-  useEffect(() => {
-    skipRef.current = skip
-  }, [skip])
-
-  useEffect(() => {
-    mappingsRef.current = mappings
-  }, [mappings])
 
   useEffect(() => {
     isLoadingRef.current = isLoading
   }, [isLoading])
-  useEffect(() => {
-    isLoadingMoreRef.current = isLoadingMore
-  }, [isLoadingMore])
 
   // 从本地加载最近打开(按用户隔离;最多 5 条,越近期越靠前)
   useEffect(() => {
@@ -125,65 +109,28 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
     })
   }, [recentStorageKey])
 
-  // 加载账号映射列表;silent:不清空列表、不顶全屏 loading,失败仅 warn
+  // 加载启动台全量列表(无服务端分页);silent:不顶全屏 loading、不闪清空,失败仅 warn
   const loadMappings = useCallback(
-    async (isRefresh: boolean = false, options?: { silent?: boolean }) => {
+    async (options?: { silent?: boolean }) => {
       const silent = options?.silent === true
 
-      if (isLoadingMoreRef.current && !isRefresh) return
       if (!silent && isLoadingRef.current) return
 
       const currentLoadId = (loadIdRef.current += 1)
 
       try {
-        if (isRefresh) {
-          setSkip(0)
-          skipRef.current = 0
-          if (!silent) {
-            setIsRefreshing(true)
-            setMappings([])
-          }
-        } else {
-          setIsLoadingMore(true)
-        }
         if (!silent) {
+          setIsRefreshing(true)
+          setMappings([])
           setIsLoading(true)
           setError(null)
         }
 
-        const currentSkip = isRefresh ? 0 : skipRef.current
-
-        const keyword = searchKeywordRef.current.trim() || undefined
-        const result = await api.getLaunchpadApps(token, {
-          skip: currentSkip,
-          limit: pageSize,
-          app_name: keyword
-        })
+        const result = await api.getLaunchpadApps(token)
 
-        // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
         if (currentLoadId !== loadIdRef.current) return
 
-        if (isRefresh) {
-          setMappings(result.items)
-          setTotal(result.total)
-          setHasMore(result.items.length < result.total)
-        } else {
-          setMappings(prev => {
-            const existingIds = new Set(prev.map(m => `${m.app_id}_${m.mapped_key}`))
-            const uniqueNewMappings = result.items.filter(
-              m => !existingIds.has(`${m.app_id}_${m.mapped_key}`)
-            )
-            const nextList = [...prev, ...uniqueNewMappings]
-            setHasMore(nextList.length < result.total)
-            return nextList
-          })
-        }
-
-        if (isRefresh) {
-          setIsRefreshing(false)
-        } else {
-          setIsLoadingMore(false)
-        }
+        setMappings(result.items)
       } catch (err: any) {
         if (currentLoadId !== loadIdRef.current) return
         if (silent) {
@@ -192,81 +139,55 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
           logger.error('AppMappingList: Failed to load mappings', err)
           setError(err.message || '加载应用列表失败')
         }
-        setIsRefreshing(false)
-        setIsLoadingMore(false)
       } finally {
         if (currentLoadId === loadIdRef.current) {
+          setIsRefreshing(false)
           if (!silent) {
             setIsLoading(false)
           }
         }
       }
     },
-    [token, pageSize]
+    [token]
   )
 
   // 初始化加载
   useEffect(() => {
-    loadMappings(true)
+    void loadMappings()
   }, [token, loadMappings])
 
-  // 处理搜索(防抖);已有列表时静默刷新,避免整页闪白
-  useEffect(() => {
-    if (searchTimerRef.current) {
-      clearTimeout(searchTimerRef.current)
-    }
-
-    searchTimerRef.current = setTimeout(() => {
-      loadMappings(true, { silent: mappingsRef.current.length > 0 })
-    }, 500)
-
-    return () => {
-      if (searchTimerRef.current) {
-        clearTimeout(searchTimerRef.current)
-      }
-    }
-  }, [searchKeyword, loadMappings])
-
   // 窗口从后台切回前台时静默刷新
   useEffect(() => {
     const onVis = () => {
       if (document.visibilityState === 'visible' && token) {
-        void loadMappings(true, { silent: true })
+        void loadMappings({ silent: true })
       }
     }
     document.addEventListener('visibilitychange', onVis)
     return () => document.removeEventListener('visibilitychange', onVis)
   }, [token, loadMappings])
 
-  // 处理滚动事件(使用节流避免频繁触发
+  // 处理滚动事件(下拉刷新)
   const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)
-  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
-    const container = e.currentTarget
-    if (!container) return
+  const handleScroll = useCallback(
+    (e: React.UIEvent<HTMLDivElement>) => {
+      const container = e.currentTarget
+      if (!container) return
 
-    const { scrollTop, scrollHeight, clientHeight } = container
+      const { scrollTop } = container
 
-    // 节流处理加载逻辑
-    if (scrollTimerRef.current) {
-      clearTimeout(scrollTimerRef.current)
-    }
-
-    scrollTimerRef.current = setTimeout(() => {
-      // 下拉刷新:滚动到顶部附近(往上拉)
-      if (scrollTop < 50 && !isRefreshing && !isLoading) {
-        loadMappings(true)
+      if (scrollTimerRef.current) {
+        clearTimeout(scrollTimerRef.current)
       }
 
-      // 上拉加载更多:滚动到底部附近(往下拉)
-      const distanceToBottom = scrollHeight - scrollTop - clientHeight
-      if (distanceToBottom < 100 && hasMore && !isLoadingMore && !isLoading) {
-        const nextSkip = skipRef.current + pageSize
-        setSkip(nextSkip)
-        skipRef.current = nextSkip
-        loadMappings(false)
-      }
-    }, 100)
-  }, [isRefreshing, isLoading, isLoadingMore, hasMore, loadMappings])
+      scrollTimerRef.current = setTimeout(() => {
+        if (scrollTop < 50 && !isRefreshing && !isLoading) {
+          void loadMappings()
+        }
+      }, 100)
+    },
+    [isRefreshing, isLoading, loadMappings]
+  )
 
   // 前端按关键词过滤展示列表(兜底:后端未过滤时也能正确显示)
   const displayedMappings = useMemo(() => {
@@ -300,74 +221,96 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
     return groups
   }, [displayedMappings])
 
-  // 处理 SSO 登录
-  const handleSsoLogin = async (mapping: AppMapping) => {
-    if (!mapping.is_active) {
-      alert('该账号已禁用,无法登录')
-      return
-    }
+  // 处理 SSO / 导航打开(NONE 与同接口返回 redirect_url)
+  const handleSsoLogin = useCallback(
+    async (mapping: AppMapping) => {
+      if (!mapping.is_active) {
+        alert('该账号已禁用,无法登录')
+        return
+      }
 
-    setLoginLoading(prev => ({ ...prev, [mapping.app_id]: true }))
-    
-    try {
-      const result = await api.ssoLogin(token, mapping.app_id)
-      
-      if (result.redirect_url) {
-        const normalized = normalizeExternalUrl(result.redirect_url)
-        if (!normalized) {
-          alert('无效的跳转地址')
-          return
-        }
-        if (openInSystemBrowser && window.electron?.ipcRenderer) {
-          window.electron.ipcRenderer.send('open-url-external', normalized)
+      setLoginLoading(prev => ({ ...prev, [mapping.app_id]: true }))
+
+      try {
+        const result = await api.ssoLogin(token, mapping.app_id)
+
+        if (result.redirect_url) {
+          const normalized = normalizeExternalUrl(result.redirect_url)
+          if (!normalized) {
+            alert('无效的跳转地址')
+            return
+          }
+          if (openInSystemBrowser && window.electron?.ipcRenderer) {
+            window.electron.ipcRenderer.send('open-url-external', normalized)
+          } else {
+            window.open(normalized, '_blank')
+          }
+          saveRecentApp({
+            app_id: mapping.app_id,
+            mapped_key: mapping.mapped_key ?? '',
+            app_name: mapping.app_name
+          })
         } else {
-          window.open(normalized, '_blank')
+          alert('SSO 登录失败:未返回跳转地址')
         }
-        saveRecentApp({ app_id: mapping.app_id, mapped_key: mapping.mapped_key, app_name: mapping.app_name })
-      } else {
-        alert('SSO 登录失败:未返回跳转地址')
+      } catch (err: any) {
+        logger.error('AppMappingList: SSO login failed', err)
+        alert(err.message || 'SSO 登录失败')
+      } finally {
+        setLoginLoading(prev => ({ ...prev, [mapping.app_id]: false }))
       }
-    } catch (err: any) {
-      logger.error('AppMappingList: SSO login failed', err)
-      alert(err.message || 'SSO 登录失败')
-    } finally {
-      setLoginLoading(prev => ({ ...prev, [mapping.app_id]: false }))
-    }
-  }
+    },
+    [token, openInSystemBrowser, saveRecentApp]
+  )
+
+  const recentLoadingText = useCallback(
+    (item: RecentAppItem) => {
+      const m = mappings.find(
+        x => x.app_id === item.app_id && (x.mapped_key ?? '') === item.mapped_key
+      )
+      return m ? launchLoadingLabel(m.protocol_type) : '登录中...'
+    },
+    [mappings]
+  )
 
   // 点击最近打开项:优先用当前列表中的 mapping,否则用本地记录发起 SSO
-  const handleRecentAppClick = useCallback(async (item: RecentAppItem) => {
-    const mapping = mappings.find(m => m.app_id === item.app_id && m.mapped_key === item.mapped_key)
-    if (mapping) {
-      if (!mapping.is_active) return
-      await handleSsoLogin(mapping)
-      return
-    }
-    setLoginLoading(prev => ({ ...prev, [item.app_id]: true }))
-    try {
-      const result = await api.ssoLogin(token, item.app_id)
-      if (result.redirect_url) {
-        const normalized = normalizeExternalUrl(result.redirect_url)
-        if (!normalized) {
-          alert('无效的跳转地址')
-          return
-        }
-        if (openInSystemBrowser && window.electron?.ipcRenderer) {
-          window.electron.ipcRenderer.send('open-url-external', normalized)
+  const handleRecentAppClick = useCallback(
+    async (item: RecentAppItem) => {
+      const mapping = mappings.find(
+        m => m.app_id === item.app_id && (m.mapped_key ?? '') === item.mapped_key
+      )
+      if (mapping) {
+        if (!mapping.is_active) return
+        await handleSsoLogin(mapping)
+        return
+      }
+      setLoginLoading(prev => ({ ...prev, [item.app_id]: true }))
+      try {
+        const result = await api.ssoLogin(token, item.app_id)
+        if (result.redirect_url) {
+          const normalized = normalizeExternalUrl(result.redirect_url)
+          if (!normalized) {
+            alert('无效的跳转地址')
+            return
+          }
+          if (openInSystemBrowser && window.electron?.ipcRenderer) {
+            window.electron.ipcRenderer.send('open-url-external', normalized)
+          } else {
+            window.open(normalized, '_blank')
+          }
+          saveRecentApp(item)
         } else {
-          window.open(normalized, '_blank')
+          alert('SSO 登录失败:未返回跳转地址')
         }
-        saveRecentApp(item)
-      } else {
-        alert('SSO 登录失败:未返回跳转地址')
+      } catch (err: any) {
+        logger.error('AppMappingList: SSO login failed (recent)', err)
+        alert((err as Error).message || 'SSO 登录失败')
+      } finally {
+        setLoginLoading(prev => ({ ...prev, [item.app_id]: false }))
       }
-    } catch (err: any) {
-      logger.error('AppMappingList: SSO login failed (recent)', err)
-      alert((err as Error).message || 'SSO 登录失败')
-    } finally {
-      setLoginLoading(prev => ({ ...prev, [item.app_id]: false }))
-    }
-  }, [token, mappings, saveRecentApp, openInSystemBrowser])
+    },
+    [token, mappings, saveRecentApp, openInSystemBrowser, handleSsoLogin]
+  )
 
   return (
     <div style={{ flex: 1, padding: '40px', overflowY: 'auto' }}>
@@ -499,26 +442,40 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
             最近打开
           </div>
           <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: '12px' }}>
-            {recentApps.map((item) => (
-              <div
-                key={`${item.app_id}_${item.mapped_key}`}
-                className={`browser-card browser-card--recent ${loginLoading[item.app_id] ? 'disabled' : ''}`}
-                onClick={() => !loginLoading[item.app_id] && handleRecentAppClick(item)}
-                style={{
-                  opacity: loginLoading[item.app_id] ? 0.6 : 1,
-                  cursor: loginLoading[item.app_id] ? 'not-allowed' : 'pointer',
-                  WebkitAppRegion: 'no-drag',
-                } as React.CSSProperties}
-              >
-                {getAvatarPlaceholder(item.app_name, item.app_id, 28, { marginRight: 10 }, 'applicationIcon', 'roundedSquare')}
-                <div style={{ minWidth: 0 }}>
-                  <div style={{ fontWeight: 'bold', fontSize: '13px' }}>{item.app_name}</div>
-                  {loginLoading[item.app_id] && (
-                    <div style={{ fontSize: '11px', color: '#888', marginTop: '2px' }}>登录中...</div>
-                  )}
+            {recentApps.map((item) => {
+              const mappingForIcon =
+                mappings.find(m => m.app_id === item.app_id && (m.mapped_key ?? '') === item.mapped_key) ??
+                mappings.find(m => m.app_id === item.app_id)
+              return (
+                <div
+                  key={`${item.app_id}_${item.mapped_key}`}
+                  className={`browser-card browser-card--recent ${loginLoading[item.app_id] ? 'disabled' : ''}`}
+                  onClick={() => !loginLoading[item.app_id] && handleRecentAppClick(item)}
+                  style={{
+                    opacity: loginLoading[item.app_id] ? 0.6 : 1,
+                    cursor: loginLoading[item.app_id] ? 'not-allowed' : 'pointer',
+                    WebkitAppRegion: 'no-drag',
+                  } as React.CSSProperties}
+                >
+                  <LaunchpadAppIcon
+                    appName={item.app_name}
+                    appId={item.app_id}
+                    iconUrl={mappingForIcon?.icon_url}
+                    iconObjectKey={mappingForIcon?.icon_object_key}
+                    sizePx={28}
+                    extraStyle={{ marginRight: 10 }}
+                  />
+                  <div style={{ minWidth: 0 }}>
+                    <div style={{ fontWeight: 'bold', fontSize: '13px' }}>{item.app_name}</div>
+                    {loginLoading[item.app_id] && (
+                      <div style={{ fontSize: '11px', color: '#888', marginTop: '2px' }}>
+                        {recentLoadingText(item)}
+                      </div>
+                    )}
+                  </div>
                 </div>
-              </div>
-            ))}
+              )
+            })}
           </div>
         </div>
       )}
@@ -563,71 +520,81 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
                 </span>
               </div>
               <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
-                {group.apps.map((mapping) => (
-                  <div
-                    key={`${mapping.app_id}_${mapping.mapped_key}`}
-                    className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
-                    onClick={() => {
-                      if (mapping.is_active && (mapping.protocol_type === 'SIMPLE_API' || mapping.protocol_type === 'OIDC') && !loginLoading[mapping.app_id]) {
-                        handleSsoLogin(mapping)
-                      }
-                    }}
-                    style={{
-                      opacity: !mapping.is_active || loginLoading[mapping.app_id] ? 0.6 : 1,
-                      cursor: !mapping.is_active || loginLoading[mapping.app_id] ? 'not-allowed' : 'pointer'
-                    }}
-                  >
-                    {getAvatarPlaceholder(mapping.app_name, mapping.app_id, 40, { marginRight: 15 }, 'applicationIcon', 'roundedSquare')}
-                    <div>
-                      <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
-                      {loginLoading[mapping.app_id] && (
-                        <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
-                      )}
+                {group.apps.map((mapping) => {
+                  const rowBusy = !canOpenLaunch(mapping) || loginLoading[mapping.app_id]
+                  return (
+                    <div
+                      key={`${mapping.app_id}_${mapping.mapped_key ?? ''}`}
+                      className={`browser-card ${rowBusy ? 'disabled' : ''}`}
+                      onClick={() => {
+                        if (canOpenLaunch(mapping) && !loginLoading[mapping.app_id]) {
+                          void handleSsoLogin(mapping)
+                        }
+                      }}
+                      style={{
+                        opacity: rowBusy ? 0.6 : 1,
+                        cursor: rowBusy ? 'not-allowed' : 'pointer'
+                      }}
+                    >
+                      <LaunchpadAppIcon
+                        appName={mapping.app_name}
+                        appId={mapping.app_id}
+                        iconUrl={mapping.icon_url}
+                        iconObjectKey={mapping.icon_object_key}
+                        sizePx={40}
+                        extraStyle={{ marginRight: 15 }}
+                      />
+                      <div>
+                        <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
+                        {loginLoading[mapping.app_id] && (
+                          <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
+                            {launchLoadingLabel(mapping.protocol_type)}
+                          </div>
+                        )}
+                      </div>
                     </div>
-                  </div>
-                ))}
+                  )
+                })}
               </div>
             </div>
           ))
         ) : (
           <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
-            {displayedMappings.map((mapping) => (
-              <div
-                key={`${mapping.app_id}_${mapping.mapped_key}`}
-                className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
-                onClick={() => {
-                  if (mapping.is_active && (mapping.protocol_type === 'SIMPLE_API' || mapping.protocol_type === 'OIDC') && !loginLoading[mapping.app_id]) {
-                    handleSsoLogin(mapping)
-                  }
-                }}
-                style={{
-                  opacity: !mapping.is_active || loginLoading[mapping.app_id] ? 0.6 : 1,
-                  cursor: !mapping.is_active || loginLoading[mapping.app_id] ? 'not-allowed' : 'pointer'
-                }}
-              >
-                {getAvatarPlaceholder(mapping.app_name, mapping.app_id, 40, { marginRight: 15 }, 'applicationIcon', 'roundedSquare')}
-                <div>
-                  <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
-                  {loginLoading[mapping.app_id] && (
-                    <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
-                  )}
+            {displayedMappings.map((mapping) => {
+              const rowBusy = !canOpenLaunch(mapping) || loginLoading[mapping.app_id]
+              return (
+                <div
+                  key={`${mapping.app_id}_${mapping.mapped_key ?? ''}`}
+                  className={`browser-card ${rowBusy ? 'disabled' : ''}`}
+                  onClick={() => {
+                    if (canOpenLaunch(mapping) && !loginLoading[mapping.app_id]) {
+                      void handleSsoLogin(mapping)
+                    }
+                  }}
+                  style={{
+                    opacity: rowBusy ? 0.6 : 1,
+                    cursor: rowBusy ? 'not-allowed' : 'pointer'
+                  }}
+                >
+                  <LaunchpadAppIcon
+                    appName={mapping.app_name}
+                    appId={mapping.app_id}
+                    iconUrl={mapping.icon_url}
+                    iconObjectKey={mapping.icon_object_key}
+                    sizePx={40}
+                    extraStyle={{ marginRight: 15 }}
+                  />
+                  <div>
+                    <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
+                    {loginLoading[mapping.app_id] && (
+                      <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
+                        {launchLoadingLabel(mapping.protocol_type)}
+                      </div>
+                    )}
+                  </div>
                 </div>
-              </div>
-            ))}
-          </div>
-        )}
-
-        {/* 加载更多提示 */}
-        {isLoadingMore && (
-          <div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
-            加载中...
-          </div>
-        )}
-
-        {/* 没有更多数据提示 */}
-        {!hasMore && displayedMappings.length > 0 && (
-          <div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
-            没有更多了
+              )
+            })}
           </div>
         )}
 

+ 109 - 0
src/renderer/src/components/LaunchpadAppIcon.tsx

@@ -0,0 +1,109 @@
+import React, { useEffect, useState } from 'react'
+import type { LaunchpadIconResolvePayload, LaunchpadIconResolveResult } from '../types/ipcLaunchpad'
+import { getAvatarPlaceholder } from '../utils/avatarUtils'
+
+function isProbablyHttpIconUrl(raw: string): boolean {
+  const s = raw.trim()
+  return /^https?:\/\//i.test(s)
+}
+
+interface LaunchpadAppIconProps {
+  appName: string
+  appId: string
+  iconUrl?: string | null
+  iconObjectKey?: string | null
+  sizePx: number
+  /** 与 `getAvatarPlaceholder` 的 extraStyle 一致(如 marginRight) */
+  extraStyle?: React.CSSProperties
+}
+
+/** 应用中心:`icon_url` 为空用渐变占位;非空则经主进程缓存后通过自定义协议展示 */
+export function LaunchpadAppIcon({
+  appName,
+  appId,
+  iconUrl,
+  iconObjectKey,
+  sizePx,
+  extraStyle,
+}: LaunchpadAppIconProps): JSX.Element {
+  const [resolvedSrc, setResolvedSrc] = useState<string | null>(null)
+  const [usePlaceholder, setUsePlaceholder] = useState(false)
+
+  useEffect(() => {
+    setUsePlaceholder(false)
+
+    const urlTrim = typeof iconUrl === 'string' ? iconUrl.trim() : ''
+    if (!urlTrim || !isProbablyHttpIconUrl(urlTrim)) {
+      setResolvedSrc(null)
+      return
+    }
+
+    const invoke = window.electron?.ipcRenderer?.invoke
+    if (!invoke) {
+      setResolvedSrc(null)
+      return
+    }
+
+    let cancelled = false
+
+    void (async () => {
+      try {
+        const payload: LaunchpadIconResolvePayload = {
+          app_id: appId,
+          icon_url: urlTrim,
+          icon_object_key: iconObjectKey ?? null,
+        }
+        const res = (await invoke('launchpad-icon-resolve', payload)) as LaunchpadIconResolveResult
+
+        if (cancelled) return
+        if (res.kind === 'custom' && res.url) {
+          setResolvedSrc(res.url)
+        } else {
+          setResolvedSrc(null)
+        }
+      } catch {
+        if (!cancelled) setResolvedSrc(null)
+      }
+    })()
+
+    return () => {
+      cancelled = true
+    }
+  }, [appId, iconUrl, iconObjectKey])
+
+  const placeholderNode = getAvatarPlaceholder(
+    appName,
+    appId,
+    sizePx,
+    extraStyle,
+    'applicationIcon',
+    'roundedSquare'
+  )
+
+  if (!resolvedSrc || usePlaceholder) {
+    return <>{placeholderNode}</>
+  }
+
+  const br = Math.round(sizePx * 0.22)
+
+  return (
+    <img
+      src={resolvedSrc}
+      alt=""
+      draggable={false}
+      onError={() => setUsePlaceholder(true)}
+      style={{
+        width: sizePx,
+        height: sizePx,
+        minWidth: sizePx,
+        minHeight: sizePx,
+        borderRadius: br,
+        objectFit: 'cover',
+        flexShrink: 0,
+        display: 'block',
+        boxSizing: 'border-box',
+        ...extraStyle,
+      }}
+    />
+  )
+}

+ 4 - 0
src/renderer/src/env.d.ts

@@ -1,5 +1,8 @@
 /// <reference types="vite/client" />
 
+type LaunchpadIconResolvePayload_window = import('./types/ipcLaunchpad').LaunchpadIconResolvePayload
+type LaunchpadIconResolveResult_window = import('./types/ipcLaunchpad').LaunchpadIconResolveResult
+
 interface Window {
   electron: {
     ipcRenderer: {
@@ -8,6 +11,7 @@ interface Window {
       once(channel: string, listener: (event: any, ...args: any[]) => void): void
       removeListener(channel: string, listener: (event: any, ...args: any[]) => void): void
       removeAllListeners(channel: string): void
+      invoke(channel: 'launchpad-icon-resolve', payload: LaunchpadIconResolvePayload_window): Promise<LaunchpadIconResolveResult_window>
       invoke(channel: string, ...args: any[]): Promise<any>
     }
   }

+ 10 - 24
src/renderer/src/services/api.ts

@@ -63,13 +63,17 @@ export interface UserListResponse {
 export interface AppMapping {
   app_name: string;
   app_id: string;
-  protocol_type: 'SIMPLE_API' | 'OIDC';
-  mapped_key: string;
+  protocol_type: 'SIMPLE_API' | 'OIDC' | 'NONE';
+  mapped_key: string | null;
   mapped_email: string | null;
   is_active: boolean;
   description: string | null;
   category_id: number | null;
   category_name: string | null;
+  /** Logo 展示用地址(可能为预签名 URL) */
+  icon_url?: string | null;
+  /** 对象存储场景下的稳定对象键 */
+  icon_object_key?: string | null;
 }
 
 export interface AppMappingListResponse {
@@ -1048,31 +1052,13 @@ export const api = {
 
   /**
    * 获取启动台应用列表(含分类信息,用于应用中心展示)
+   * 接口无服务端分页:一次返回当前用户可访问的全量条目;检索请使用前端过滤。
    * @param token 用户 Token
-   * @param params 查询参数
    */
-  getLaunchpadApps: async (
-    token: string,
-    params?: {
-      skip?: number;
-      limit?: number;
-      app_name?: string;
-    }
-  ): Promise<AppMappingListResponse> => {
-    logger.info('API: Get launchpad apps', params);
-
-    const queryParams = new URLSearchParams();
-    if (params?.skip !== undefined) {
-      queryParams.append('skip', String(params.skip));
-    }
-    if (params?.limit !== undefined) {
-      queryParams.append('limit', String(params.limit));
-    }
-    if (params?.app_name) {
-      queryParams.append('app_name', params.app_name);
-    }
+  getLaunchpadApps: async (token: string): Promise<AppMappingListResponse> => {
+    logger.info('API: Get launchpad apps');
 
-    const response = await fetch(`${API_BASE_URL}/simple/me/launchpad-apps?${queryParams.toString()}`, {
+    const response = await fetch(`${API_BASE_URL}/simple/me/launchpad-apps`, {
       method: 'GET',
       headers: {
         'Authorization': `Bearer ${token}`,

+ 10 - 0
src/renderer/src/types/ipcLaunchpad.ts

@@ -0,0 +1,10 @@
+/** 主进程 IPC `launchpad-icon-resolve` 的返回结构 */
+export type LaunchpadIconResolveResult =
+  | { kind: 'custom'; url: string }
+  | { kind: 'placeholder' }
+
+export interface LaunchpadIconResolvePayload {
+  app_id: string
+  icon_url: string | null
+  icon_object_key?: string | null
+}