liuq 1 месяц назад
Родитель
Сommit
6a1063fed6

+ 9 - 5
backend/app/api/v1/endpoints/messages.py

@@ -1,6 +1,6 @@
 from typing import Any, List, Optional
 from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
 from sqlalchemy import or_, and_, desc
 from app.core.database import get_db
 from app.api.v1 import deps
@@ -23,6 +23,10 @@ def _process_message_content(message: Message) -> MessageResponse:
     # Pydantic v2 use model_validate
     response = MessageResponse.model_validate(message)
     
+    # 填充应用名称
+    if message.app_id and message.app:
+        response.app_name = message.app.app_name
+    
     if message.content_type in [ContentType.IMAGE, ContentType.VIDEO, ContentType.FILE]:
         # 如果内容是对象 Key (不以 http 开头),则生成预签名 URL
         # 如果是旧数据的完整 URL,则保持不变 (或视需求处理)
@@ -83,7 +87,7 @@ async def create_message(
         
         mapping = db.query(AppUserMapping).filter(
             AppUserMapping.app_id == message_in.app_id,
-            AppUserMapping.app_user_id == message_in.app_user_id
+            AppUserMapping.mapped_key == message_in.app_user_id
         ).first()
         
         if not mapping:
@@ -177,7 +181,7 @@ def read_messages(
     """
     获取当前用户的消息列表 (所有历史记录)
     """
-    query = db.query(Message).filter(Message.receiver_id == current_user.id)
+    query = db.query(Message).options(joinedload(Message.app)).filter(Message.receiver_id == current_user.id)
     if unread_only:
         query = query.filter(Message.is_read == False)
     
@@ -269,13 +273,13 @@ def get_chat_history(
     """
     if other_user_id == 0:
         # System Notifications
-        query = db.query(Message).filter(
+        query = db.query(Message).options(joinedload(Message.app)).filter(
             Message.receiver_id == current_user.id,
             Message.type == MessageType.NOTIFICATION
         ).order_by(Message.created_at.desc())
     else:
         # User Chat
-        query = db.query(Message).filter(
+        query = db.query(Message).options(joinedload(Message.app)).filter(
             or_(
                 and_(Message.sender_id == current_user.id, Message.receiver_id == other_user_id),
                 and_(Message.sender_id == other_user_id, Message.receiver_id == current_user.id)

+ 1 - 0
backend/app/schemas/message.py

@@ -46,6 +46,7 @@ class MessageResponse(BaseModel):
     sender_id: Optional[int]
     receiver_id: int
     app_id: Optional[int]
+    app_name: Optional[str] = None  # 应用名称,用于前端显示
     
     type: MessageType
     content_type: ContentType

+ 277 - 50
frontend/src/views/message/index.vue

@@ -48,55 +48,111 @@
 
     <!-- 右侧:聊天窗口 -->
     <div class="chat-window">
-      <template v-if="currentChatId">
+      <template v-if="currentChatId !== null">
         <!-- 顶部标题 -->
         <header class="chat-header-bar">
           <h3>{{ currentChatUser?.full_name || currentChatUser?.username }}</h3>
         </header>
 
         <!-- 消息流区域 -->
-        <main class="message-stream" ref="scrollContainer">
-          <div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-me': msg.sender_id === currentUserId }">
-            
-            <UserAvatar 
-              v-if="msg.sender_id !== currentUserId"
-              :name="currentChatUser?.full_name || currentChatUser?.username || '?'" 
-              :userId="msg.sender_id" 
-              :size="36" 
-              class="msg-avatar"
-            />
-            
-            <div class="message-content-wrapper">
-               <div class="message-bubble">
-                 <!-- TEXT -->
-                 <span v-if="msg.content_type === 'TEXT'">{{ msg.content }}</span>
-                 
-                 <!-- IMAGE -->
-                 <el-image 
-                   v-else-if="msg.content_type === 'IMAGE'" 
-                   :src="msg.content" 
-                   :preview-src-list="[msg.content]"
-                   class="msg-image"
-                 />
-                 
-                 <!-- FILE -->
-                 <div v-else-if="msg.content_type === 'FILE'" class="msg-file">
-                    <el-icon><Document /></el-icon>
-                    <a :href="msg.content" target="_blank">下载文件</a>
-                 </div>
-                 
-                 <!-- VIDEO -->
-                 <video v-else-if="msg.content_type === 'VIDEO'" :src="msg.content" controls class="msg-video"></video>
-               </div>
-               <div class="message-time">{{ formatTime(msg.created_at) }}</div>
+        <main class="message-stream" ref="scrollContainer" :class="{ 'notification-mode': currentChatId === 0 }">
+          <!-- 系统通知卡片样式 -->
+          <template v-if="currentChatId === 0">
+            <div v-for="msg in messages" :key="msg.id" class="notification-card-wrapper">
+              <div class="notification-card">
+                <!-- 发送者信息 -->
+                <div v-if="msg.app_id || msg.type === 'NOTIFICATION'" class="notification-sender">
+                  <el-icon class="sender-icon"><House /></el-icon>
+                  <span class="sender-name">{{ getAppName(msg) || '系统通知' }}</span>
+                </div>
+                
+                <!-- 通知标题 -->
+                <div class="notification-title">{{ msg.title }}</div>
+                
+                <!-- 通知内容 -->
+                <div class="notification-content">
+                  <!-- 如果内容是JSON格式,解析为键值对 -->
+                  <template v-if="isJsonContent(msg.content)">
+                    <div 
+                      v-for="(value, key) in parseJsonContent(msg.content)" 
+                      :key="key"
+                      class="notification-detail-item"
+                    >
+                      <span class="detail-label">{{ key }}:</span>
+                      <span class="detail-value">{{ value }}</span>
+                    </div>
+                  </template>
+                  <!-- 普通文本内容 -->
+                  <template v-else>
+                    <div class="notification-text">{{ msg.content }}</div>
+                  </template>
+                </div>
+                
+                <!-- 操作按钮 -->
+                <div v-if="msg.action_url" class="notification-action">
+                  <a 
+                    :href="msg.action_url" 
+                    class="action-link"
+                    @click.prevent="handleNotificationAction(msg)"
+                  >
+                    <span>{{ msg.action_text || '进入小程序查看' }}</span>
+                    <el-icon class="action-icon"><ArrowRight /></el-icon>
+                  </a>
+                </div>
+                
+                <!-- 时间戳 -->
+                <div class="notification-time">{{ formatTime(msg.created_at) }}</div>
+              </div>
             </div>
+          </template>
+          
+          <!-- 普通消息样式(用户私信) -->
+          <template v-else>
+            <div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-me': msg.sender_id === currentUserId }">
+              
+              <UserAvatar 
+                v-if="msg.sender_id !== currentUserId && msg.sender_id !== null"
+                :name="currentChatUser?.full_name || currentChatUser?.username || '系统通知'" 
+                :userId="msg.sender_id || 0" 
+                :size="36" 
+                class="msg-avatar"
+              />
+              
+              <div class="message-content-wrapper">
+                 <div class="message-bubble">
+                   <!-- TEXT -->
+                   <span v-if="msg.content_type === 'TEXT'">{{ msg.content }}</span>
+                   
+                   <!-- IMAGE -->
+                   <el-image 
+                     v-else-if="msg.content_type === 'IMAGE'" 
+                     :src="msg.content" 
+                     :preview-src-list="[msg.content]"
+                     class="msg-image"
+                   />
+                   
+                   <!-- FILE -->
+                   <div v-else-if="msg.content_type === 'FILE'" class="msg-file">
+                      <el-icon><Document /></el-icon>
+                      <a :href="msg.content" target="_blank">下载文件</a>
+                   </div>
+                   
+                   <!-- VIDEO -->
+                   <video v-else-if="msg.content_type === 'VIDEO'" :src="msg.content" controls class="msg-video"></video>
+                 </div>
+                 <div class="message-time">{{ formatTime(msg.created_at) }}</div>
+              </div>
 
-            <!-- Removed UserAvatar for Me -->
-          </div>
+              <!-- Removed UserAvatar for Me -->
+            </div>
+          </template>
+          
+          <!-- 空状态提示 -->
+          <el-empty v-if="messages.length === 0" description="暂无消息" :image-size="60" />
         </main>
 
-        <!-- 底部输入框 -->
-        <footer class="input-area">
+        <!-- 底部输入框 - 系统消息不显示输入框 -->
+        <footer v-if="currentChatId !== 0" class="input-area">
           <div class="toolbar">
             <el-upload
               class="upload-demo"
@@ -156,9 +212,9 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, computed, watch, nextTick } from 'vue'
+import { ref, onMounted, computed, nextTick } from 'vue'
 import UserAvatar from '@/components/UserAvatar.vue'
-import { Search, Plus, Picture, Folder, Document } from '@element-plus/icons-vue'
+import { Picture, Document, House, ArrowRight } from '@element-plus/icons-vue'
 import api from '@/utils/request'
 import { useAuthStore } from '@/store/auth'
 import { ElMessage } from 'element-plus'
@@ -183,6 +239,9 @@ const selectedUserId = ref<number | null>(null)
 const userOptions = ref<any[]>([])
 const searchingUsers = ref(false)
 
+// 应用名称缓存
+const appNameCache = ref<Record<number, string>>({})
+
 // Computed
 const filteredConversations = computed(() => {
   if (!searchText.value) return conversations.value
@@ -215,8 +274,14 @@ const initWebSocket = () => {
       const msg = JSON.parse(event.data)
       if (msg.type === 'NEW_MESSAGE') {
         const newMessage = msg.data
+        const isSystemNotification = newMessage.type === 'NOTIFICATION' || newMessage.sender_id === null
+        
         // If current chat is open, append message
-        if (currentChatId.value === newMessage.sender_id || (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id)) {
+        if (isSystemNotification && currentChatId.value === 0) {
+          // System notification and viewing system messages
+          messages.value.push(newMessage)
+          scrollToBottom()
+        } else if (currentChatId.value === newMessage.sender_id || (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id)) {
            // Handle case where sender_id is myself (from another device) or currentChat is sender
            messages.value.push(newMessage)
            scrollToBottom()
@@ -228,14 +293,24 @@ const initWebSocket = () => {
         }
         
         // Update sidebar list
-        updateConversationPreview(
-            newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
-            newMessage.content,
-            newMessage.content_type
-        )
+        if (isSystemNotification) {
+          // System notification: update user_id 0 conversation
+          updateConversationPreview(0, newMessage.content, newMessage.content_type)
+        } else {
+          updateConversationPreview(
+              newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
+              newMessage.content,
+              newMessage.content_type
+          )
+        }
         
         // Update unread count if not current chat
-        if (newMessage.sender_id !== currentUserId.value && currentChatId.value !== newMessage.sender_id) {
+        if (isSystemNotification) {
+          if (currentChatId.value !== 0) {
+            const conv = conversations.value.find(c => c.user_id === 0)
+            if (conv) conv.unread_count = (conv.unread_count || 0) + 1
+          }
+        } else if (newMessage.sender_id !== currentUserId.value && currentChatId.value !== newMessage.sender_id) {
             const conv = conversations.value.find(c => c.user_id === newMessage.sender_id)
             if (conv) conv.unread_count = (conv.unread_count || 0) + 1
         }
@@ -306,7 +381,7 @@ const loadHistory = async (userId: number) => {
 }
 
 const sendMessage = async () => {
-  if (!inputMessage.value.trim() || !currentChatId.value) return
+  if (!inputMessage.value.trim() || !currentChatId.value || currentChatId.value === 0) return
   
   const payload = {
     receiver_id: currentChatId.value,
@@ -315,7 +390,7 @@ const sendMessage = async () => {
     content_type: 'TEXT',
     title: '私信'
   }
-  
+
   try {
     const res = await api.post('/messages/', payload)
     messages.value.push(res.data)
@@ -331,6 +406,8 @@ const sendMessage = async () => {
 }
 
 const handleUpload = async (options: any) => {
+  if (!currentChatId.value || currentChatId.value === 0) return
+  
   const { file } = options
   const formData = new FormData()
   formData.append('file', file)
@@ -442,6 +519,47 @@ const startNewChat = () => {
   selectedUserId.value = null
 }
 
+// 获取应用名称
+const getAppName = (msg: any) => {
+  // 优先使用后端返回的app_name
+  if (msg.app_name) {
+    return msg.app_name
+  }
+  // 如果没有,尝试从缓存获取
+  if (msg.app_id && appNameCache.value[msg.app_id]) {
+    return appNameCache.value[msg.app_id]
+  }
+  return null
+}
+
+// 判断内容是否为JSON格式
+const isJsonContent = (content: string) => {
+  if (!content) return false
+  try {
+    const parsed = JSON.parse(content)
+    return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
+  } catch {
+    return false
+  }
+}
+
+// 解析JSON内容
+const parseJsonContent = (content: string) => {
+  try {
+    return JSON.parse(content)
+  } catch {
+    return {}
+  }
+}
+
+// 处理通知操作点击
+const handleNotificationAction = (msg: any) => {
+  if (msg.action_url) {
+    // 如果是SSO跳转链接,直接打开
+    window.open(msg.action_url, '_blank')
+  }
+}
+
 </script>
 
 <style scoped>
@@ -570,6 +688,10 @@ const startNewChat = () => {
   padding: 20px;
 }
 
+.message-stream.notification-mode {
+  background: #f5f5f5;
+}
+
 .message-row {
   display: flex;
   margin-bottom: 20px;
@@ -654,4 +776,109 @@ const startNewChat = () => {
   display: flex;
   justify-content: flex-end;
 }
+
+/* 系统通知卡片样式 */
+.notification-card-wrapper {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: center;
+}
+
+.notification-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  max-width: 90%;
+  width: 100%;
+  position: relative;
+}
+
+.notification-sender {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+  font-size: 13px;
+  color: #666;
+}
+
+.sender-icon {
+  font-size: 16px;
+  margin-right: 6px;
+  color: #999;
+}
+
+.sender-name {
+  font-weight: 500;
+}
+
+.notification-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12px;
+  line-height: 1.4;
+}
+
+.notification-content {
+  margin-bottom: 12px;
+}
+
+.notification-detail-item {
+  display: flex;
+  margin-bottom: 8px;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.detail-label {
+  color: #666;
+  min-width: 80px;
+  flex-shrink: 0;
+}
+
+.detail-value {
+  color: #333;
+  flex: 1;
+}
+
+.notification-text {
+  font-size: 14px;
+  color: #333;
+  line-height: 1.6;
+}
+
+.notification-action {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid #f0f0f0;
+}
+
+.action-link {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: #1890ff;
+  text-decoration: none;
+  font-size: 14px;
+  cursor: pointer;
+  transition: color 0.2s;
+}
+
+.action-link:hover {
+  color: #40a9ff;
+}
+
+.action-icon {
+  font-size: 14px;
+  margin-left: 4px;
+}
+
+.notification-time {
+  position: absolute;
+  top: 16px;
+  right: 16px;
+  font-size: 12px;
+  color: #999;
+}
 </style>