Sfoglia il codice sorgente

更新应用中心分类等内容

liuq 1 mese fa
parent
commit
3f7e7cf622

+ 7 - 0
src/main/index.ts

@@ -1071,6 +1071,13 @@ app.whenReady().then(() => {
     }
   })
 
+  // 使用系统默认浏览器打开 URL(应用中心「用系统浏览器打开」选项)
+  ipcMain.on('open-url-external', (_, url: string) => {
+    if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
+      shell.openExternal(url)
+    }
+  })
+
   // 处理打开图片预览窗口
   ipcMain.on('open-image-preview', (_, imageUrl: string) => {
     createImagePreviewWindow(imageUrl)

+ 6 - 0
src/renderer/src/App.tsx

@@ -54,9 +54,12 @@ function App(): JSX.Element {
     messages,
     setMessages,
     isLoadingMessages,
+    isLoadingMore,
+    hasMoreMessages,
     uploadProgress,
     sendMessage: sendMessageHook,
     sendFileMessage,
+    fetchMoreMessages,
     loadedContacts
   } = useMessages(token, currentUserId, activeContactId, setContacts, unreadCountsRef)
 
@@ -565,6 +568,9 @@ function App(): JSX.Element {
               onShowChatSearch={() => setShowChatSearchModal(true)}
               onContextMenu={handleContextMenu}
               setMessages={setMessages}
+              isLoadingMore={isLoadingMore}
+              hasMoreMessages={hasMoreMessages}
+              onLoadMoreMessages={fetchMoreMessages}
             />
           )
         ) : activeTab === 'browser' ? (

+ 5 - 84
src/renderer/src/BrowserWindow.tsx

@@ -1,5 +1,5 @@
 import React, { useState, useEffect, useRef, useCallback } from 'react'
-import { BsX, BsArrowClockwise, BsArrowLeft, BsArrowRight } from 'react-icons/bs'
+import { BsX } from 'react-icons/bs'
 
 interface Tab {
   id: string
@@ -13,9 +13,7 @@ interface Tab {
 const BrowserWindow: React.FC = () => {
   const [tabs, setTabs] = useState<Tab[]>([])
   const [activeTabId, setActiveTabId] = useState<string | null>(null)
-  const [inputValue, setInputValue] = useState('')
   const contentRef = useRef<HTMLDivElement>(null)
-  const isResizing = useRef(false)
 
   // 更新 BrowserView 的位置
   const updateViewBounds = useCallback(() => {
@@ -44,17 +42,7 @@ const BrowserWindow: React.FC = () => {
     }
 
     const handleTabUpdate = (_event: any, id: string, updates: Partial<Tab>) => {
-        setTabs(prev => prev.map(t => {
-            if (t.id === id) {
-                const newTab = { ...t, ...updates }
-                // 如果是当前标签且有 URL 更新,同步到输入框
-                if (id === activeTabId && updates.url) {
-                    setInputValue(updates.url)
-                }
-                return newTab
-            }
-            return t
-        }))
+        setTabs(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t))
     }
 
     if (window.electron && window.electron.ipcRenderer) {
@@ -75,21 +63,13 @@ const BrowserWindow: React.FC = () => {
       window.electron.ipcRenderer.removeAllListeners('tab-update')
       window.removeEventListener('resize', updateViewBounds)
     }
-  }, [updateViewBounds, activeTabId]) // 依赖 activeTabId 以确保 input 同步
+  }, [updateViewBounds])
   
-  // 当 tabs 变化导致布局变化时, activeTab 变化时,可能需要调整 bounds
+  // 当 tabs 或 activeTabId 变化导致布局变化时,调整 BrowserView 的 bounds
   useEffect(() => {
       updateViewBounds()
   }, [tabs.length, activeTabId, updateViewBounds])
 
-  // 切换标签时更新地址栏
-  useEffect(() => {
-      const activeTab = tabs.find(t => t.id === activeTabId)
-      if (activeTab) {
-          setInputValue(activeTab.url)
-      }
-  }, [activeTabId, tabs])
-
   const handleSwitchTab = (id: string) => {
       setActiveTabId(id)
       window.electron.ipcRenderer.send('browser-action', { type: 'switch-tab', id })
@@ -117,34 +97,6 @@ const BrowserWindow: React.FC = () => {
     window.electron.ipcRenderer.send('browser-action', { type: 'close-tab', id: tabId })
   }
 
-  const handleReload = () => {
-      window.electron.ipcRenderer.send('browser-action', { type: 'reload' })
-  }
-
-  const handleGoBack = () => {
-      window.electron.ipcRenderer.send('browser-action', { type: 'go-back' })
-  }
-
-  const handleGoForward = () => {
-      window.electron.ipcRenderer.send('browser-action', { type: 'go-forward' })
-  }
-
-  const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
-      if (e.key === 'Enter') {
-          let url = inputValue.trim()
-          if (!url) return
-          if (!url.startsWith('http://') && !url.startsWith('https://')) {
-              url = 'http://' + url
-          }
-          if (activeTabId) {
-             window.electron.ipcRenderer.send('browser-action', { type: 'load-url', id: activeTabId, url })
-          } else {
-             window.electron.ipcRenderer.send('browser-action', { type: 'create-tab', url })
-          }
-      }
-  }
-
-  const activeTab = tabs.find(t => t.id === activeTabId)
 
   return (
     <div className="browser-window" style={{ display: 'flex', flexDirection: 'column', height: '100vh', width: '100vw', backgroundColor: '#f5f5f5', overflow: 'hidden' }}>
@@ -189,38 +141,7 @@ const BrowserWindow: React.FC = () => {
         </div>
       </div>
 
-      {/* Navigation Toolbar */}
-      {activeTabId && (
-        <div className="browser-toolbar" style={{ height: '40px', backgroundColor: '#ffffff', display: 'flex', alignItems: 'center', padding: '0 10px', borderBottom: '1px solid #ddd' }}>
-          <button disabled={!activeTab?.canGoBack} onClick={handleGoBack} style={{ border: 'none', background: 'transparent', cursor: activeTab?.canGoBack ? 'pointer' : 'default', padding: '4px' }}>
-              <BsArrowLeft size={16} color={activeTab?.canGoBack ? '#333' : '#ccc'} />
-          </button>
-          <button disabled={!activeTab?.canGoForward} onClick={handleGoForward} style={{ border: 'none', background: 'transparent', cursor: activeTab?.canGoForward ? 'pointer' : 'default', padding: '4px', marginRight: '10px' }}>
-              <BsArrowRight size={16} color={activeTab?.canGoForward ? '#333' : '#ccc'} />
-          </button>
-          <button onClick={handleReload} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', marginRight: '15px' }}>
-              <BsArrowClockwise size={16} color="#333" />
-          </button>
-          
-          <input 
-            value={inputValue}
-            onChange={(e) => setInputValue(e.target.value)}
-            onKeyDown={handleInputKeyDown}
-            onFocus={(e) => e.target.select()}
-            style={{ 
-                flex: 1, 
-                backgroundColor: '#f2f2f2', 
-                border: 'none',
-                borderRadius: '4px', 
-                padding: '6px 15px', 
-                fontSize: '12px', 
-                color: '#333', 
-                outline: 'none'
-            }} 
-            placeholder="输入网址并回车"
-          />
-        </div>
-      )}
+      {/* 导航栏已隐藏:仅保留标签栏与网页内容区 */}
 
       {/* Content Container (Placeholder for BrowserView) */}
       <div 

+ 309 - 62
src/renderer/src/components/AppMappingList.tsx

@@ -1,23 +1,33 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react'
-import { BsSearch } from 'react-icons/bs'
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
+import { BsSearch, BsGrid3X3Gap, BsGlobe } from 'react-icons/bs'
 import { api, AppMapping } from '../services/api'
 import { logger } from '../utils/logger'
+import { stringToColor, calculateFontSize } from '../utils/iconGradientUtils'
 import '../index.css'
 
 interface AppMappingListProps {
   token: string
 }
 
-// 根据应用名称生成图标
-const getAppIcon = (appName: string): React.ReactNode => {
+/** 最近打开本地存储项 */
+export interface RecentAppItem {
+  app_id: string
+  mapped_key: string
+  app_name: string
+}
+
+const RECENT_APPS_KEY = 'launchpad-recent-apps'
+const RECENT_APPS_MAX = 5
+const SHOW_CATEGORY_KEY = 'launchpad-show-category'
+const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
+
+// 根据应用名称生成图标,可选尺寸(默认 40)
+const getAppIcon = (appName: string, containerSize: number = 40): React.ReactNode => {
   const getDisplayChars = (name: string): string => {
-    // 提取所有中文字符
     const chineseChars = name.match(/[\u4e00-\u9fa5]/g);
     if (chineseChars && chineseChars.length > 0) {
-      // 只显示前2个字符,更简洁美观
       return chineseChars.slice(0, 2).join('');
     }
-    // 英文:提取首字母,最多2个
     const words = name.split(/\s+/).filter(w => w.length > 0);
     if (words.length >= 2) {
       return (words[0][0] + words[1][0]).toUpperCase();
@@ -25,36 +35,22 @@ const getAppIcon = (appName: string): React.ReactNode => {
     return name.substring(0, 2).toUpperCase();
   };
 
-  const getBackgroundColor = (name: string): string => {
-    // 使用更柔和的颜色
-    const colors = [
-      '#667eea', '#764ba2', '#f093fb', '#4facfe', 
-      '#43e97b', '#fa709a', '#30cfd0', '#a8edea',
-      '#ff9a9e', '#ffecd2', '#ff8a80', '#f6d365',
-      '#84fab0', '#a1c4fd', '#ffd89b', '#c471ed'
-    ];
-    let hash = 0;
-    for (let i = 0; i < name.length; i++) {
-      hash = name.charCodeAt(i) + ((hash << 5) - hash);
-    }
-    return colors[Math.abs(hash) % colors.length];
-  };
-
   const chars = getDisplayChars(appName);
-  const bgColor = getBackgroundColor(appName);
 
   return (
-    <div style={{ 
-      width: '40px', 
-      height: '40px', 
-      background: bgColor,
-      borderRadius: '8px',  // 改为圆角矩形
-      marginRight: '15px', 
-      display: 'flex', 
-      alignItems: 'center', 
-      justifyContent: 'center', 
-      color: 'white', 
-      fontSize: '16px',
+    <div style={{
+      width: `${containerSize}px`,
+      height: `${containerSize}px`,
+      minWidth: `${containerSize}px`,
+      minHeight: `${containerSize}px`,
+      background: stringToColor(appName),
+      borderRadius: '8px',
+      marginRight: containerSize > 32 ? '15px' : '10px',
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      color: 'white',
+      fontSize: `${calculateFontSize(containerSize, chars.length)}px`,
       fontWeight: '600',
       overflow: 'hidden',
       lineHeight: '1',
@@ -76,6 +72,23 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
   const [hasMore, setHasMore] = useState(true)
   const [error, setError] = useState<string | null>(null)
   const [loginLoading, setLoginLoading] = useState<Record<string, boolean>>({})
+  const [showCategory, setShowCategory] = useState(() => {
+    try {
+      const raw = localStorage.getItem(SHOW_CATEGORY_KEY)
+      return raw === null ? true : raw === 'true'
+    } catch {
+      return true
+    }
+  })
+  const [openInSystemBrowser, setOpenInSystemBrowser] = useState(() => {
+    try {
+      const raw = localStorage.getItem(OPEN_IN_SYSTEM_BROWSER_KEY)
+      return raw === 'true'
+    } catch {
+      return false
+    }
+  })
+  const [recentApps, setRecentApps] = useState<RecentAppItem[]>([])
 
   // 分页状态
   const [skip, setSkip] = useState(0)
@@ -92,6 +105,34 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     skipRef.current = skip
   }, [skip])
 
+  // 从本地加载最近打开(最多 5 条,越近期越靠前)
+  useEffect(() => {
+    try {
+      const raw = localStorage.getItem(RECENT_APPS_KEY)
+      if (raw) {
+        const list = JSON.parse(raw) as RecentAppItem[]
+        if (Array.isArray(list)) {
+          setRecentApps(list.slice(0, RECENT_APPS_MAX))
+        }
+      }
+    } catch (_) {
+      setRecentApps([])
+    }
+  }, [])
+
+  const saveRecentApp = useCallback((item: { app_id: string; mapped_key: string; app_name: string }) => {
+    setRecentApps(prev => {
+      const next = [
+        item,
+        ...prev.filter(x => !(x.app_id === item.app_id && x.mapped_key === item.mapped_key))
+      ].slice(0, RECENT_APPS_MAX)
+      try {
+        localStorage.setItem(RECENT_APPS_KEY, JSON.stringify(next))
+      } catch (_) {}
+      return next
+    })
+  }, [])
+
   // 加载账号映射列表
   const loadMappings = useCallback(async (isRefresh: boolean = false) => {
     if (isLoading || (isLoadingMore && !isRefresh)) return
@@ -110,7 +151,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
 
       const currentSkip = isRefresh ? 0 : skipRef.current
       
-      const result = await api.getMyMappings(token, {
+      const result = await api.getLaunchpadApps(token, {
         skip: currentSkip,
         limit: pageSize,
         app_name: searchKeyword || undefined
@@ -201,6 +242,31 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     }, 100)
   }, [isRefreshing, isLoading, isLoadingMore, hasMore, loadMappings])
 
+  const categorizedApps = useMemo(() => {
+    const groupMap = new Map<number | null, { categoryId: number | null; categoryName: string; apps: AppMapping[] }>()
+
+    for (const app of mappings) {
+      const key = app.category_id
+      if (!groupMap.has(key)) {
+        groupMap.set(key, {
+          categoryId: key,
+          categoryName: key === null ? '未分类' : (app.category_name || '未分类'),
+          apps: [],
+        })
+      }
+      groupMap.get(key)!.apps.push(app)
+    }
+
+    const groups = Array.from(groupMap.values())
+    groups.sort((a, b) => {
+      if (a.categoryId === null && b.categoryId !== null) return 1
+      if (a.categoryId !== null && b.categoryId === null) return -1
+      return a.categoryName.localeCompare(b.categoryName, 'zh-CN')
+    })
+
+    return groups
+  }, [mappings])
+
   // 处理 SSO 登录
   const handleSsoLogin = async (mapping: AppMapping) => {
     if (!mapping.is_active) {
@@ -219,8 +285,12 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
       const result = await api.ssoLogin(token, mapping.app_id)
       
       if (result.redirect_url) {
-        // 使用 window.open,主进程会自动拦截并在 BrowserWindow 中打开
-        window.open(result.redirect_url, '_blank')
+        if (openInSystemBrowser && window.electron?.ipcRenderer) {
+          window.electron.ipcRenderer.send('open-url-external', result.redirect_url)
+        } else {
+          window.open(result.redirect_url, '_blank')
+        }
+        saveRecentApp({ app_id: mapping.app_id, mapped_key: mapping.mapped_key, app_name: mapping.app_name })
       } else {
         alert('SSO 登录失败:未返回跳转地址')
       }
@@ -232,9 +302,98 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     }
   }
 
+  // 点击最近打开项:优先用当前列表中的 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 || mapping.protocol_type !== 'SIMPLE_API') 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) {
+        if (openInSystemBrowser && window.electron?.ipcRenderer) {
+          window.electron.ipcRenderer.send('open-url-external', result.redirect_url)
+        } else {
+          window.open(result.redirect_url, '_blank')
+        }
+        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 }))
+    }
+  }, [token, mappings, saveRecentApp, openInSystemBrowser])
+
   return (
     <div style={{ flex: 1, padding: '40px', overflowY: 'auto' }}>
-      <h2 style={{ marginBottom: '30px', color: '#333', WebkitAppRegion: 'drag' } as React.CSSProperties}>应用中心</h2>
+      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
+        <h2 style={{ margin: 0, color: '#333', WebkitAppRegion: 'drag' } as React.CSSProperties}>应用中心</h2>
+        <div style={{ display: 'flex', alignItems: 'center', gap: '8px', WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
+          <button
+            title={openInSystemBrowser ? '在应用内打开' : '用系统浏览器打开'}
+            onClick={() => {
+              setOpenInSystemBrowser(prev => {
+                const next = !prev
+                try {
+                  localStorage.setItem(OPEN_IN_SYSTEM_BROWSER_KEY, next ? 'true' : 'false')
+                } catch (_) {}
+                return next
+              })
+            }}
+            style={{
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              width: '36px',
+              height: '36px',
+              padding: 0,
+              borderRadius: '6px',
+              border: '1px solid #e0e0e0',
+              backgroundColor: openInSystemBrowser ? '#1890ff' : '#f5f5f5',
+              color: openInSystemBrowser ? '#fff' : '#666',
+              cursor: 'pointer',
+              fontSize: '16px',
+              transition: 'all 0.2s',
+            } as React.CSSProperties}
+          >
+            <BsGlobe style={{ fontSize: '18px' }} />
+          </button>
+          <button
+            onClick={() => {
+              setShowCategory(prev => {
+                const next = !prev
+                try {
+                  localStorage.setItem(SHOW_CATEGORY_KEY, JSON.stringify(next))
+                } catch (_) {}
+                return next
+              })
+            }}
+            style={{
+              display: 'flex',
+              alignItems: 'center',
+              gap: '6px',
+              padding: '6px 14px',
+              borderRadius: '6px',
+              border: '1px solid #e0e0e0',
+              backgroundColor: showCategory ? '#1890ff' : '#f5f5f5',
+              color: showCategory ? '#fff' : '#666',
+              cursor: 'pointer',
+              fontSize: '13px',
+              transition: 'all 0.2s',
+            } as React.CSSProperties}
+          >
+            <BsGrid3X3Gap style={{ fontSize: '14px' }} />
+            {showCategory ? '取消分类' : '显示分类'}
+          </button>
+        </div>
+      </div>
       
       {/* 搜索框 */}
       <div style={{ marginBottom: '20px' }}>
@@ -285,6 +444,47 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
         </div>
       </div>
 
+      {/* 最近打开:仅显示 5 条,与全部应用用分隔线隔开 */}
+      {recentApps.length > 0 && (
+        <div style={{ marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid #eee' }}>
+          <div style={{
+            fontSize: '15px',
+            fontWeight: 'bold',
+            color: '#333',
+            marginBottom: '12px',
+            paddingBottom: '8px',
+            borderBottom: '1px solid #eee',
+            display: 'flex',
+            alignItems: 'center',
+            gap: '8px',
+          }}>
+            最近打开
+          </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}
+              >
+                {getAppIcon(item.app_name, 28)}
+                <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>
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
       {/* 应用列表 */}
       <div
         ref={scrollContainerRef}
@@ -305,32 +505,79 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
           </div>
         )}
 
-        {/* 应用项 - 使用原来的 browser-card 样式 */}
-        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
-          {mappings.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' && !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'
-              }}
-            >
-              {getAppIcon(mapping.app_name)}
-              <div>
-                <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
-                {loginLoading[mapping.app_id] && (
-                  <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
-                )}
+        {showCategory ? (
+          categorizedApps.map((group) => (
+            <div key={group.categoryId ?? 'uncategorized'} style={{ marginBottom: '28px' }}>
+              <div style={{
+                fontSize: '15px',
+                fontWeight: 'bold',
+                color: '#333',
+                marginBottom: '12px',
+                paddingBottom: '8px',
+                borderBottom: '1px solid #eee',
+                display: 'flex',
+                alignItems: 'center',
+                gap: '8px',
+              }}>
+                {group.categoryName}
+                <span style={{ fontSize: '12px', color: '#999', fontWeight: '400' }}>
+                  ({group.apps.length})
+                </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' && !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'
+                    }}
+                  >
+                    {getAppIcon(mapping.app_name)}
+                    <div>
+                      <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
+                      {loginLoading[mapping.app_id] && (
+                        <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
+                      )}
+                    </div>
+                  </div>
+                ))}
               </div>
             </div>
-          ))}
-        </div>
+          ))
+        ) : (
+          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
+            {mappings.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' && !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'
+                }}
+              >
+                {getAppIcon(mapping.app_name)}
+                <div>
+                  <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
+                  {loginLoading[mapping.app_id] && (
+                    <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
 
         {/* 加载更多提示 */}
         {isLoadingMore && (

+ 44 - 17
src/renderer/src/components/ChatWindow/MessageBubble.tsx

@@ -1,9 +1,11 @@
-import React from 'react'
+import React, { useState } from 'react'
 import { Message } from '../../types'
 import { downloadFile, getFileIcon, formatFileSize, refreshMediaUrl } from '../../utils/messageUtils'
 import { logger } from '../../utils/logger'
 import { api } from '../../services/api'
 
+const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
+
 interface MessageBubbleProps {
   msg: Message
   uploadProgress?: { [key: string]: number }
@@ -19,8 +21,41 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
   token,
   setMessages
 }) => {
-  // 通知类型消息
+  const [actionLoading, setActionLoading] = useState(false)
+
+  // 通知类型消息(带跳转:先请求 callback-url 再打开,打开方式与应用中心一致)
   if (msg.type === 'notification') {
+    const openInSystemBrowser = (() => {
+      try {
+        return localStorage.getItem(OPEN_IN_SYSTEM_BROWSER_KEY) === 'true'
+      } catch {
+        return false
+      }
+    })()
+
+    const handleNotificationAction = async (e: React.MouseEvent) => {
+      if (!msg.actionUrl) return
+      e.stopPropagation()
+      setActionLoading(true)
+      try {
+        let urlToOpen: string
+        try {
+          const res = await api.getMessageCallbackUrl(token, msg.id)
+          urlToOpen = res.callback_url
+        } catch (err) {
+          logger.warn('MessageBubble: callback-url failed, fallback to actionUrl', err)
+          urlToOpen = msg.actionUrl
+        }
+        if (openInSystemBrowser && window.electron?.ipcRenderer) {
+          window.electron.ipcRenderer.send('open-url-external', urlToOpen)
+        } else {
+          window.open(urlToOpen, '_blank')
+        }
+      } finally {
+        setActionLoading(false)
+      }
+    }
+
     return (
       <div style={{ minWidth: '250px' }}>
         {msg.title && (
@@ -33,35 +68,27 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
         </div>
         {msg.actionUrl && (
           <button
-            onClick={(e) => {
-              e.stopPropagation();
-              if (msg.actionUrl) {
-                if (window.electron && window.electron.ipcRenderer) {
-                  window.electron.ipcRenderer.send('open-url', msg.actionUrl);
-                } else {
-                  window.open(msg.actionUrl, '_blank');
-                }
-              }
-            }}
+            onClick={(e) => handleNotificationAction(e)}
+            disabled={actionLoading}
             style={{
               padding: '6px 16px',
-              backgroundColor: '#1aad19',
+              backgroundColor: actionLoading ? '#999' : '#1aad19',
               color: 'white',
               border: 'none',
               borderRadius: '4px',
-              cursor: 'pointer',
+              cursor: actionLoading ? 'not-allowed' : 'pointer',
               fontSize: '13px',
               fontWeight: '500',
               transition: 'background-color 0.2s'
             }}
             onMouseEnter={(e) => {
-              e.currentTarget.style.backgroundColor = '#179b16'
+              if (!actionLoading) e.currentTarget.style.backgroundColor = '#179b16'
             }}
             onMouseLeave={(e) => {
-              e.currentTarget.style.backgroundColor = '#1aad19'
+              if (!actionLoading) e.currentTarget.style.backgroundColor = '#1aad19'
             }}
           >
-            {msg.actionText || '立即处理'}
+            {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
           </button>
         )}
       </div>

+ 83 - 0
src/renderer/src/hooks/useMessages.ts

@@ -13,8 +13,11 @@ export function useMessages(
   const [messages, setMessages] = useState<Record<number, Message[]>>({})
   const [loadedContacts, setLoadedContacts] = useState<Set<number>>(new Set())
   const [isLoadingMessages, setIsLoadingMessages] = useState(false)
+  const [isLoadingMore, setIsLoadingMore] = useState(false)
+  const [hasMoreMessages, setHasMoreMessages] = useState<Record<number, boolean>>({})
   const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({})
   const markedReadContactsRef = useRef<Set<number>>(new Set())
+  const isLoadingMoreRef = useRef(false)
 
   // 获取指定联系人的消息列表
   const fetchMessages = useCallback(async (contactId: number) => {
@@ -68,6 +71,11 @@ export function useMessages(
         ...prev,
         [contactId]: formattedMessages
       }))
+
+      setHasMoreMessages(prev => ({
+        ...prev,
+        [contactId]: messagesData.length >= 50
+      }))
       
       setLoadedContacts(prev => new Set(prev).add(contactId))
     } catch (error: any) {
@@ -77,6 +85,78 @@ export function useMessages(
     }
   }, [token, currentUserId, loadedContacts])
 
+  const formatMessageData = useCallback((msg: any): Message => {
+    const isNotification = msg.type === 'NOTIFICATION'
+    const isSelf = !isNotification && currentUserId !== null && msg.sender_id === currentUserId
+
+    let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
+    if (isNotification) {
+      messageType = 'notification'
+    } else if (msg.content_type === 'IMAGE') {
+      messageType = 'image'
+    } else if (msg.content_type === 'VIDEO') {
+      messageType = 'video'
+    } else if (msg.content_type === 'FILE') {
+      messageType = 'file'
+    }
+
+    return {
+      id: msg.id,
+      content: msg.content || msg.title || '',
+      isSelf: isSelf,
+      type: messageType,
+      timestamp: new Date(msg.created_at).getTime(),
+      title: msg.title,
+      actionUrl: msg.action_url,
+      actionText: msg.action_text,
+      messageType: msg.type,
+      isRead: msg.is_read,
+      content_type: msg.content_type,
+      size: (msg as any).size
+    }
+  }, [currentUserId])
+
+  const fetchMoreMessages = useCallback(async (contactId: number) => {
+    if (!token || isLoadingMoreRef.current || !hasMoreMessages[contactId]) return
+
+    const currentMessages = messages[contactId] || []
+    const skip = currentMessages.length
+
+    isLoadingMoreRef.current = true
+    setIsLoadingMore(true)
+    try {
+      const messagesData = await api.getMessages(token, contactId, {
+        skip,
+        limit: 50
+      })
+
+      logger.info('App: Fetched more messages', { contactId, skip, count: messagesData.length })
+
+      setHasMoreMessages(prev => ({
+        ...prev,
+        [contactId]: messagesData.length >= 50
+      }))
+
+      if (messagesData.length === 0) return
+
+      const formattedMessages: Message[] = messagesData.map(formatMessageData)
+      formattedMessages.sort((a, b) => a.timestamp - b.timestamp)
+
+      const existingIds = new Set(currentMessages.map(m => m.id))
+      const newMessages = formattedMessages.filter(m => !existingIds.has(m.id))
+
+      setMessages(prev => ({
+        ...prev,
+        [contactId]: [...newMessages, ...(prev[contactId] || [])]
+      }))
+    } catch (error: any) {
+      logger.error('App: Failed to fetch more messages', { contactId, error: error.message })
+    } finally {
+      isLoadingMoreRef.current = false
+      setIsLoadingMore(false)
+    }
+  }, [token, messages, hasMoreMessages, formatMessageData])
+
   // 发送文本消息
   const sendMessage = useCallback(async (content: string, contactId: number) => {
     if (!content.trim() || !contactId) return
@@ -285,10 +365,13 @@ export function useMessages(
     messages,
     setMessages,
     isLoadingMessages,
+    isLoadingMore,
+    hasMoreMessages,
     uploadProgress,
     sendMessage,
     sendFileMessage,
     fetchMessages,
+    fetchMoreMessages,
     loadedContacts
   }
 }

+ 6 - 0
src/renderer/src/index.css

@@ -231,3 +231,9 @@ body {
   background-color: #f0f0f0;
   border-color: #d0d0d0;
 }
+
+/* 最近打开小卡片:一行 5 个 */
+.browser-card--recent {
+  padding: 10px;
+  margin-bottom: 0;
+}

+ 71 - 6
src/renderer/src/pages/ChatPage.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useRef, useEffect } from 'react'
+import React, { useState, useRef, useEffect, useCallback } from 'react'
 import { BsFolder2, BsClockHistory } from 'react-icons/bs'
 import { Contact, Message } from '../types'
 import { MessageBubble } from '../components/ChatWindow/MessageBubble'
@@ -22,6 +22,9 @@ interface ChatPageProps {
   onShowChatSearch: () => void
   onContextMenu: (e: React.MouseEvent, msgId: number) => void
   setMessages: React.Dispatch<React.SetStateAction<Record<number, Message[]>>>
+  isLoadingMore: boolean
+  hasMoreMessages: Record<number, boolean>
+  onLoadMoreMessages: (contactId: number) => void
 }
 
 export const ChatPage: React.FC<ChatPageProps> = ({
@@ -40,14 +43,64 @@ export const ChatPage: React.FC<ChatPageProps> = ({
   onFileSelect,
   onShowChatSearch,
   onContextMenu,
-  setMessages
+  setMessages,
+  isLoadingMore,
+  hasMoreMessages,
+  onLoadMoreMessages
 }) => {
   const messagesEndRef = useRef<HTMLDivElement>(null)
   const chatContainerRef = useRef<HTMLDivElement>(null)
+  const isFirstLoadRef = useRef(true)
+  const prevScrollHeightRef = useRef(0)
+  const isLoadingMoreRef = useRef(false)
 
   useEffect(() => {
-    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
-  }, [messages, activeContactId])
+    const container = chatContainerRef.current
+    if (!container) return
+
+    if (isLoadingMoreRef.current) {
+      const newScrollHeight = container.scrollHeight
+      const addedHeight = newScrollHeight - prevScrollHeightRef.current
+      if (addedHeight > 0) {
+        container.scrollTop = addedHeight
+      }
+      isLoadingMoreRef.current = false
+      prevScrollHeightRef.current = container.scrollHeight
+      return
+    }
+
+    if (isFirstLoadRef.current) {
+      messagesEndRef.current?.scrollIntoView({ behavior: 'instant' })
+      isFirstLoadRef.current = false
+    } else {
+      messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+    }
+    prevScrollHeightRef.current = container.scrollHeight
+  }, [messages])
+
+  useEffect(() => {
+    isFirstLoadRef.current = true
+    prevScrollHeightRef.current = 0
+    messagesEndRef.current?.scrollIntoView({ behavior: 'instant' })
+  }, [activeContactId])
+
+  const handleScroll = useCallback(() => {
+    const container = chatContainerRef.current
+    if (!container || !activeContactId) return
+
+    if (container.scrollTop < 50 && hasMoreMessages[activeContactId] && !isLoadingMore) {
+      prevScrollHeightRef.current = container.scrollHeight
+      isLoadingMoreRef.current = true
+      onLoadMoreMessages(activeContactId)
+    }
+  }, [activeContactId, hasMoreMessages, isLoadingMore, onLoadMoreMessages])
+
+  useEffect(() => {
+    const container = chatContainerRef.current
+    if (!container) return
+    container.addEventListener('scroll', handleScroll)
+    return () => container.removeEventListener('scroll', handleScroll)
+  }, [handleScroll])
 
   const handleSend = async () => {
     if (!inputValue.trim() || !activeContactId) return
@@ -114,7 +167,18 @@ export const ChatPage: React.FC<ChatPageProps> = ({
             <div>暂无消息</div>
           </div>
         ) : activeContactId ? (
-          (messages[activeContactId] || []).map((msg) => {
+          <>
+            {hasMoreMessages[activeContactId] && (
+              <div style={{ textAlign: 'center', padding: '10px', color: '#999', fontSize: '12px' }}>
+                {isLoadingMore ? '加载中...' : '向上滚动加载更多'}
+              </div>
+            )}
+            {!hasMoreMessages[activeContactId] && (messages[activeContactId] || []).length > 0 && (
+              <div style={{ textAlign: 'center', padding: '10px', color: '#ccc', fontSize: '12px' }}>
+                已加载全部消息
+              </div>
+            )}
+            {(messages[activeContactId] || []).map((msg) => {
             const isMediaMessage = msg.type === 'image' || msg.type === 'video' || msg.type === 'file' || 
                                  msg.content_type === 'IMAGE' || msg.content_type === 'VIDEO' || msg.content_type === 'FILE'
             
@@ -192,7 +256,8 @@ export const ChatPage: React.FC<ChatPageProps> = ({
                 </div>
               </div>
             )
-          })
+          })}
+          </>
         ) : null}
         <div ref={messagesEndRef} />
       </div>

+ 68 - 0
src/renderer/src/services/api.ts

@@ -66,6 +66,9 @@ export interface AppMapping {
   mapped_key: string;
   mapped_email: string | null;
   is_active: boolean;
+  description: string | null;
+  category_id: number | null;
+  category_name: string | null;
 }
 
 export interface AppMappingListResponse {
@@ -765,6 +768,28 @@ export const api = {
     }
   },
 
+  /**
+   * 获取通知/广播消息的可跳转链接(带 ticket 的 callback_url,用于 SSO 跳转)
+   * @param token 用户 Token
+   * @param messageId 消息ID
+   */
+  getMessageCallbackUrl: async (token: string, messageId: number): Promise<{ callback_url: string }> => {
+    const response = await fetch(`${API_BASE_URL}/messages/${messageId}/callback-url`, {
+      method: 'GET',
+      headers: {
+        'Authorization': `Bearer ${token}`
+      }
+    });
+
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      logger.error('API: Get message callback URL failed', { messageId, status: response.status, error });
+      throw new Error(error.detail || '获取跳转链接失败');
+    }
+
+    return response.json();
+  },
+
   /**
    * 获取联系人列表(聊天会话列表)
    * @param token 用户 Token
@@ -921,6 +946,49 @@ export const api = {
     return response.json();
   },
 
+  /**
+   * 获取启动台应用列表(含分类信息,用于应用中心展示)
+   * @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);
+    }
+
+    const response = await fetch(`${API_BASE_URL}/simple/me/launchpad-apps?${queryParams.toString()}`, {
+      method: 'GET',
+      headers: {
+        'Authorization': `Bearer ${token}`,
+        'Content-Type': 'application/json'
+      }
+    });
+
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      logger.error('API: Get launchpad apps failed', { status: response.status, error });
+      throw new Error(error.detail || '获取应用列表失败');
+    }
+
+    return response.json();
+  },
+
   /**
    * SSO 单点登录
    * @param token 用户 Token(可选,如果已登录)

+ 37 - 0
src/renderer/src/utils/iconGradientUtils.ts

@@ -0,0 +1,37 @@
+// 预设的渐变色(12 条即可,可按喜好改)
+const GRADIENTS = [
+  'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+  'linear-gradient(135deg, #2af598 0%, #009efd 100%)',
+  'linear-gradient(135deg, #b721ff 0%, #21d4fd 100%)',
+  'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)',
+  'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
+  'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
+  'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
+  'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
+  'linear-gradient(135deg, #30cfd0 0%, #330867 100%)',
+  'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
+  'linear-gradient(135deg, #fbc2eb 0%, #a6c1ee 100%)',
+  'linear-gradient(135deg, #8fd3f4 0%, #84fab0 100%)',
+];
+
+// 根据字符串得到固定渐变
+function stringToColor(str: string): string {
+  let hash = 0;
+  for (let i = 0; i < str.length; i++) {
+    hash = str.charCodeAt(i) + ((hash << 5) - hash);
+  }
+  const index = Math.abs(hash) % GRADIENTS.length;
+  return GRADIENTS[index];
+}
+
+// 按字数算字号(可选,和图标一起用时复现完整效果)
+function calculateFontSize(containerSize: number, textLength: number): number {
+  if (textLength <= 2) return containerSize * 0.35;
+  if (textLength <= 4) return containerSize * 0.25;
+  if (textLength <= 6) return containerSize * 0.18;
+  if (textLength <= 9) return containerSize * 0.15;
+  if (textLength <= 15) return containerSize * 0.12;
+  return containerSize * 0.1;
+}
+
+export { stringToColor, calculateFontSize };