Forráskód Böngészése

新增增再次提醒

liuq 3 hete
szülő
commit
3a06099303

+ 4 - 30
src/renderer/src/App.tsx

@@ -14,7 +14,7 @@ import { useWebSocket } from './hooks/useWebSocket'
 import { UserContact, api } from './services/api'
 import { logger } from './utils/logger'
 import { formatFullDateTime } from './utils/timeUtils'
-import { isNotificationLikeMessage, isMessageFromSelf } from './utils/messageTypes'
+import { formatApiMessageToMessage } from './utils/formatMessage'
 import { getDefaultAvatar, getSessionAvatar } from './utils/avatarUtils'
 import { Contact, Message, SearchResult } from './types'
 
@@ -227,35 +227,9 @@ function App(): JSX.Element {
               limit: 50
             })
             
-            const formattedMessages: Message[] = messagesData.map(msg => {
-              const notificationLike = isNotificationLikeMessage(msg.type, msg.content_type)
-              const isSelf = isMessageFromSelf(msg, currentUserId)
-              
-              let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-              if (notificationLike) {
-                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,
-                content_type: msg.content_type,
-                size: (msg as any).size
-              }
-            })
+            const formattedMessages: Message[] = messagesData.map(msg =>
+              formatApiMessageToMessage(msg, currentUserId)
+            )
             
             formattedMessages.sort((a, b) => a.timestamp - b.timestamp)
             

+ 98 - 26
src/renderer/src/components/ChatWindow/MessageBubble.tsx

@@ -5,6 +5,8 @@ import { logger } from '../../utils/logger'
 import { api } from '../../services/api'
 import { normalizeExternalUrl } from '../../utils/urlUtils'
 import { isNotificationLikeMessage } from '../../utils/messageTypes'
+import { formatApiMessageToMessage } from '../../utils/formatMessage'
+import { showToast } from '../../utils/toast'
 
 const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
 
@@ -12,6 +14,7 @@ interface MessageBubbleProps {
   msg: Message
   uploadProgress?: { [key: string]: number }
   activeContactId: number | null
+  currentUserId: number | null
   token: string
   setMessages: React.Dispatch<React.SetStateAction<Record<number, Message[]>>>
 }
@@ -20,10 +23,12 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
   msg,
   uploadProgress = {},
   activeContactId,
+  currentUserId,
   token,
   setMessages
 }) => {
   const [actionLoading, setActionLoading] = useState(false)
+  const [remindLoading, setRemindLoading] = useState(false)
 
   // 通知类型消息(带跳转:先请求 callback-url 再打开,打开方式与应用中心一致)
   if (msg.type === 'notification' || isNotificationLikeMessage(msg.messageType, msg.content_type)) {
@@ -35,6 +40,44 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
       }
     })()
 
+    const isSenderUserNotification =
+      msg.isSelf && msg.content_type === 'USER_NOTIFICATION'
+    const targetReceiverId = msg.receiver_id ?? activeContactId
+    const showRemindButton = Boolean(isSenderUserNotification && targetReceiverId != null)
+    const showCallbackButton = Boolean(!isSenderUserNotification && msg.actionUrl)
+
+    const handleRemind = async (e: React.MouseEvent) => {
+      e.stopPropagation()
+      const rid = msg.receiver_id ?? activeContactId
+      if (!rid || remindLoading) return
+      setRemindLoading(true)
+      try {
+        const response = await api.sendUserNotificationMessage(token, {
+          receiverId: rid,
+          title: msg.title || '通知',
+          content: msg.content || '',
+          actionUrl: msg.actionUrl,
+          actionText: msg.actionText,
+          appId: msg.app_id
+        })
+        const newMsg = formatApiMessageToMessage(response, currentUserId)
+        if (activeContactId == null) return
+        setMessages(prev => {
+          const list = prev[activeContactId] || []
+          if (list.some(m => m.id === newMsg.id)) return prev
+          return {
+            ...prev,
+            [activeContactId]: [...list, newMsg]
+          }
+        })
+      } catch (err) {
+        logger.error('MessageBubble: remind failed', err)
+        showToast(err instanceof Error ? err.message : '发送失败')
+      } finally {
+        setRemindLoading(false)
+      }
+    }
+
     const handleNotificationAction = async (e: React.MouseEvent) => {
       if (!msg.actionUrl) return
       e.stopPropagation()
@@ -101,33 +144,62 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
         >
           {msg.content}
         </div>
-        {msg.actionUrl && (
+        {(showRemindButton || showCallbackButton) && (
           <div style={{ padding: '0 12px 12px', backgroundColor: '#ffffff' }}>
-            <button
-              type="button"
-              onClick={(e) => handleNotificationAction(e)}
-              disabled={actionLoading}
-              style={{
-                padding: '5px 16px',
-                backgroundColor: 'transparent',
-                color: actionLoading ? '#999' : '#1890ff',
-                border: `1px solid ${actionLoading ? '#d9d9d9' : '#1890ff'}`,
-                borderRadius: '4px',
-                cursor: actionLoading ? 'not-allowed' : 'pointer',
-                fontSize: '13px',
-                fontWeight: 500,
-                transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
-              }}
-              onMouseEnter={(e) => {
-                if (actionLoading) return
-                e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
-              }}
-              onMouseLeave={(e) => {
-                e.currentTarget.style.backgroundColor = 'transparent'
-              }}
-            >
-              {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
-            </button>
+            {showRemindButton && (
+              <button
+                type="button"
+                onClick={(e) => handleRemind(e)}
+                disabled={remindLoading}
+                style={{
+                  padding: '5px 16px',
+                  backgroundColor: 'transparent',
+                  color: remindLoading ? '#999' : '#1890ff',
+                  border: `1px solid ${remindLoading ? '#d9d9d9' : '#1890ff'}`,
+                  borderRadius: '4px',
+                  cursor: remindLoading ? 'not-allowed' : 'pointer',
+                  fontSize: '13px',
+                  fontWeight: 500,
+                  transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
+                }}
+                onMouseEnter={(e) => {
+                  if (remindLoading) return
+                  e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
+                }}
+                onMouseLeave={(e) => {
+                  e.currentTarget.style.backgroundColor = 'transparent'
+                }}
+              >
+                {remindLoading ? '发送中...' : '再次提醒'}
+              </button>
+            )}
+            {showCallbackButton && (
+              <button
+                type="button"
+                onClick={(e) => handleNotificationAction(e)}
+                disabled={actionLoading}
+                style={{
+                  padding: '5px 16px',
+                  backgroundColor: 'transparent',
+                  color: actionLoading ? '#999' : '#1890ff',
+                  border: `1px solid ${actionLoading ? '#d9d9d9' : '#1890ff'}`,
+                  borderRadius: '4px',
+                  cursor: actionLoading ? 'not-allowed' : 'pointer',
+                  fontSize: '13px',
+                  fontWeight: 500,
+                  transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
+                }}
+                onMouseEnter={(e) => {
+                  if (actionLoading) return
+                  e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
+                }}
+                onMouseLeave={(e) => {
+                  e.currentTarget.style.backgroundColor = 'transparent'
+                }}
+              >
+                {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
+              </button>
+            )}
           </div>
         )}
       </div>

+ 8 - 54
src/renderer/src/hooks/useMessages.ts

@@ -2,7 +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, isMessageFromSelf } from '../utils/messageTypes'
+import { isNotificationLikeMessage } from '../utils/messageTypes'
+import { formatApiMessageToMessage } from '../utils/formatMessage'
 
 export function useMessages(
   token: string,
@@ -39,35 +40,10 @@ export function useMessages(
     setIsLoadingMore(false)
   }, [token, currentUserId])
 
-  const formatMessageData = useCallback((msg: any): Message => {
-    const notificationLike = isNotificationLikeMessage(msg.type, msg.content_type)
-    const isSelf = isMessageFromSelf(msg, currentUserId)
-
-    let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-    if (notificationLike) {
-      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,
-      content_type: msg.content_type,
-      size: (msg as any).size
-    }
-  }, [currentUserId])
+  const formatMessageData = useCallback(
+    (msg: any): Message => formatApiMessageToMessage(msg, currentUserId),
+    [currentUserId]
+  )
 
   // 拉取该会话最新一页;切换会话时也会调用,并与本地已加载的更早历史、未入库的本地消息合并
   const refreshMessages = useCallback(async (contactId: number) => {
@@ -186,29 +162,7 @@ export function useMessages(
       const response = await api.sendMessage(token, contactId, content.trim(), 'TEXT')
       logger.info('App: Message sent successfully', { content, receiverId: contactId, messageId: response.id })
       
-      let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-      if (isNotificationLikeMessage(response.type, response.content_type)) {
-        messageType = 'notification'
-      } else if (response.content_type === 'IMAGE') {
-        messageType = 'image'
-      } else if (response.content_type === 'VIDEO') {
-        messageType = 'video'
-      } else if (response.content_type === 'FILE') {
-        messageType = 'file'
-      }
-      
-      const realMessage: Message = {
-        id: response.id,
-        content: response.content,
-        isSelf: true,
-        type: messageType,
-        timestamp: new Date(response.created_at).getTime(),
-        messageType: response.type,
-        content_type: response.content_type,
-        title: response.title,
-        actionUrl: response.action_url,
-        actionText: response.action_text
-      }
+      const realMessage = formatApiMessageToMessage(response, currentUserId)
       
       setMessages(prev => ({
         ...prev,
@@ -224,7 +178,7 @@ export function useMessages(
       }))
       throw error
     }
-  }, [token, setContacts])
+  }, [token, setContacts, currentUserId])
 
   // 发送文件消息
   const sendFileMessage = useCallback(async (file: File, contentType: 'IMAGE' | 'VIDEO' | 'FILE' = 'FILE', contactId: number) => {

+ 4 - 30
src/renderer/src/hooks/useWebSocket.ts

@@ -1,7 +1,8 @@
 import { useEffect, useRef } from 'react'
 import { WebSocketService } from '../services/websocket'
 import { logger } from '../utils/logger'
-import { isNotificationLikeMessage, isMessageFromSelf } from '../utils/messageTypes'
+import { isNotificationLikeMessage } from '../utils/messageTypes'
+import { formatApiMessageToMessage } from '../utils/formatMessage'
 import { Message, Contact } from '../types'
 import { getSessionAvatar } from '../utils/avatarUtils'
 
@@ -93,35 +94,8 @@ export function useWebSocket({
           return
         }
         
-        const isSelf = isMessageFromSelf(incomingMsg, currentUserId)
-        
-        let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-        if (notificationLike) {
-          messageType = 'notification'
-        } else {
-          const contentType = incomingMsg.content_type
-          if (contentType === 'IMAGE') {
-            messageType = 'image'
-          } else if (contentType === 'VIDEO') {
-            messageType = 'video'
-          } else if (contentType === 'FILE') {
-            messageType = 'file'
-          }
-        }
-        
-        const newMessage: Message = {
-          id: incomingMsg.id || Date.now(),
-          content: incomingMsg.content || incomingMsg.title || '收到一条新消息',
-          isSelf: isSelf,
-          type: messageType,
-          timestamp: incomingMsg.created_at ? new Date(incomingMsg.created_at).getTime() : Date.now(),
-          title: incomingMsg.title,
-          actionUrl: incomingMsg.action_url,
-          actionText: incomingMsg.action_text || '立即处理',
-          messageType: incomingMsg.type,
-          content_type: incomingMsg.content_type,
-          size: (incomingMsg as any).size
-        }
+        const newMessage = formatApiMessageToMessage(incomingMsg, currentUserId)
+        const isSelf = newMessage.isSelf
 
         const currentChatId = activeContactIdRef.current
         const isCurrentChat = currentChatId === targetContactId

+ 2 - 0
src/renderer/src/pages/ChatPage.tsx

@@ -256,6 +256,7 @@ export const ChatPage: React.FC<ChatPageProps> = ({
                           msg={msg}
                           uploadProgress={uploadProgress}
                           activeContactId={activeContactId}
+                          currentUserId={currentUserId}
                           token={token}
                           setMessages={setMessages}
                         />
@@ -302,6 +303,7 @@ export const ChatPage: React.FC<ChatPageProps> = ({
                           msg={msg}
                           uploadProgress={uploadProgress}
                           activeContactId={activeContactId}
+                          currentUserId={currentUserId}
                           token={token}
                           setMessages={setMessages}
                         />

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

@@ -32,6 +32,7 @@ export interface MessageResponse {
   sender_id?: number;
   receiver_id?: number;
   app_user_id?: string;
+  app_id?: number;
   size?: number;
 }
 
@@ -657,6 +658,71 @@ export const api = {
     return response.json();
   },
 
+  /**
+   * 发送与历史 USER_NOTIFICATION 同结构的消息(再次提醒)
+   */
+  sendUserNotificationMessage: async (
+    token: string,
+    options: {
+      receiverId: number;
+      title: string;
+      content: string;
+      actionUrl?: string;
+      actionText?: string;
+      appId?: number;
+    }
+  ): Promise<MessageResponse> => {
+    const {
+      receiverId,
+      title,
+      content,
+      actionUrl,
+      actionText,
+      appId
+    } = options;
+    logger.info('API: Send USER_NOTIFICATION message', { receiverId, title });
+    const body: Record<string, unknown> = {
+      receiver_id: receiverId,
+      type: 'MESSAGE',
+      content_type: 'USER_NOTIFICATION',
+      title,
+      content
+    };
+    if (actionUrl != null && actionUrl !== '') {
+      body.action_url = actionUrl;
+    }
+    if (actionText != null && actionText !== '') {
+      body.action_text = actionText;
+    }
+    if (appId != null) {
+      body.app_id = appId;
+    }
+
+    const response = await fetch(`${API_BASE_URL}/messages/`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      body: JSON.stringify(body)
+    });
+
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      logger.error('API: Send USER_NOTIFICATION failed', { status: response.status, error });
+      const detail = (error as { detail?: unknown }).detail;
+      const msg =
+        typeof detail === 'string'
+          ? detail
+          : Array.isArray(detail)
+            ? detail.map((d: unknown) => (typeof d === 'object' && d && 'msg' in d ? String((d as { msg: string }).msg) : String(d))).join('; ')
+            : '发送失败';
+      throw new Error(msg || '发送失败');
+    }
+
+    return response.json();
+  },
+
   /**
    * 应用调用:发送通知(签名认证)
    * 适用于业务系统后端向用户推送通知

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

@@ -12,6 +12,9 @@ export interface Message {
   messageType?: 'MESSAGE' | 'NOTIFICATION'
   content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION'
   size?: number
+  receiver_id?: number
+  sender_id?: number
+  app_id?: number
 }
 
 export interface Contact {

+ 36 - 0
src/renderer/src/utils/formatMessage.ts

@@ -0,0 +1,36 @@
+import { Message } from '../types'
+import { isMessageFromSelf, isNotificationLikeMessage } from './messageTypes'
+
+/** 将接口 / WS 消息转为前端 Message(含 receiver_id 等,供再次提醒等使用) */
+export function formatApiMessageToMessage(msg: any, currentUserId: number | null): Message {
+  const notificationLike = isNotificationLikeMessage(msg.type, msg.content_type)
+  const isSelf = isMessageFromSelf(msg, currentUserId)
+
+  let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
+  if (notificationLike) {
+    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 != null ? msg.id : Date.now(),
+    content: msg.content || msg.title || '',
+    isSelf,
+    type: messageType,
+    timestamp: msg.created_at ? new Date(msg.created_at).getTime() : Date.now(),
+    title: msg.title,
+    actionUrl: msg.action_url,
+    actionText: msg.action_text,
+    messageType: msg.type,
+    content_type: msg.content_type,
+    size: msg.size,
+    receiver_id: msg.receiver_id,
+    sender_id: msg.sender_id,
+    app_id: msg.app_id
+  }
+}

+ 25 - 0
src/renderer/src/utils/toast.ts

@@ -0,0 +1,25 @@
+/** 轻量提示(对齐移动端 uni.showToast 的常见用法) */
+export function showToast(message: string, durationMs = 2500): void {
+  const el = document.createElement('div')
+  el.textContent = message
+  el.setAttribute('role', 'status')
+  el.style.cssText = [
+    'position:fixed',
+    'bottom:88px',
+    'left:50%',
+    'transform:translateX(-50%)',
+    'max-width:min(90vw,360px)',
+    'padding:10px 16px',
+    'background:rgba(0,0,0,0.78)',
+    'color:#fff',
+    'font-size:14px',
+    'line-height:1.4',
+    'border-radius:8px',
+    'z-index:100000',
+    'pointer-events:none',
+    'box-sizing:border-box',
+    'word-break:break-word'
+  ].join(';')
+  document.body.appendChild(el)
+  window.setTimeout(() => el.remove(), durationMs)
+}