Bladeren bron

V1.0.14 转发功能

liuq 1 maand geleden
bovenliggende
commit
96016d6dd9

+ 35 - 13
src/renderer/src/App.tsx

@@ -6,6 +6,7 @@ import ContactList from './components/ContactList'
 import AppMappingList from './components/AppMappingList'
 import { Navigation } from './components/Navigation'
 import { SettingsModal } from './components/Modals/SettingsModal'
+import { ForwardMessageModal } from './components/Modals/ForwardMessageModal'
 import { ChatPage } from './pages/ChatPage'
 import { useAuth } from './hooks/useAuth'
 import { useContacts } from './hooks/useContacts'
@@ -46,6 +47,8 @@ function App(): JSX.Element {
   const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
   const [inputValue, setInputValue] = useState('')
   const [contextMenu, setContextMenu] = useState<{ x: number, y: number, msgId: number } | null>(null)
+  const [messageToForward, setMessageToForward] = useState<Message | null>(null)
+  const [forwardSubmitting, setForwardSubmitting] = useState(false)
   
   // 搜索相关状态
   const [searchQuery, setSearchQuery] = useState('')
@@ -70,6 +73,8 @@ function App(): JSX.Element {
       setChatSearchResults([])
       setInputValue('')
       setContextMenu(null)
+      setMessageToForward(null)
+      setForwardSubmitting(false)
       setSidebarTotalUnread(0)
     }
   }, [isLoggedIn])
@@ -149,6 +154,7 @@ function App(): JSX.Element {
     uploadProgress,
     sendMessage: sendMessageHook,
     sendFileMessage,
+    forwardMessage,
     fetchMessages,
     fetchMoreMessages,
     loadedContacts
@@ -864,33 +870,49 @@ function App(): JSX.Element {
           >
             <div
               className="context-menu-item"
-              onClick={async () => {
+              onClick={e => {
+                e.stopPropagation()
                 if (activeContactId != null && contextMenu) {
                   const list = messages[activeContactId] || []
                   const msg = list.find(m => m.id === contextMenu.msgId)
                   if (msg) {
-                    const text =
-                      msg.type === 'text' || msg.type === 'notification'
-                        ? msg.content
-                        : [msg.title, msg.content].filter(Boolean).join('\n')
-                    try {
-                      await navigator.clipboard.writeText(text)
-                    } catch (err) {
-                      logger.error('App: copy message to clipboard failed', {
-                        error: err instanceof Error ? err.message : String(err)
-                      })
-                    }
+                    setMessageToForward(msg)
                   }
                 }
                 setContextMenu(null)
               }}
             >
-              复制
+              转发
             </div>
           </div>
         </>
       )}
 
+      <ForwardMessageModal
+        isOpen={messageToForward != null}
+        onClose={() => {
+          if (!forwardSubmitting) {
+            setMessageToForward(null)
+          }
+        }}
+        token={token}
+        recentContacts={contacts}
+        isSubmitting={forwardSubmitting}
+        onConfirm={async receiverIds => {
+          if (messageToForward == null) return
+          setForwardSubmitting(true)
+          try {
+            await forwardMessage(messageToForward, receiverIds)
+            setMessageToForward(null)
+            void fetchContacts({ silent: true }).then(() => refreshUnreadCount())
+          } catch (err) {
+            alert(err instanceof Error ? err.message : '转发失败')
+          } finally {
+            setForwardSubmitting(false)
+          }
+        }}
+      />
+
       <SettingsModal
         isOpen={showSettings}
         onClose={() => setShowSettings(false)}

+ 374 - 0
src/renderer/src/components/Modals/ForwardMessageModal.tsx

@@ -0,0 +1,374 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { BsSearch } from 'react-icons/bs'
+import { api, UserContact } from '../../services/api'
+import { logger } from '../../utils/logger'
+import { Contact } from '../../types'
+import { getDefaultAvatar } from '../../utils/avatarUtils'
+
+type Tab = 'recent' | 'address'
+
+function displayNameForUserContact(c: UserContact): string {
+  const parts: string[] = []
+  if (c.name) parts.push(c.name)
+  if (c.english_name) parts.push(c.english_name)
+  return parts.length > 0 ? parts.join(' / ') : `用户${c.id}`
+}
+
+interface ForwardMessageModalProps {
+  isOpen: boolean
+  onClose: () => void
+  token: string
+  recentContacts: Contact[]
+  isSubmitting: boolean
+  onConfirm: (receiverIds: number[]) => void | Promise<void>
+}
+
+export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
+  isOpen,
+  onClose,
+  token,
+  recentContacts,
+  isSubmitting,
+  onConfirm
+}) => {
+  const [tab, setTab] = useState<Tab>('recent')
+  const [selected, setSelected] = useState<Set<number>>(() => new Set())
+  const [nameById, setNameById] = useState<Record<number, string>>({})
+
+  const [searchKeyword, setSearchKeyword] = useState('')
+  const [addressResults, setAddressResults] = useState<UserContact[]>([])
+  const [addressLoading, setAddressLoading] = useState(false)
+  const [addressError, setAddressError] = useState('')
+
+  useEffect(() => {
+    if (!isOpen) {
+      setTab('recent')
+      setSelected(new Set())
+      setNameById({})
+      setSearchKeyword('')
+      setAddressResults([])
+      setAddressError('')
+    }
+  }, [isOpen])
+
+  const runAddressSearch = useCallback(
+    async (q: string) => {
+      if (!token) return
+      setAddressLoading(true)
+      setAddressError('')
+      try {
+        const list = await api.searchContacts(token, q, 50)
+        setAddressResults(list)
+        setNameById(prev => {
+          const n = { ...prev }
+          for (const c of list) {
+            n[c.id] = displayNameForUserContact(c)
+          }
+          return n
+        })
+      } catch (e: unknown) {
+        setAddressError(e instanceof Error ? e.message : '搜索失败')
+        setAddressResults([])
+      } finally {
+        setAddressLoading(false)
+      }
+    },
+    [token]
+  )
+
+  useEffect(() => {
+    if (!isOpen || tab !== 'address' || !token) return
+    const q = searchKeyword.trim()
+    if (q === '') {
+      setAddressResults([])
+      setAddressError('')
+      setAddressLoading(false)
+      return
+    }
+    const t = window.setTimeout(() => {
+      void runAddressSearch(q)
+    }, 300)
+    return () => clearTimeout(t)
+  }, [isOpen, tab, searchKeyword, token, runAddressSearch])
+
+  const recentSorted = React.useMemo(() => {
+    return recentContacts
+      .filter(c => c.id > 0)
+      .slice()
+      .sort((a, b) => (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0))
+  }, [recentContacts])
+
+  useEffect(() => {
+    if (!isOpen) return
+    setNameById(prev => {
+      const n = { ...prev }
+      for (const c of recentSorted) {
+        n[c.id] = c.name
+      }
+      return n
+    })
+  }, [isOpen, recentSorted])
+
+  const toggleId = (id: number) => {
+    if (id <= 0) return
+    setSelected(prev => {
+      const next = new Set(prev)
+      if (next.has(id)) next.delete(id)
+      else next.add(id)
+      return next
+    })
+  }
+
+  const handleConfirm = async () => {
+    if (selected.size === 0) return
+    try {
+      await onConfirm([...selected])
+    } catch (e: unknown) {
+      logger.error('ForwardMessageModal: onConfirm', { error: e })
+      alert(e instanceof Error ? e.message : '转发失败')
+    }
+  }
+
+  if (!isOpen) return null
+
+  return (
+    <div
+      style={{
+        position: 'fixed',
+        top: 0,
+        left: 0,
+        right: 0,
+        bottom: 0,
+        backgroundColor: 'rgba(0,0,0,0.5)',
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        zIndex: 1000
+      }}
+      onClick={onClose}
+    >
+      <div
+        style={{
+          backgroundColor: '#fff',
+          borderRadius: '8px',
+          width: '100%',
+          maxWidth: '440px',
+          maxHeight: '80vh',
+          display: 'flex',
+          flexDirection: 'column',
+          boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
+          overflow: 'hidden'
+        }}
+        onClick={e => e.stopPropagation()}
+      >
+        <div style={{ padding: '16px 20px', borderBottom: '1px solid #eee' }}>
+          <h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>转发给</h2>
+        </div>
+
+        <div style={{ display: 'flex', borderBottom: '1px solid #eee' }}>
+          <button
+            type="button"
+            onClick={() => setTab('recent')}
+            style={{
+              flex: 1,
+              padding: '12px',
+              border: 'none',
+              background: tab === 'recent' ? '#f0f9ff' : '#fff',
+              color: tab === 'recent' ? '#0066cc' : '#666',
+              fontWeight: tab === 'recent' ? 600 : 400,
+              cursor: 'pointer'
+            }}
+          >
+            近期
+          </button>
+          <button
+            type="button"
+            onClick={() => setTab('address')}
+            style={{
+              flex: 1,
+              padding: '12px',
+              border: 'none',
+              background: tab === 'address' ? '#f0f9ff' : '#fff',
+              color: tab === 'address' ? '#0066cc' : '#666',
+              fontWeight: tab === 'address' ? 600 : 400,
+              cursor: 'pointer'
+            }}
+          >
+            通讯录
+          </button>
+        </div>
+
+        <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+          {tab === 'recent' && (
+            <div
+              style={{
+                flex: 1,
+                overflowY: 'auto',
+                padding: '8px 0',
+                WebkitAppRegion: 'no-drag' as React.CSSProperties
+              }}
+            >
+              {recentSorted.length === 0 ? (
+                <div style={{ textAlign: 'center', color: '#999', padding: '24px' }}>暂无近期会话</div>
+              ) : (
+                recentSorted.map(c => (
+                  <label
+                    key={c.id}
+                    style={{
+                      display: 'flex',
+                      alignItems: 'center',
+                      padding: '10px 16px',
+                      cursor: 'pointer',
+                      gap: '12px',
+                      borderBottom: '1px solid #f5f5f5'
+                    }}
+                  >
+                    <input
+                      type="checkbox"
+                      checked={selected.has(c.id)}
+                      onChange={() => toggleId(c.id)}
+                    />
+                    <div style={{ width: 40, height: 40, borderRadius: '4px', overflow: 'hidden' }}>{c.avatar}</div>
+                    <div style={{ flex: 1, minWidth: 0 }}>
+                      <div style={{ fontSize: '15px', fontWeight: 500, color: '#333' }}>{c.name}</div>
+                    </div>
+                  </label>
+                ))
+              )}
+            </div>
+          )}
+
+          {tab === 'address' && (
+            <div
+              style={{
+                flex: 1,
+                display: 'flex',
+                flexDirection: 'column',
+                minHeight: 0
+              }}
+            >
+              <div
+                style={{
+                  padding: '10px 16px',
+                  borderBottom: '1px solid #eee',
+                  display: 'flex',
+                  alignItems: 'center',
+                  gap: '8px'
+                }}
+              >
+                <BsSearch style={{ color: '#999' }} />
+                <input
+                  type="text"
+                  placeholder="搜索(手机号/姓名/英文名)"
+                  value={searchKeyword}
+                  onChange={e => setSearchKeyword(e.target.value)}
+                  style={{
+                    flex: 1,
+                    border: 'none',
+                    outline: 'none',
+                    fontSize: '14px'
+                  }}
+                />
+              </div>
+              <div
+                style={{ flex: 1, overflowY: 'auto', minHeight: 0, WebkitAppRegion: 'no-drag' as React.CSSProperties }}
+              >
+                {addressLoading && (
+                  <div style={{ textAlign: 'center', color: '#999', padding: '16px' }}>加载中…</div>
+                )}
+                {addressError && <div style={{ color: '#c00', padding: '8px 16px' }}>{addressError}</div>}
+                {!addressLoading && addressResults.map(c => (
+                  <label
+                    key={c.id}
+                    style={{
+                      display: 'flex',
+                      alignItems: 'center',
+                      padding: '10px 16px',
+                      cursor: 'pointer',
+                      gap: '12px',
+                      borderBottom: '1px solid #f5f5f5'
+                    }}
+                  >
+                    <input
+                      type="checkbox"
+                      checked={selected.has(c.id)}
+                      onChange={() => toggleId(c.id)}
+                    />
+                    <div style={{ width: 40, height: 40, borderRadius: '4px', overflow: 'hidden' }}>
+                      {getDefaultAvatar(c.id, c.name || c.english_name, { sizePx: 40 })}
+                    </div>
+                    <div style={{ flex: 1, minWidth: 0 }}>
+                      <div style={{ fontSize: '15px', fontWeight: 500, color: '#333' }}>
+                        {displayNameForUserContact(c)}
+                      </div>
+                    </div>
+                  </label>
+                ))}
+                {!addressLoading && addressResults.length === 0 && !addressError && (
+                  <div style={{ textAlign: 'center', color: '#999', padding: '24px' }}>
+                    {searchKeyword.trim()
+                      ? '无匹配联系人'
+                      : '请输入关键词搜索联系人(手机号/姓名/英文名)'}
+                  </div>
+                )}
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div
+          style={{
+            padding: '12px 16px',
+            borderTop: '1px solid #eee',
+            display: 'flex',
+            flexDirection: 'column',
+            gap: '8px'
+          }}
+        >
+          <div style={{ fontSize: '13px', color: '#666' }}>已选 {selected.size} 人</div>
+          {selected.size > 0 && (
+            <div style={{ fontSize: '12px', color: '#999', lineHeight: 1.4, maxHeight: '48px', overflow: 'hidden' }}>
+              {Array.from(selected)
+                .map(id => nameById[id] || `用户${id}`)
+                .join('、')}
+            </div>
+          )}
+          <div style={{ display: 'flex', gap: '8px' }}>
+            <button
+              type="button"
+              onClick={onClose}
+              disabled={isSubmitting}
+              style={{
+                flex: 1,
+                padding: '10px',
+                border: '1px solid #ddd',
+                borderRadius: '6px',
+                background: '#fff',
+                cursor: isSubmitting ? 'not-allowed' : 'pointer'
+              }}
+            >
+              取消
+            </button>
+            <button
+              type="button"
+              onClick={() => void handleConfirm()}
+              disabled={isSubmitting || selected.size === 0}
+              style={{
+                flex: 1,
+                padding: '10px',
+                border: 'none',
+                borderRadius: '6px',
+                background: selected.size === 0 || isSubmitting ? '#ccc' : '#1aad19',
+                color: '#fff',
+                fontWeight: 500,
+                cursor: isSubmitting || selected.size === 0 ? 'not-allowed' : 'pointer'
+              }}
+            >
+              {isSubmitting ? '发送中…' : '确定转发'}
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 17 - 9
src/renderer/src/hooks/useContacts.ts

@@ -53,15 +53,23 @@ export function useContacts(
       const contactsData = await api.getContacts(token)
       logger.info('App: Fetched contacts', { count: contactsData.length, silent })
 
-      const formattedContacts: Contact[] = contactsData.map(contact => ({
-        id: contact.id,
-        name: contact.name || `用户${contact.id}`,
-        avatar: getSessionAvatar(contact.id, contact.name, 40),
-        lastMessage: contact.last_message,
-        lastMessageTime: contact.last_message_time ? formatMessageTime(contact.last_message_time) : undefined,
-        unreadCount: contact.unread_count ?? 0,
-        remarks: contact.remarks ?? null
-      }))
+      const formattedContacts: Contact[] = contactsData.map(contact => {
+        const rawTime = contact.last_message_time
+        const t =
+          rawTime != null && rawTime !== ''
+            ? new Date(rawTime).getTime()
+            : undefined
+        return {
+          id: contact.id,
+          name: contact.name || `用户${contact.id}`,
+          avatar: getSessionAvatar(contact.id, contact.name, 40),
+          lastMessage: contact.last_message,
+          lastMessageTime: rawTime ? formatMessageTime(rawTime) : undefined,
+          lastMessageAt: Number.isFinite(t) ? t : undefined,
+          unreadCount: contact.unread_count ?? 0,
+          remarks: contact.remarks ?? null
+        }
+      })
 
       setContacts(formattedContacts)
 

+ 221 - 1
src/renderer/src/hooks/useMessages.ts

@@ -2,8 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react'
 import { Message } from '../types'
 import { api } from '../services/api'
 import { logger } from '../utils/logger'
-import { isNotificationLikeMessage } from '../utils/messageTypes'
 import { formatApiMessageToMessage } from '../utils/formatMessage'
+import { getSessionAvatar } from '../utils/avatarUtils'
 
 /** silent:不显示会话加载态;失败时保留当前消息 */
 export type RefreshMessagesOptions = { silent?: boolean }
@@ -256,6 +256,225 @@ export function useMessages(
     }
   }, [token])
 
+  const previewForForwardedMessage = useCallback((msg: Message): string => {
+    if (msg.type === 'image') return '[图片]'
+    if (msg.type === 'video') return '[视频]'
+    if (msg.type === 'file') return msg.title || '[文件]'
+    if (msg.type === 'notification') {
+      if (msg.content_type === 'USER_NOTIFICATION' && msg.title) {
+        return msg.title
+      }
+      return [msg.title, msg.content].filter(Boolean).join(' ') || msg.content
+    }
+    return msg.content || ''
+  }, [])
+
+  const forwardMessage = useCallback(
+    async (msg: Message, receiverIds: number[]) => {
+      if (!token) return
+      const unique = [...new Set(receiverIds)].filter(id => id > 0)
+      if (unique.length === 0) return
+
+      const lastPreview = previewForForwardedMessage(msg)
+
+      const updateContactRow = (contactId: number) => {
+        setContacts(prev => {
+          const has = prev.some(c => c.id === contactId)
+          if (has) {
+            return prev.map(c =>
+              c.id === contactId
+                ? { ...c, lastMessage: lastPreview, lastMessageTime: '刚刚', lastMessageAt: Date.now() }
+                : c
+            )
+          }
+          return [
+            ...prev,
+            {
+              id: contactId,
+              name: `用户${contactId}`,
+              avatar: getSessionAvatar(contactId, `用户${contactId}`, 40),
+              lastMessage: lastPreview,
+              lastMessageTime: '刚刚',
+              lastMessageAt: Date.now(),
+              unreadCount: 0,
+              remarks: null
+            }
+          ]
+        })
+      }
+
+      for (const contactId of unique) {
+        const tempMessageId = Date.now() + Math.floor(Math.random() * 1e9)
+
+        if (msg.content_type === 'USER_NOTIFICATION' && msg.type === 'notification') {
+          const tempMessage: Message = {
+            id: tempMessageId,
+            content: msg.content,
+            isSelf: true,
+            type: 'notification',
+            timestamp: Date.now(),
+            title: msg.title,
+            actionUrl: msg.actionUrl,
+            actionText: msg.actionText,
+            content_type: 'USER_NOTIFICATION',
+            app_id: msg.app_id
+          }
+          setMessages(prev => ({
+            ...prev,
+            [contactId]: [...(prev[contactId] || []), tempMessage]
+          }))
+          updateContactRow(contactId)
+          try {
+            const response = await api.sendUserNotificationMessage(token, {
+              receiverId: contactId,
+              title: msg.title || '通知',
+              content: msg.content,
+              actionUrl: msg.actionUrl,
+              actionText: msg.actionText,
+              appId: msg.app_id
+            })
+            const realMessage = formatApiMessageToMessage(response, currentUserId)
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || [])
+                .filter(m => m.id !== tempMessageId)
+                .concat(realMessage)
+            }))
+          } catch (error: unknown) {
+            logger.error('App: forward USER_NOTIFICATION failed', { contactId, error })
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || []).filter(m => m.id !== tempMessageId)
+            }))
+            throw error
+          }
+          continue
+        }
+
+        if (msg.type === 'notification') {
+          const text = [msg.title, msg.content].filter(Boolean).join('\n') || msg.content
+          if (!text.trim()) {
+            continue
+          }
+          const tempMessage: Message = {
+            id: tempMessageId,
+            content: text.trim(),
+            isSelf: true,
+            type: 'text',
+            timestamp: Date.now()
+          }
+          setMessages(prev => ({
+            ...prev,
+            [contactId]: [...(prev[contactId] || []), tempMessage]
+          }))
+          updateContactRow(contactId)
+          try {
+            const response = await api.sendMessage(token, contactId, text.trim(), 'TEXT')
+            const realMessage = formatApiMessageToMessage(response, currentUserId)
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || [])
+                .filter(m => m.id !== tempMessageId)
+                .concat(realMessage)
+            }))
+          } catch (error: unknown) {
+            logger.error('App: forward notification as TEXT failed', { contactId, error })
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || []).filter(m => m.id !== tempMessageId)
+            }))
+            throw error
+          }
+          continue
+        }
+
+        if (msg.type === 'text') {
+          const text = (msg.content || '').trim()
+          if (!text) continue
+          const tempMessage: Message = {
+            id: tempMessageId,
+            content: text,
+            isSelf: true,
+            type: 'text',
+            timestamp: Date.now()
+          }
+          setMessages(prev => ({
+            ...prev,
+            [contactId]: [...(prev[contactId] || []), tempMessage]
+          }))
+          updateContactRow(contactId)
+          try {
+            const response = await api.sendMessage(token, contactId, text, 'TEXT')
+            const realMessage = formatApiMessageToMessage(response, currentUserId)
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || [])
+                .filter(m => m.id !== tempMessageId)
+                .concat(realMessage)
+            }))
+          } catch (error: unknown) {
+            logger.error('App: forward text failed', { contactId, error })
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || []).filter(m => m.id !== tempMessageId)
+            }))
+            throw error
+          }
+          continue
+        }
+
+        if (msg.type === 'image' || msg.type === 'video' || msg.type === 'file') {
+          const contentType = (msg.type === 'image' ? 'IMAGE' : msg.type === 'video' ? 'VIDEO' : 'FILE') as
+            | 'IMAGE'
+            | 'VIDEO'
+            | 'FILE'
+          const content = msg.content
+          if (!content) {
+            logger.warn('App: forward media skipped, empty content', { type: msg.type })
+            continue
+          }
+          const title = msg.title || '私信'
+          const tempMessage: Message = {
+            id: tempMessageId,
+            content,
+            isSelf: true,
+            type: msg.type,
+            timestamp: Date.now(),
+            title,
+            content_type: contentType,
+            size: msg.size
+          }
+          setMessages(prev => ({
+            ...prev,
+            [contactId]: [...(prev[contactId] || []), tempMessage]
+          }))
+          updateContactRow(contactId)
+          try {
+            const response = await api.sendMessage(token, contactId, content, contentType, title)
+            const realMessage = formatApiMessageToMessage(response, currentUserId)
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || [])
+                .filter(m => m.id !== tempMessageId)
+                .concat(realMessage)
+            }))
+          } catch (error: unknown) {
+            logger.error('App: forward media failed', { contactId, error })
+            setMessages(prev => ({
+              ...prev,
+              [contactId]: (prev[contactId] || []).filter(m => m.id !== tempMessageId)
+            }))
+            throw error
+          }
+          continue
+        }
+
+        logger.warn('App: forwardMessage unsupported message', { type: msg.type, content_type: msg.content_type })
+      }
+    },
+    [token, setContacts, currentUserId, previewForForwardedMessage]
+  )
+
   // 切换到某个会话时同步托盘:清除该会话在托盘未读列表中的展示(服务端已读由点击会话时 read-all 处理)
   useEffect(() => {
     if (activeContactId && window.electron?.ipcRenderer) {
@@ -279,6 +498,7 @@ export function useMessages(
     uploadProgress,
     sendMessage,
     sendFileMessage,
+    forwardMessage,
     fetchMessages: refreshMessages,
     fetchMoreMessages,
     loadedContacts

+ 2 - 0
src/renderer/src/types/index.ts

@@ -23,6 +23,8 @@ export interface Contact {
   avatar: React.ReactNode
   lastMessage?: string
   lastMessageTime?: string
+  /** 原始时间戳(ms),用于「近期」会话排序等 */
+  lastMessageAt?: number
   unreadCount: number
   remarks?: string | null
 }