Explorar el Código

V1.0.11版本上传

liuq hace 3 semanas
padre
commit
9650a5b559

+ 1 - 1
package.json

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

+ 3 - 1
src/main/index.ts

@@ -672,7 +672,7 @@ function createWindow(): void {
     width: 1000,
     height: 700,
     show: false,
-    resizable: false, // Prevent resizing on login screen
+    resizable: true, // 与手动登录后一致,否则 Windows 标题栏不显示最大化;登录成功仍会 setSize/center
     title: '韫珠IM',
     icon: iconPath,
     autoHideMenuBar: true,
@@ -883,6 +883,8 @@ app.whenReady().then(() => {
 
   ipcMain.on('ping', () => console.log('pong'))
 
+  ipcMain.handle('get-app-version', () => app.getVersion())
+
   // 生产环境自动更新(自建服务器)
   if (!is.dev) {
     autoUpdater.checkForUpdatesAndNotify()

+ 5 - 5
src/renderer/src/App.tsx

@@ -125,7 +125,7 @@ function App(): JSX.Element {
         prevActiveTabRef.current = activeTab
         return
       }
-      void fetchContacts().then(() => refreshUnreadCount())
+      void fetchContacts({ silent: true }).then(() => refreshUnreadCount())
     }
     prevActiveTabRef.current = activeTab
   }, [activeTab, token, fetchContacts, refreshUnreadCount])
@@ -156,9 +156,9 @@ function App(): JSX.Element {
       if (focusRefreshTimerRef.current) clearTimeout(focusRefreshTimerRef.current)
       focusRefreshTimerRef.current = setTimeout(() => {
         focusRefreshTimerRef.current = null
-        void fetchContacts().then(() => refreshUnreadCount())
+        void fetchContacts({ silent: true }).then(() => refreshUnreadCount())
         const cid = activeContactIdRef.current
-        if (cid != null) void fetchMessages(cid)
+        if (cid != null) void fetchMessages(cid, { silent: true })
       }, 300)
     }
     window.addEventListener('blur', onBlur)
@@ -262,14 +262,14 @@ function App(): JSX.Element {
 
   // 处理发送消息
   const handleSend = useCallback(async () => {
-    if (!activeContactId) return
+    if (activeContactId == null || activeContactId < 0) return
     await sendMessageHook(inputValue, activeContactId)
     setInputValue('')
   }, [inputValue, activeContactId, sendMessageHook])
 
   // 处理文件选择
   const handleFileSelect = useCallback((file: File) => {
-    if (!activeContactId) return
+    if (activeContactId == null || activeContactId < 0) return
     const contentType = api.getContentType(file) as 'IMAGE' | 'VIDEO' | 'FILE'
     sendFileMessage(file, contentType, activeContactId)
   }, [activeContactId, sendFileMessage])

+ 98 - 57
src/renderer/src/components/AppMappingList.tsx

@@ -26,10 +26,13 @@ const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
 
 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>>({})
@@ -75,6 +78,17 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
     skipRef.current = skip
   }, [skip])
 
+  useEffect(() => {
+    mappingsRef.current = mappings
+  }, [mappings])
+
+  useEffect(() => {
+    isLoadingRef.current = isLoading
+  }, [isLoading])
+  useEffect(() => {
+    isLoadingMoreRef.current = isLoadingMore
+  }, [isLoadingMore])
+
   // 从本地加载最近打开(按用户隔离;最多 5 条,越近期越靠前)
   useEffect(() => {
     if (!recentStorageKey) {
@@ -111,83 +125,99 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
     })
   }, [recentStorageKey])
 
-  // 加载账号映射列表
-  const loadMappings = useCallback(async (isRefresh: boolean = false) => {
-    if (isLoading || (isLoadingMore && !isRefresh)) return
+  // 加载账号映射列表;silent:不清空列表、不顶全屏 loading,失败仅 warn
+  const loadMappings = useCallback(
+    async (isRefresh: boolean = false, options?: { silent?: boolean }) => {
+      const silent = options?.silent === true
 
-    const currentLoadId = (loadIdRef.current += 1)
-
-    try {
-      if (isRefresh) {
-        setIsRefreshing(true)
-        setSkip(0)
-        skipRef.current = 0
-        setMappings([])
-      } else {
-        setIsLoadingMore(true)
-      }
-      setIsLoading(true)
-      setError(null)
+      if (isLoadingMoreRef.current && !isRefresh) return
+      if (!silent && isLoadingRef.current) return
 
-      const currentSkip = isRefresh ? 0 : skipRef.current
+      const currentLoadId = (loadIdRef.current += 1)
 
-      const keyword = searchKeywordRef.current.trim() || undefined
-      const result = await api.getLaunchpadApps(token, {
-        skip: currentSkip,
-        limit: pageSize,
-        app_name: keyword
-      })
+      try {
+        if (isRefresh) {
+          setSkip(0)
+          skipRef.current = 0
+          if (!silent) {
+            setIsRefreshing(true)
+            setMappings([])
+          }
+        } else {
+          setIsLoadingMore(true)
+        }
+        if (!silent) {
+          setIsLoading(true)
+          setError(null)
+        }
 
-      // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
-      if (currentLoadId !== loadIdRef.current) return
+        const currentSkip = isRefresh ? 0 : skipRef.current
 
-      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
+        const keyword = searchKeywordRef.current.trim() || undefined
+        const result = await api.getLaunchpadApps(token, {
+          skip: currentSkip,
+          limit: pageSize,
+          app_name: keyword
         })
-      }
 
-      if (isRefresh) {
+        // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
+        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)
+        }
+      } catch (err: any) {
+        if (currentLoadId !== loadIdRef.current) return
+        if (silent) {
+          logger.warn('AppMappingList: silent refresh failed', err)
+        } else {
+          logger.error('AppMappingList: Failed to load mappings', err)
+          setError(err.message || '加载应用列表失败')
+        }
         setIsRefreshing(false)
-      } else {
         setIsLoadingMore(false)
+      } finally {
+        if (currentLoadId === loadIdRef.current) {
+          if (!silent) {
+            setIsLoading(false)
+          }
+        }
       }
-    } catch (err: any) {
-      if (currentLoadId !== loadIdRef.current) return
-      logger.error('AppMappingList: Failed to load mappings', err)
-      setError(err.message || '加载应用列表失败')
-      setIsRefreshing(false)
-      setIsLoadingMore(false)
-    } finally {
-      if (currentLoadId === loadIdRef.current) {
-        setIsLoading(false)
-      }
-    }
-  }, [token, pageSize])
+    },
+    [token, pageSize]
+  )
 
   // 初始化加载
   useEffect(() => {
     loadMappings(true)
-  }, [token])
+  }, [token, loadMappings])
 
-  // 处理搜索(防抖)
+  // 处理搜索(防抖);已有列表时静默刷新,避免整页闪白
   useEffect(() => {
     if (searchTimerRef.current) {
       clearTimeout(searchTimerRef.current)
     }
 
     searchTimerRef.current = setTimeout(() => {
-      loadMappings(true)
+      loadMappings(true, { silent: mappingsRef.current.length > 0 })
     }, 500)
 
     return () => {
@@ -195,7 +225,18 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
         clearTimeout(searchTimerRef.current)
       }
     }
-  }, [searchKeyword])
+  }, [searchKeyword, loadMappings])
+
+  // 窗口从后台切回前台时静默刷新
+  useEffect(() => {
+    const onVis = () => {
+      if (document.visibilityState === 'visible' && token) {
+        void loadMappings(true, { silent: true })
+      }
+    }
+    document.addEventListener('visibilitychange', onVis)
+    return () => document.removeEventListener('visibilitychange', onVis)
+  }, [token, loadMappings])
 
   // 处理滚动事件(使用节流避免频繁触发)
   const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)

+ 28 - 1
src/renderer/src/components/Modals/SettingsModal.tsx

@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useEffect, useState } from 'react'
 
 type UpdateStatus = 'idle' | 'checking' | 'update-available' | 'update-downloaded' | 'latest' | 'error'
 
@@ -25,6 +25,29 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
   updateDownloadProgress = null,
   onCheckForUpdates
 }) => {
+  const [appVersion, setAppVersion] = useState<string | null>(null)
+
+  useEffect(() => {
+    if (!isOpen) return
+    const ipc = typeof window !== 'undefined' && window.electron?.ipcRenderer
+    if (!ipc) {
+      setAppVersion(null)
+      return
+    }
+    let cancelled = false
+    ipc
+      .invoke('get-app-version')
+      .then((v: unknown) => {
+        if (!cancelled && typeof v === 'string') setAppVersion(v)
+      })
+      .catch(() => {
+        if (!cancelled) setAppVersion(null)
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [isOpen])
+
   if (!isOpen) return null
 
   const hasUpdateAPI = typeof window !== 'undefined' && window.electron?.ipcRenderer
@@ -79,6 +102,10 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
             <div style={{ color: '#999', fontSize: '12px', marginBottom: '5px' }}>用户名</div>
             <div style={{ color: '#333', fontSize: '14px' }}>{currentUserName || '未知'}</div>
           </div>
+          <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
+            <div style={{ color: '#999', fontSize: '12px', marginBottom: '5px' }}>当前版本</div>
+            <div style={{ color: '#333', fontSize: '14px' }}>{appVersion ?? '—'}</div>
+          </div>
 
           {hasUpdateAPI && onCheckForUpdates && (
             <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>

+ 5 - 0
src/renderer/src/hooks/useAuth.ts

@@ -77,6 +77,11 @@ export function useAuth() {
               userId: userInfo.id || savedUserId,
               userName
             })
+
+            if (window.electron?.ipcRenderer) {
+              window.electron.ipcRenderer.send('login-success')
+              logger.info('useAuth: Sent login-success to main process (auto login)')
+            }
           } else {
             logger.warn('useAuth: Auto login failed, token invalid - no user info returned')
             localStorage.removeItem('auth_token')

+ 26 - 10
src/renderer/src/hooks/useContacts.ts

@@ -5,6 +5,9 @@ import { logger } from '../utils/logger'
 import { formatMessageTime } from '../utils/timeUtils'
 import { getSessionAvatar } from '../utils/avatarUtils'
 
+/** silent:不显示全屏「加载联系人」;失败时跳过更新 */
+export type FetchContactsOptions = { silent?: boolean }
+
 export function useContacts(
   token: string,
   activeContactId: number | null,
@@ -39,14 +42,17 @@ export function useContacts(
     }
   }, [token, setActiveContactId])
 
-  const fetchContacts = useCallback(async () => {
+  const fetchContacts = useCallback(async (options?: FetchContactsOptions) => {
     if (!token) return
-    
-    setIsLoadingContacts(true)
+
+    const silent = options?.silent === true
+    if (!silent) {
+      setIsLoadingContacts(true)
+    }
     try {
       const contactsData = await api.getContacts(token)
-      logger.info('App: Fetched contacts', { count: contactsData.length })
-      
+      logger.info('App: Fetched contacts', { count: contactsData.length, silent })
+
       const formattedContacts: Contact[] = contactsData.map(contact => ({
         id: contact.id,
         name: contact.name || `用户${contact.id}`,
@@ -55,16 +61,26 @@ export function useContacts(
         lastMessageTime: contact.last_message_time ? formatMessageTime(contact.last_message_time) : undefined,
         unreadCount: contact.unread_count ?? 0
       }))
-      
+
       setContacts(formattedContacts)
-      
+
       if (formattedContacts.length > 0 && activeContactIdRef.current === null) {
         setActiveContactId(formattedContacts[0].id)
       }
-    } catch (error: any) {
-      logger.error('App: Failed to fetch contacts', { error: error.message })
+    } catch (error: unknown) {
+      if (silent) {
+        logger.warn('App: fetchContacts skipped (silent)', {
+          error: error instanceof Error ? error.message : String(error)
+        })
+      } else {
+        logger.error('App: Failed to fetch contacts', {
+          error: error instanceof Error ? error.message : String(error)
+        })
+      }
     } finally {
-      setIsLoadingContacts(false)
+      if (!silent) {
+        setIsLoadingContacts(false)
+      }
     }
   }, [token, setActiveContactId])
 

+ 24 - 6
src/renderer/src/hooks/useMessages.ts

@@ -5,6 +5,9 @@ import { logger } from '../utils/logger'
 import { isNotificationLikeMessage } from '../utils/messageTypes'
 import { formatApiMessageToMessage } from '../utils/formatMessage'
 
+/** silent:不显示会话加载态;失败时保留当前消息 */
+export type RefreshMessagesOptions = { silent?: boolean }
+
 export function useMessages(
   token: string,
   currentUserId: number | null,
@@ -45,17 +48,20 @@ export function useMessages(
   )
 
   // 拉取该会话最新一页;切换会话时也会调用,并与本地已加载的更早历史、未入库的本地消息合并
-  const refreshMessages = useCallback(async (contactId: number) => {
+  const refreshMessages = useCallback(async (contactId: number, options?: RefreshMessagesOptions) => {
     if (!token) return
 
-    setIsLoadingMessages(true)
+    const silent = options?.silent === true
+    if (!silent) {
+      setIsLoadingMessages(true)
+    }
     try {
       const messagesData = await api.getMessages(token, contactId, {
         skip: 0,
         limit: 50
       })
 
-      logger.info('App: Refreshed messages', { contactId, count: messagesData.length })
+      logger.info('App: Refreshed messages', { contactId, count: messagesData.length, silent })
 
       const formattedMessages: Message[] = messagesData.map(formatMessageData)
       formattedMessages.sort((a, b) => a.timestamp - b.timestamp)
@@ -85,10 +91,22 @@ export function useMessages(
       }))
 
       setLoadedContacts(prev => new Set(prev).add(contactId))
-    } catch (error: any) {
-      logger.error('App: Failed to refresh messages', { contactId, error: error.message })
+    } catch (error: unknown) {
+      if (silent) {
+        logger.warn('App: refresh messages skipped (silent)', {
+          contactId,
+          error: error instanceof Error ? error.message : String(error)
+        })
+      } else {
+        logger.error('App: Failed to refresh messages', {
+          contactId,
+          error: error instanceof Error ? error.message : String(error)
+        })
+      }
     } finally {
-      setIsLoadingMessages(false)
+      if (!silent) {
+        setIsLoadingMessages(false)
+      }
     }
   }, [token, formatMessageData])
 

+ 14 - 5
src/renderer/src/hooks/useWebSocket.ts

@@ -5,6 +5,7 @@ import { isNotificationLikeMessage } from '../utils/messageTypes'
 import { formatApiMessageToMessage } from '../utils/formatMessage'
 import { Message, Contact } from '../types'
 import { getSessionAvatar } from '../utils/avatarUtils'
+import type { FetchContactsOptions } from './useContacts'
 
 interface UseWebSocketProps {
   isLoggedIn: boolean
@@ -14,9 +15,9 @@ interface UseWebSocketProps {
   contactsRef: React.MutableRefObject<Contact[]>
   setMessages: React.Dispatch<React.SetStateAction<Record<number, Message[]>>>
   setContacts: React.Dispatch<React.SetStateAction<Contact[]>>
-  /** 收到消息后刷新服务端未读总数(防抖,不作为会话内计数) */
+  /** 收到消息后与会话列表一并刷新(防抖内先拉 conversations 再拉总未读) */
   refreshUnreadCount: () => void
-  fetchContacts: () => Promise<void>
+  fetchContacts: (options?: FetchContactsOptions) => Promise<void>
 }
 
 export function useWebSocket({
@@ -207,8 +208,8 @@ export function useWebSocket({
           })
         } else {
           logger.info('App: Contact not in list, refreshing contacts', { targetContactId })
-          fetchContactsRef.current().catch(error => {
-            logger.error('App: Failed to refresh contacts after receiving message', error)
+          void fetchContactsRef.current({ silent: true }).catch(error => {
+            logger.warn('App: fetchContacts after WS (missing contact) failed', error)
           })
         }
 
@@ -217,7 +218,15 @@ export function useWebSocket({
         }
         debounceTimerRef.current = setTimeout(() => {
           debounceTimerRef.current = null
-          refreshUnreadCountRef.current()
+          void fetchContactsRef
+            .current({ silent: true })
+            .then(() => {
+              refreshUnreadCountRef.current()
+            })
+            .catch((error: unknown) => {
+              logger.warn('App: fetchContacts after WS message failed', error)
+              refreshUnreadCountRef.current()
+            })
         }, 400)
 
         // 仅当「当前会话且主窗口在前台」时抑制托盘;否则(含同会话但窗口在后台)推 unreadMap

+ 87 - 61
src/renderer/src/pages/ChatPage.tsx

@@ -145,13 +145,15 @@ export const ChatPage: React.FC<ChatPageProps> = ({
     )
   }
 
+  /** 负 id:应用系统通知会话(-app_id),只读,不展示发消息/发文件 */
+  const isAppNotificationSession = activeContactId != null && activeContactId < 0
+
   return (
     <>
-      <div style={{ padding: '0 20px', height: '60px', display: 'flex', alignItems: 'center', borderBottom: '1px solid #e7e7e7', justifyContent: 'space-between', WebkitAppRegion: 'drag' } as React.CSSProperties}>
+      <div style={{ padding: '0 20px', height: '60px', display: 'flex', alignItems: 'center', borderBottom: '1px solid #e7e7e7', WebkitAppRegion: 'drag' } as React.CSSProperties}>
         <div style={{ fontWeight: 'bold', fontSize: '16px', WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
           {activeContact.name}
         </div>
-        <div style={{ fontSize: '20px', cursor: 'pointer', color: '#666', WebkitAppRegion: 'no-drag' } as React.CSSProperties}>...</div>
       </div>
       
       <div 
@@ -325,21 +327,20 @@ export const ChatPage: React.FC<ChatPageProps> = ({
         <div ref={messagesEndRef} />
       </div>
 
-      <div style={{ height: '180px', borderTop: '1px solid #e7e7e7', backgroundColor: '#ffffff', display: 'flex', flexDirection: 'column' }}>
-        <div style={{ padding: '10px 15px', display: 'flex', gap: '15px' }}>
-          <label htmlFor="file-input" style={{ cursor: 'pointer' }}>
-            <BsFolder2 className="toolbar-icon" title="发送文件" />
-          </label>
-          <input
-            type="file"
-            id="file-input"
-            style={{ display: 'none' }}
-            onChange={handleFileSelect}
-            accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.ppt,.pptx"
-          />
-          <BsClockHistory 
-            className="toolbar-icon" 
-            title="聊天记录" 
+      {isAppNotificationSession ? (
+        <div
+          style={{
+            borderTop: '1px solid #e7e7e7',
+            backgroundColor: '#ffffff',
+            padding: '10px 15px',
+            display: 'flex',
+            alignItems: 'center',
+            gap: '15px'
+          }}
+        >
+          <BsClockHistory
+            className="toolbar-icon"
+            title="聊天记录"
             onClick={(e) => {
               e.preventDefault()
               e.stopPropagation()
@@ -348,56 +349,81 @@ export const ChatPage: React.FC<ChatPageProps> = ({
             style={{ cursor: 'pointer' }}
           />
         </div>
-        
-        <textarea
-          style={{
-            flex: 1,
-            border: 'none',
-            resize: 'none',
-            padding: '0 20px',
-            outline: 'none',
-            fontSize: '14px',
-            fontFamily: 'inherit',
-            lineHeight: '1.5'
-          }}
-          value={inputValue}
-          onChange={(e) => setInputValue(e.target.value)}
-          onKeyDown={(e) => {
-            if (e.key === 'Enter' && !e.shiftKey) {
-              e.preventDefault()
-              handleSend()
-            }
-          }}
-        />
-        <div style={{ padding: '10px 20px', display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
-          <div style={{ fontSize: '12px', color: '#999', marginRight: '10px' }}>
-            按 Enter 发送
+      ) : (
+        <div style={{ height: '180px', borderTop: '1px solid #e7e7e7', backgroundColor: '#ffffff', display: 'flex', flexDirection: 'column' }}>
+          <div style={{ padding: '10px 15px', display: 'flex', gap: '15px' }}>
+            <label htmlFor="file-input" style={{ cursor: 'pointer' }}>
+              <BsFolder2 className="toolbar-icon" title="发送文件" />
+            </label>
+            <input
+              type="file"
+              id="file-input"
+              style={{ display: 'none' }}
+              onChange={handleFileSelect}
+              accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.ppt,.pptx"
+            />
+            <BsClockHistory
+              className="toolbar-icon"
+              title="聊天记录"
+              onClick={(e) => {
+                e.preventDefault()
+                e.stopPropagation()
+                onShowChatSearch()
+              }}
+              style={{ cursor: 'pointer' }}
+            />
           </div>
-          <button
-            onClick={handleSend}
+
+          <textarea
             style={{
-              padding: '6px 24px',
-              backgroundColor: '#f5f5f5',
-              border: '1px solid #e7e7e7',
-              borderRadius: '4px',
-              cursor: 'pointer',
-              color: '#606060',
+              flex: 1,
+              border: 'none',
+              resize: 'none',
+              padding: '0 20px',
+              outline: 'none',
               fontSize: '14px',
-              transition: 'all 0.2s'
-            }}
-            onMouseEnter={(e) => {
-              e.currentTarget.style.backgroundColor = '#1aad19'
-              e.currentTarget.style.color = 'white'
+              fontFamily: 'inherit',
+              lineHeight: '1.5'
             }}
-            onMouseLeave={(e) => {
-              e.currentTarget.style.backgroundColor = '#f5f5f5'
-              e.currentTarget.style.color = '#606060'
+            value={inputValue}
+            onChange={(e) => setInputValue(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === 'Enter' && !e.shiftKey) {
+                e.preventDefault()
+                handleSend()
+              }
             }}
-          >
-            发送(S)
-          </button>
+          />
+          <div style={{ padding: '10px 20px', display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
+            <div style={{ fontSize: '12px', color: '#999', marginRight: '10px' }}>
+              按 Enter 发送
+            </div>
+            <button
+              onClick={handleSend}
+              style={{
+                padding: '6px 24px',
+                backgroundColor: '#f5f5f5',
+                border: '1px solid #e7e7e7',
+                borderRadius: '4px',
+                cursor: 'pointer',
+                color: '#606060',
+                fontSize: '14px',
+                transition: 'all 0.2s'
+              }}
+              onMouseEnter={(e) => {
+                e.currentTarget.style.backgroundColor = '#1aad19'
+                e.currentTarget.style.color = 'white'
+              }}
+              onMouseLeave={(e) => {
+                e.currentTarget.style.backgroundColor = '#f5f5f5'
+                e.currentTarget.style.color = '#606060'
+              }}
+            >
+              发送(S)
+            </button>
+          </div>
         </div>
-      </div>
+      )}
     </>
   )
 }