|
|
@@ -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>
|