From fed060545ae08dfde90673e8fc49775fcb5d4777 Mon Sep 17 00:00:00 2001 From: QDKF Date: Sat, 27 Sep 2025 20:37:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=A6=E5=91=98=E7=AB=AF=E6=88=91?= =?UTF-8?q?=E7=9A=84=E6=B6=88=E6=81=AF:=20=E6=B6=88=E6=81=AF=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=8A=A8=E7=94=BB=E3=80=81@=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E3=80=81=E8=A1=A8=E6=83=85/=E5=9B=BE=E7=89=87=E5=8F=91?= =?UTF-8?q?=E9=80=81=E3=80=815s=E8=BD=AE=E8=AF=A2=E3=80=81=E7=A9=BA?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=80=82=E9=85=8D;=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E6=95=99=E5=AD=A6=E7=BB=9F=E8=AE=A1=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/modules/statistics.ts | 10 + src/components/InstantMessage.vue | 2670 +++++++++++++---- .../Ai/component/AiAppPublish-Simple.vue | 123 +- .../message/components/MessageInput.vue | 242 +- .../components/NotificationMessages.vue | 582 +++- .../teacher/statistics/tab/BasicData.vue | 72 +- 6 files changed, 2912 insertions(+), 787 deletions(-) diff --git a/src/api/modules/statistics.ts b/src/api/modules/statistics.ts index 0644800..611495b 100644 --- a/src/api/modules/statistics.ts +++ b/src/api/modules/statistics.ts @@ -360,6 +360,16 @@ export class StatisticsApi { return ApiRequest.get('/statistics/comments', params) } + // 获取课程教学建设数据统计 + static getCourseTeachingStats(courseId?: string): Promise> { + return ApiRequest.get('/aiol/statistics/course-teaching-stats', { courseId }) + } + // 导出统计报告 static exportStatsReport(type: string, params?: { startDate?: string diff --git a/src/components/InstantMessage.vue b/src/components/InstantMessage.vue index d6b43b0..49bbfa9 100644 --- a/src/components/InstantMessage.vue +++ b/src/components/InstantMessage.vue @@ -25,7 +25,23 @@ 'not-disturb': conversation.izNotDisturb === 1 }]" @click="selectConversation(conversation)">
- + +
+
+ +
+ {{ member.name.charAt(0) }} +
+
+
+ {{ conversation.name.charAt(0) }} +
+
+
+ {{ conversation.name.charAt(0) }} +
@@ -84,40 +100,80 @@
-
- -
-
- {{ message.time }} + +
+
+
正在加载消息...
+
+ + +
+
+ +
+
+ {{ message.time }} +
+
+ + +
+
+ +
+
+ +
+ {{ getSenderName(message) }} + 讲师 +
+
+
+
+ 图片 +
+
+
+
+ +
+
+ {{ message.fileName || '未知文件' }} + 下载 +
+ +
+ 调试: fileName={{ message.fileName }}, fileType={{ message.fileType }}, content={{ + message.content }} +
+
+
+
+
+
+ +
+
- -
-
- -
-
- -
- {{ getSenderName(message) }} - 讲师 -
-
-
{{ message.content }}
-
- 图片 -
-
- 文件 - {{ message.fileName }} -
-
-
-
- -
+ +
+
+ + + +
+
+ {{ selectedConversation.type === 'group' ? '群聊暂无消息' : '私聊暂无消息' }} +
+
+ {{ selectedConversation.type === 'group' ? '在群聊中开始对话吧' : '开始与对方聊天吧' }}
@@ -246,7 +302,7 @@
-
+
@@ -261,25 +317,55 @@
+ + + + +
+
+
+ + +
+
+ +
+
+ 正在上传图片... +
+
+ + @keydown="handleKeydown" @input="adjustTextareaHeight" @keyup="handleAtInput" @blur="hideAtList" + ref="messageTextarea" rows="3" :disabled="!canSendMessage">
- - - + +
+
+ 选择表情 + +
+
+ +
+
+
+
{{ messageInput.length }}/500 -
@@ -350,6 +436,17 @@
+ + +
+
+ + {{ user.name }} +
+
@@ -406,10 +503,12 @@ interface BackendMessage { // 消息类型 interface Message { - id: number + id: string // 改为字符串类型,与教师端一致 type: 'text' | 'image' | 'file' content: string fileName?: string + fileUrl?: string // 文件下载链接 + fileType?: string // 文件类型 time: string isSelf: boolean senderName?: string @@ -465,8 +564,66 @@ const searchKeyword = ref('') const selectedConversation = ref(null) const messageInput = ref('') const showEmojiPicker = ref(false) + +// 表情列表 +const emojiList = ref([ + '😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', + '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', + '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩', + '🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣', + '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', + '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', + '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😬', '🙄', '😯', + '😦', '😧', '😮', '😲', '🥱', '😴', '🤤', '😪', '😵', '🤐', + '🥴', '🤢', '🤮', '🤧', '😷', '🤒', '🤕', '🤑', '🤠', '😈', + '👿', '👹', '👺', '🤡', '💩', '👻', '💀', '☠️', '👽', '👾', + '🤖', '🎃', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', + '😾', '👶', '🧒', '👦', '👧', '🧑', '👨', '👩', '🧓', '👴', + '👵', '👱', '👨‍🦰', '👩‍🦰', '👨‍🦱', '👩‍🦱', '👨‍🦳', '👩‍🦳', '👨‍🦲', '👩‍🦲', + '🤵', '👰', '🤰', '🤱', '👼', '🎅', '🤶', '🦸', '🦹', '🧙', + '🧚', '🧛', '🧜', '🧝', '🧞', '🧟', '💆', '💇', '🚶', '🏃', + '💃', '🕺', '👯', '🧖', '🧗', '🤺', '🏇', '⛷️', '🏂', '🏌️', + '🏄', '🚣', '🏊', '⛹️', '🏋️', '🚴', '🚵', '🤸', '🤼', '🤽', + '🤾', '🤹', '🧘', '🛀', '🛌', '👭', '👫', '👬', '💏', '💑', + '👪', '🗣️', '👤', '👥', '🫂', '👋', '🤚', '🖐️', '✋', '🖖', + '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', + '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', + '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', '🦾', + '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🦷', '🦴', '👀', + '👁️', '👅', '👄', '💋', '🩸', '💎', '👑', '👒', '🎩', '🎓', + '🧢', '⛑️', '📿', '💄', '💍', '💼', '👜', '🎒', '🧳', '👝', + '👛', '🛍️', '🛒', '🎁', '🎈', '🎉', '🎊', '🎀', '🎂', '🍰', + '🧁', '🥧', '🍭', '🍬', '🍫', '🍩', '🍪', '🍯', '🍼', '🥛', + '☕', '🍵', '🧃', '🥤', '🧊', '🍺', '🍻', '🥂', '🍷', '🥃', + '🍸', '🍹', '🧉', '🍾', '🧊', '🥄', '🍴', '🍽️', '🥣', '🥡', + '🥢', '🧂', '🥜', '🌰', '🍞', '🥐', '🥖', '🍳', '🥞', '🧇', + '🥯', '🥨', '🧀', '🥚', '🍳', '🧈', '🥞', '🧇', '🥯', '🥨', + '🥖', '🥐', '🍞', '🥨', '🥯', '🧇', '🥞', '🧈', '🥚', '🍳', + '🥓', '🥩', '🍗', '🍖', '🦴', '🌭', '🍔', '🍟', '🍕', '🥪', + '🥙', '🧆', '🌮', '🌯', '🥗', '🥘', '🥫', '🍝', '🍜', '🍲', + '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙', '🍚', '🍘', '🍥', + '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', + '🎂', '🍮', '🍭', '🍬', '🍫', '🍩', '🍪', '🍯', '🍼', '🥛', + '☕', '🍵', '🧃', '🥤', '🧊', '🍺', '🍻', '🥂', '🍷', '🥃', + '🍸', '🍹', '🧉', '🍾', '🧊', '🥄', '🍴', '🍽️', '🥣', '🥡', + '🥢', '🧂', '🥜', '🌰', '🍞', '🥐', '🥖', '🍳', '🥞', '🧇', + '🥯', '🥨', '🧀', '🥚', '🍳', '🧈', '🥞', '🧇', '🥯', '🥨' +]) const messagesContainer = ref() const messageTextarea = ref() +const fileInputRef = ref() + +// 图片相关状态 +const selectedImages = ref>([]) +const isUploadingImage = ref(false) + +// @功能相关状态 +const showAtList = ref(false) +const atListPosition = ref({ top: 0, left: 0 }) +const atKeyword = ref('') +const atList = ref>([]) +const atStartIndex = ref(-1) +const atSelectedIndex = ref(0) // 当前选中的用户索引 // 用户状态管理 const userStore = useUserStore() @@ -476,11 +633,16 @@ const conversations = ref([]) const conversationsLoading = ref(false) const conversationsError = ref('') +// 群成员数据存储(按群ID存储) +const groupMembers = ref>({}) + +// 消息加载状态 +const messagesLoading = ref(false) + // 详情面板相关状态 const showDetailsPanel = ref(false) // 群聊相关状态 -const groupMembers = ref([]) const groupInfo = ref({ name: '', memberCount: 0 @@ -506,11 +668,27 @@ const showExitConfirmModal = ref(false) const teacherUserIds = ref([]) const showTeacherLabel = ref(false) // 当前群聊的讲师标签显示开关 +// 轮询相关状态 +const pollingInterval = ref(null) +const isPolling = ref(false) +const pollingIntervalMs = 3000 // 3秒轮询一次 +const lastMessageTimestamp = ref('') // 最后一条消息的时间戳 +const lastMessageId = ref('') // 最后一条消息的ID + // 数据转换函数 const transformChatData = (backendItem: BackendChat): Conversation => { // 根据API返回的数字类型进行判断:0=私聊,1=群聊 const conversationType = backendItem.type === 1 ? 'group' : 'single' + // 调试:打印服务器返回的最后消息信息 + // console.log('🔍 转换会话数据:', { + // id: backendItem.id, + // name: backendItem.name, + // lastMessage: backendItem.lastMessage, + // lastMessageTime: backendItem.lastMessageTime, + // unreadCount: backendItem.unreadCount + // }) + return { id: Number(backendItem.id), name: backendItem.name || '未知会话', @@ -566,11 +744,35 @@ const transformMessageData = (backendItem: BackendMessage): Message => { // 获取当前用户ID const currentUserId = userStore.user?.id?.toString() + // 处理文件消息的文件名 + let fileName = undefined + if (type === 'file') { + // 优先使用 fileName,然后尝试从 fileUrl 提取,最后从 content 提取 + fileName = backendItem.fileName || + backendItem.fileUrl?.split('/').pop() || + backendItem.content?.split('/').pop() + + // 如果从URL提取的文件名包含查询参数,需要清理 + if (fileName && fileName.includes('?')) { + fileName = fileName.split('?')[0] + } + + // console.log('🔍 文件消息数据:', { + // messageType: backendItem.messageType, + // fileName: backendItem.fileName, + // fileUrl: backendItem.fileUrl, + // content: backendItem.content, + // processedFileName: fileName + // }) + } + return { - id: Number(backendItem.id), + id: backendItem.id, // 保持字符串类型,与教师端一致 type, content: backendItem.content || '', - fileName: type === 'file' ? backendItem.fileName || backendItem.fileUrl?.split('/').pop() : undefined, + fileName: fileName, + fileUrl: type === 'file' ? backendItem.fileUrl || backendItem.content : undefined, + fileType: type === 'file' ? backendItem.fileType || fileName?.split('.').pop()?.toLowerCase() : undefined, time: formatTime(backendItem.timestamp || backendItem.createTime || new Date().toISOString()), isSelf: backendItem.senderId === currentUserId, // 根据实际用户ID判断 senderName: backendItem.senderName, @@ -591,7 +793,7 @@ const loadAllConversationsNotDisturbStatus = async () => { return } - console.log('🔍 开始批量加载所有会话的免打扰状态...') + // console.log('🔍 开始批量加载所有会话的免打扰状态...') // 为每个会话并行加载免打扰状态 const promises = conversations.value.map(async (conversation: Conversation) => { @@ -604,14 +806,14 @@ const loadAllConversationsNotDisturbStatus = async () => { if (currentUserMember && currentUserMember.izNotDisturb !== undefined) { conversation.izNotDisturb = currentUserMember.izNotDisturb - console.log(`📱 会话 ${conversation.name} 免打扰状态:`, currentUserMember.izNotDisturb) + // console.log(`📱 会话 ${conversation.name} 免打扰状态:`, currentUserMember.izNotDisturb) } else { conversation.izNotDisturb = 0 // 默认关闭免打扰 - console.log(`📱 会话 ${conversation.name} 免打扰状态: 默认关闭`) + // console.log(`📱 会话 ${conversation.name} 免打扰状态: 默认关闭`) } } else { conversation.izNotDisturb = 0 // 默认关闭免打扰 - console.log(`📱 会话 ${conversation.name} 免打扰状态: API失败,默认关闭`) + // console.log(`📱 会话 ${conversation.name} 免打扰状态: API失败,默认关闭`) } } catch (error) { console.error(`❌ 加载会话 ${conversation.name} 免打扰状态失败:`, error) @@ -621,7 +823,91 @@ const loadAllConversationsNotDisturbStatus = async () => { // 等待所有免打扰状态加载完成 await Promise.allSettled(promises) - console.log('✅ 所有会话免打扰状态加载完成') + // console.log('✅ 所有会话免打扰状态加载完成') +} + +// 获取群成员数据 +const loadGroupMembers = async (chatId: string) => { + try { + const response = await ChatApi.getChatMembers(chatId) + if (response.data?.success && response.data.result) { + groupMembers.value[chatId] = response.data.result + // console.log(`✅ 群聊 ${chatId} 成员数据加载成功:`, response.data.result) + } + } catch (error) { + console.error(`❌ 获取群聊 ${chatId} 成员数据失败:`, error) + } +} + +// 获取群成员头像(最多4个) +const getGroupMemberAvatars = (chatId: string) => { + const members = groupMembers.value[chatId] || [] + const result = members.slice(0, 4).map((member: any) => ({ + id: member.id, + name: member.realname || member.username || member.name || '未知', + avatar: member.avatar + })) + return result +} + +// 计算群成员头像样式 +const getMemberAvatarStyle = (chatId: string, index: number) => { + const members = groupMembers.value[chatId] || [] + const memberCount = Math.min(members.length, 4) + + if (memberCount === 1) { + return { + width: '100%', + height: '100%', + borderRadius: '50%', + border: 'none', + marginTop: '0' + } + } else if (memberCount === 2) { + return { + width: '50%', + height: '100%', + borderRadius: index === 0 ? '50% 0 0 50%' : '0 50% 50% 0', + border: '1px solid #fff', + marginTop: '0' + } + } else if (memberCount === 3) { + if (index === 0) { + return { + width: '100%', + height: '50%', + borderRadius: '50% 50% 0 0', + border: '1px solid #fff', + marginTop: '0' + } + } else { + return { + width: '50%', + height: '50%', + borderRadius: index === 1 ? '0 0 0 50%' : '0 0 50% 0', + border: '1px solid #fff', + marginTop: '0' + } + } + } else { // 4个成员 + if (index < 2) { + return { + width: '50%', + height: '50%', + borderRadius: index === 0 ? '50% 0 0 0' : '0 50% 0 0', + border: '1px solid #fff', + marginTop: '0' + } + } else { + return { + width: '50%', + height: '50%', + borderRadius: index === 2 ? '0 0 0 50%' : '0 0 50% 0', + border: '1px solid #fff', + marginTop: '0' + } + } + } } // 加载对话列表 @@ -629,19 +915,29 @@ const loadConversations = async () => { try { conversationsLoading.value = true conversationsError.value = '' - console.log('🔍 开始加载我的会话列表...') + // console.log('🔍 开始加载我的会话列表...') const response = await ChatApi.getMyChats() - console.log('🔍 我的会话列表API响应:', response) + // console.log('🔍 我的会话列表API响应:', response) if (response.data && response.data.success) { if (response.data.result && Array.isArray(response.data.result)) { conversations.value = response.data.result.map(transformChatData) - console.log('✅ 转换后的会话列表:', conversations.value) + // console.log('✅ 转换后的会话列表:', conversations.value) // 加载所有会话的免打扰状态 await loadAllConversationsNotDisturbStatus() + + // 为群聊加载成员数据 + for (const conversation of conversations.value) { + if (conversation.type === 'group') { + loadGroupMembers(String(conversation.id)) + } + } + + // 加载所有会话的最后消息 + await loadAllConversationsLastMessages() } else { console.warn('⚠️ 会话列表API返回数据格式不正确:', response.data) conversations.value = [] @@ -663,10 +959,11 @@ const loadConversations = async () => { // 加载会话消息 const loadChatMessages = async (chatId: string) => { try { - console.log('🔍 开始加载会话消息,chatId:', chatId) + messagesLoading.value = true + // console.log('🔍 开始加载会话消息,chatId:', chatId) const response = await ChatApi.getChatMessages(chatId) - console.log('🔍 会话消息API响应:', response) + // console.log('🔍 会话消息API响应:', response) if (response.data && response.data.success) { if (response.data.result && Array.isArray(response.data.result)) { @@ -706,7 +1003,32 @@ const loadChatMessages = async (chatId: string) => { }) selectedConversation.value!.messages = processedMessages - console.log('✅ 会话消息加载成功:', processedMessages.length, '条消息') + + // 更新会话的最后消息信息 + if (processedMessages.length > 0) { + const lastMessage = processedMessages[processedMessages.length - 1] + selectedConversation.value!.lastMessage = lastMessage.content + selectedConversation.value!.lastTime = lastMessage.time + + // 同时更新对话列表中的最后消息 + updateConversationLastMessage(String(selectedConversation.value!.id), lastMessage) + + // 设置轮询的初始消息ID和时间戳 + const lastBackendMessage = sortedMessages[sortedMessages.length - 1] + const lastId = lastBackendMessage.id + const lastTimestamp = lastBackendMessage.timestamp || lastBackendMessage.createTime + + if (lastId) { + lastMessageId.value = lastId + console.log('🔄 设置初始轮询消息ID:', lastId) + } + if (lastTimestamp) { + lastMessageTimestamp.value = lastTimestamp + console.log('🔄 设置初始轮询时间戳:', lastTimestamp) + } + } + + // console.log('✅ 会话消息加载成功:', processedMessages.length, '条消息') } else { console.warn('⚠️ 会话消息API返回数据格式不正确:', response.data) selectedConversation.value!.messages = [] @@ -718,20 +1040,22 @@ const loadChatMessages = async (chatId: string) => { } catch (error) { console.error('❌ 加载会话消息失败:', error) selectedConversation.value!.messages = [] + } finally { + messagesLoading.value = false } } // 加载会话详情 const loadChatDetail = async (chatId: string) => { try { - console.log('🔍 开始加载会话详情,chatId:', chatId) + // console.log('🔍 开始加载会话详情,chatId:', chatId) const response = await ChatApi.getChatDetail(chatId) - console.log('🔍 会话详情API响应:', response) + // console.log('🔍 会话详情API响应:', response) if (response.data && response.data.success) { const detail = response.data.result - console.log('✅ 会话详情加载成功:', detail) + // console.log('✅ 会话详情加载成功:', detail) // 更新会话信息 if (selectedConversation.value) { @@ -753,9 +1077,11 @@ const loadChatDetail = async (chatId: string) => { selectedConversation.value.izAllMuted = detail.izAllMuted } if (detail.showLabel !== undefined) { - selectedConversation.value.showLabel = detail.showLabel === '1' || detail.showLabel === 'true' + // 兼容数字和字符串类型 + const isShowLabel = detail.showLabel === 1 || detail.showLabel === '1' || detail.showLabel === true || detail.showLabel === 'true' + selectedConversation.value.showLabel = isShowLabel // 设置讲师标签显示开关状态 - showTeacherLabel.value = selectedConversation.value.showLabel + showTeacherLabel.value = isShowLabel } if (detail.izNotDisturb !== undefined) { selectedConversation.value.izNotDisturb = detail.izNotDisturb @@ -770,41 +1096,6 @@ const loadChatDetail = async (chatId: string) => { } } -// 加载群成员 -const loadGroupMembers = async (chatId: string) => { - try { - console.log('🔍 开始加载群成员,chatId:', chatId) - - const response = await ChatApi.getChatMembers(chatId) - - console.log('🔍 群成员API响应:', response) - - if (response.data && response.data.success) { - if (response.data.result && Array.isArray(response.data.result)) { - groupMembers.value = response.data.result - console.log('✅ 群成员加载成功:', groupMembers.value.length, '个成员') - - // 更新群组信息 - groupInfo.value = { - name: selectedConversation.value?.name || '', - memberCount: groupMembers.value.length - } - - // 更新教师列表 - await loadTeacherList() - } else { - console.warn('⚠️ 群成员API返回数据格式不正确:', response.data) - groupMembers.value = [] - } - } else { - console.warn('⚠️ 群成员API返回错误:', response.data) - groupMembers.value = [] - } - } catch (error) { - console.error('❌ 加载群成员失败:', error) - groupMembers.value = [] - } -} // 获取发送者姓名 const getSenderName = (message: Message): string => { @@ -815,7 +1106,9 @@ const getSenderName = (message: Message): string => { // 如果是群聊,从群成员列表中获取真实姓名作为兜底 if (selectedConversation.value?.type === 'group' && message.senderId) { - const sender = groupMembers.value.find((member: GroupMember) => member.id === message.senderId) + const chatId = String(selectedConversation.value.id) + const members = groupMembers.value[chatId] || [] + const sender = members.find((member: any) => member.id === message.senderId) if (sender) { return sender.realname || sender.username || '未知用户' } @@ -838,7 +1131,9 @@ const getSenderAvatar = (message: Message): string => { // 如果是群聊,从群成员列表中获取头像作为兜底 if (selectedConversation.value?.type === 'group' && message.senderId) { - const sender = groupMembers.value.find((member: GroupMember) => member.id === message.senderId) + const chatId = String(selectedConversation.value.id) + const members = groupMembers.value[chatId] || [] + const sender = members.find((member: any) => member.id === message.senderId) if (sender && sender.avatar) { return sender.avatar } @@ -853,26 +1148,27 @@ const getSenderAvatar = (message: Message): string => { } // 获取教师用户ID列表(参考教师端实现) -const loadTeacherList = async () => { - try { - // 这里应该调用获取教师列表的API - // const response = await TeachCourseApi.getTeacherList() - // if (response.data && response.data.result) { - // teacherUserIds.value = response.data.result.map((teacher: any) => teacher.id || teacher.userId) - // } +// const _loadTeacherList = async () => { +// try { +// // 这里应该调用获取教师列表的API +// // const response = await TeachCourseApi.getTeacherList() +// // if (response.data && response.data.result) { +// // teacherUserIds.value = response.data.result.map((teacher: any) => teacher.id || teacher.userId) +// // } - // 暂时使用群成员中的讲师信息作为教师列表 - if (groupMembers.value.length > 0) { - teacherUserIds.value = groupMembers.value - .filter((member: GroupMember) => member.isTeacher === true) - .map((member: GroupMember) => member.id) - } - } catch (error) { - console.error('❌ 获取教师列表失败:', error) - // 添加一些测试数据 - teacherUserIds.value = ['1000000000', '1000000001'] - } -} +// // 暂时使用群成员中的讲师信息作为教师列表 +// if (selectedConversation.value?.type === 'group') { +// const chatId = String(selectedConversation.value.id) +// const members = groupMembers.value[chatId] || [] +// teacherUserIds.value = members +// .filter((member: any) => member.isTeacher === true) +// .map((member: any) => member.id) +// } +// } catch (error) { +// console.error('❌ 获取教师列表失败:', error) +// teacherUserIds.value = [] +// } +// } // 判断是否为教师(参考教师端实现) const isTeacher = (senderId: string): boolean => { @@ -889,6 +1185,7 @@ const shouldShowTeacherLabel = (message: Message): boolean => { const isTeacherUser = message.senderId ? isTeacher(message.senderId) : false const shouldShow = showLabel && isTeacherUser + return shouldShow } @@ -913,6 +1210,13 @@ const shouldShowInputArea = computed(() => { return true }) +// 动态计算输入区域高度 +const inputAreaHeight = computed(() => { + const baseHeight = 128 // 基础高度 + const imagePreviewHeight = selectedImages.value.length > 0 ? 100 : 0 // 图片预览区域高度 + return `${baseHeight + imagePreviewHeight}px` +}) + // 计算属性:当前用户是否有发言权限 const canSendMessage = computed(() => { if (!selectedConversation.value) return false @@ -944,13 +1248,31 @@ const filteredConversations = computed(() => { // 选择对话 const selectConversation = async (conversation: Conversation) => { + // 停止当前轮询 + stopPolling() + + // 先设置加载状态和清空消息,避免显示之前会话的消息 + messagesLoading.value = true selectedConversation.value = conversation + selectedConversation.value.messages = [] // 清空消息列表 + + // 清空输入框和相关状态 + messageInput.value = '' + selectedImages.value = [] + showAtList.value = false + atKeyword.value = '' + atStartIndex.value = -1 + atSelectedIndex.value = 0 + // 清除未读消息 conversation.unreadCount = 0 // 关闭详情面板 showDetailsPanel.value = false + // 重置轮询状态 + resetPollingState() + // 加载会话详情 await loadChatDetail(String(conversation.id)) @@ -968,15 +1290,393 @@ const selectConversation = async (conversation: Conversation) => { // 更新最后读取消息ID const latestMessageId = getLatestMessageId() - if (latestMessageId && selectedConversation.value) { - updateLastReadMessage(String(selectedConversation.value.id), latestMessageId) + if (latestMessageId) { + updateLastReadMessage(latestMessageId) } + + // 启动轮询 + startPolling() }) } +// 表情相关方法 +const toggleEmojiPicker = () => { + showEmojiPicker.value = !showEmojiPicker.value +} + +const closeEmojiPicker = () => { + showEmojiPicker.value = false +} + +const insertEmoji = (emoji: string) => { + messageInput.value += emoji + closeEmojiPicker() + // 调整文本框高度 + adjustTextareaHeight() +} + +// 点击外部关闭表情选择器 +const handleClickOutside = (event: Event) => { + const target = event.target as HTMLElement + const emojiPicker = document.querySelector('.emoji-picker') + const emojiButton = document.querySelector('.emoji-button-wrapper .toolbar-btn') + + if (emojiPicker && emojiButton && + !emojiPicker.contains(target) && + !emojiButton.contains(target)) { + closeEmojiPicker() + } +} + +// 图片相关方法 +const selectImage = () => { + fileInputRef.value?.click() +} + +const handleFileSelect = (event: Event) => { + const target = event.target as HTMLInputElement + const files = target.files + if (files && files.length > 0) { + const file = files[0] + // 检查文件类型 + if (file.type.startsWith('image/')) { + handleImageUpload(file) + } + } + // 清空input值,允许重复选择同一文件 + target.value = '' +} + +const handleImageUpload = (file: File) => { + // 检查文件大小 (限制为5MB) + if (file.size > 5 * 1024 * 1024) { + alert('图片大小不能超过5MB') + return + } + + // 创建预览 + const reader = new FileReader() + reader.onload = (e) => { + try { + const imageUrl = e.target?.result as string + if (imageUrl) { + // 添加到预览列表 + const imageData = { + file, + url: imageUrl, + name: file.name, + size: file.size + } + selectedImages.value.push(imageData) + } + } catch (error) { + console.error('处理图片时出错:', error) + } + } + reader.onerror = () => { + console.error('读取图片文件失败') + } + reader.readAsDataURL(file) +} + +const removeImage = (index: number) => { + selectedImages.value.splice(index, 1) +} + +// @功能相关方法 +const handleAtInput = (event: KeyboardEvent) => { + // 如果是键盘导航键,不处理@输入检测 + if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) { + console.log('🔍 @功能 - 键盘导航键,跳过@输入检测:', event.key) + return + } + + console.log('🔍 @功能 - handleAtInput被调用,按键:', event.key) + + const textarea = event.target as HTMLTextAreaElement + const cursorPosition = textarea.selectionStart + const text = textarea.value + + console.log('🔍 @功能 - 输入内容:', text, '光标位置:', cursorPosition) + + // 查找@符号 + const atMatch = text.substring(0, cursorPosition).match(/@([^@\s]*)$/) + console.log('🔍 @功能 - @匹配结果:', atMatch) + + if (atMatch) { + atStartIndex.value = cursorPosition - atMatch[0].length + const newKeyword = atMatch[1] + + console.log('🔍 @功能 - 关键词变化检查:', { + oldKeyword: atKeyword.value, + newKeyword: newKeyword, + conversationType: selectedConversation.value?.type, + hasGroupMembers: !!groupMembers.value + }) + + // 更新关键词 + atKeyword.value = newKeyword + + // 如果是群聊,显示成员列表 + if (selectedConversation.value?.type === 'group' && groupMembers.value) { + console.log('🔍 @功能 - 开始加载@列表') + loadAtList() + showAtList.value = true + updateAtListPosition(textarea) + } else { + console.log('🔍 @功能 - 不是群聊或无群成员数据') + } + } else { + console.log('🔍 @功能 - 没有匹配到@符号,隐藏列表') + showAtList.value = false + } +} + +const loadAtList = () => { + console.log('🔍 @功能 - loadAtList被调用') + console.log('🔍 @功能 - 检查条件:', { + conversationType: selectedConversation.value?.type, + hasGroupMembers: !!groupMembers.value, + conversationId: selectedConversation.value?.id, + groupMembersKeys: groupMembers.value ? Object.keys(groupMembers.value) : [] + }) + + if (selectedConversation.value?.type === 'group' && groupMembers.value && selectedConversation.value.id) { + const chatId = selectedConversation.value.id.toString() + const members = groupMembers.value[chatId] || [] + console.log('🔍 @功能 - 群成员数据:', members) + + atList.value = members + .filter((member: any) => { + const name = member.realname || member.username || '' + const keyword = atKeyword.value.toLowerCase() + + return name.toLowerCase().includes(keyword) + }) + .map((member: any) => ({ + id: member.id, + name: member.realname || member.username || '未知用户', + avatar: member.avatar || '/images/profile/default-avatar.png' + })) + + // 重置选中索引为第一个 + atSelectedIndex.value = 0 + + console.log('🔍 @功能 - 过滤后的用户列表:', atList.value) + } else { + console.log('🔍 @功能 - 条件不满足,无法加载@列表') + } +} + +const updateAtListPosition = (textarea: HTMLTextAreaElement) => { + const rect = textarea.getBoundingClientRect() + atListPosition.value = { + top: rect.bottom + 5, + left: rect.left + } +} + +const selectAtUser = (user?: { id: string; name: string; avatar: string }) => { + // 如果没有传入用户,使用当前选中的用户 + const selectedUser = user || atList.value[atSelectedIndex.value] + + console.log('🔍 @功能 - selectAtUser调用:', { + user, + selectedUser, + atSelectedIndex: atSelectedIndex.value, + atListLength: atList.value.length + }) + + if (!selectedUser) { + console.warn('⚠️ @功能 - 没有可选择的用户') + return + } + + console.log('🔍 @功能 - 选择用户:', selectedUser) + console.log('🔍 @功能 - 当前状态:', { + messageTextarea: !!messageTextarea.value, + atStartIndex: atStartIndex.value, + messageInput: messageInput.value + }) + + if (messageTextarea.value && atStartIndex.value >= 0) { + const text = messageTextarea.value.value + const beforeAt = text.substring(0, atStartIndex.value) + const afterAt = text.substring(messageTextarea.value.selectionStart) + + // 插入@用户名 + const newText = beforeAt + `@${selectedUser.name} ` + afterAt + messageInput.value = newText + + console.log('🔍 @功能 - 文本替换:', { + beforeAt, + afterAt, + newText + }) + + // 设置光标位置 + const newCursorPos = beforeAt.length + selectedUser.name.length + 2 + nextTick(() => { + if (messageTextarea.value) { + messageTextarea.value.setSelectionRange(newCursorPos, newCursorPos) + messageTextarea.value.focus() + } + }) + } else { + console.warn('⚠️ @功能 - 无法选择用户,条件不满足') + } + + showAtList.value = false + atKeyword.value = '' + atStartIndex.value = -1 + atSelectedIndex.value = 0 +} + +const hideAtList = () => { + showAtList.value = false + atKeyword.value = '' + atStartIndex.value = -1 + atSelectedIndex.value = 0 +} + +// 统一的键盘事件处理 +const handleKeydown = (event: KeyboardEvent) => { + // 如果@列表正在显示,优先处理@功能 + if (showAtList.value && atList.value.length > 0) { + console.log('🔍 @功能 - 键盘事件:', event.key, '当前选中索引:', atSelectedIndex.value) + + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + event.stopPropagation() + atSelectedIndex.value = (atSelectedIndex.value + 1) % atList.value.length + console.log('🔍 @功能 - 向下选择,新索引:', atSelectedIndex.value) + return + case 'ArrowUp': + event.preventDefault() + event.stopPropagation() + atSelectedIndex.value = atSelectedIndex.value === 0 + ? atList.value.length - 1 + : atSelectedIndex.value - 1 + console.log('🔍 @功能 - 向上选择,新索引:', atSelectedIndex.value) + return + case 'Enter': + event.preventDefault() + event.stopPropagation() + console.log('🔍 @功能 - 回车选择用户') + selectAtUser() + return + case 'Escape': + event.preventDefault() + event.stopPropagation() + console.log('🔍 @功能 - ESC取消选择') + hideAtList() + return + } + } + + // 处理普通键盘事件 + if (event.key === 'Enter') { + event.preventDefault() + sendMessage() + } +} + +// 渲染@消息 +const renderAtMessage = (content: string) => { + if (!content) return '' + + // 匹配@用户名模式 + const atPattern = /@([^@\s]+)/g + return content.replace(atPattern, (_match, username) => { + return `@${username}` + }) +} + +// 发送图片消息 +const sendImageMessage = async (imageData: { file: File; url: string; name: string; size: number }) => { + try { + console.log('🖼️ 开始发送图片:', imageData.name) + + // 上传图片文件 + const uploadResponse = await ChatApi.uploadFile(imageData.file) + + if (uploadResponse.data && uploadResponse.data.success) { + const fileUrl = uploadResponse.data.message || uploadResponse.data.result?.url || uploadResponse.data.data?.url || uploadResponse.data.result || uploadResponse.data.data + // 确保fileUrl是字符串类型 + const finalFileUrl = typeof fileUrl === 'string' ? fileUrl : (fileUrl?.url || '') + + // 发送图片消息 + const messageResponse = await ChatApi.sendMessage({ + chat_id: String(selectedConversation.value!.id), + content: finalFileUrl, // 直接使用图片URL作为content + messageType: 1, // 1=图片 + fileUrl: finalFileUrl + }) + + + if (messageResponse.data && messageResponse.data.success) { + // 创建本地图片消息对象 + const newMessage: Message = { + id: messageResponse.data.result ? String(messageResponse.data.result) : Date.now().toString(), + type: 'image', + content: finalFileUrl || '图片上传失败', // 使用上传后的URL作为图片地址 + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + isSelf: true + } + + + // 添加到当前会话 + selectedConversation.value!.messages.push(newMessage) + selectedConversation.value!.lastMessage = finalFileUrl // 使用图片URL作为最后消息 + selectedConversation.value!.lastTime = newMessage.time + + + // 更新对话列表中的最后消息 + updateConversationLastMessage(String(selectedConversation.value!.id), newMessage) + + // 滚动到底部 + nextTick(() => { + scrollToBottom() + }) + + // 确保消息渲染完成后再滚动一次 + setTimeout(() => { + scrollToBottom() + }, 200) + + // 更新最后读取消息ID + if (messageResponse.data.result) { + updateLastReadMessage(messageResponse.data.result) + } + + } else { + console.error('❌ 图片消息发送失败:', messageResponse.data?.message) + } + } else { + console.error('❌ 图片上传失败:', uploadResponse.data?.message) + } + } catch (error) { + console.error('❌ 发送图片消息时出错:', error) + } +} + // 发送消息 const sendMessage = async () => { - if (!messageInput.value.trim() || !selectedConversation.value) return + // 如果@列表正在显示,不发送消息 + if (showAtList.value) { + console.log('🔍 @功能 - @列表显示中,不发送消息') + return + } + + if (!selectedConversation.value) return + + // 检查是否有内容要发送(文本或图片) + const hasText = messageInput.value.trim() + const hasImages = selectedImages.value.length > 0 + + + if (!hasText && !hasImages) return const messageContent = messageInput.value.trim() messageInput.value = '' @@ -1022,49 +1722,71 @@ const sendMessage = async () => { } try { - console.log('📤 发送消息:', messageContent) - - const response = await ChatApi.sendMessage({ - chat_id: String(selectedConversation.value.id), - content: messageContent, - messageType: 0, // 0=文本 - fileUrl: '' - }) - - console.log('📤 发送消息API响应:', response) - - if (response.data && response.data.success) { - // 创建本地消息对象 - const newMessage: Message = { - id: Date.now(), - type: 'text', - content: messageContent, - time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), - isSelf: true - } - - // 添加到当前会话 - selectedConversation.value.messages.push(newMessage) - selectedConversation.value.lastMessage = newMessage.content - selectedConversation.value.lastTime = newMessage.time - - // 滚动到底部 - nextTick(() => { - scrollToBottom() - resetTextareaHeight() - - // 更新最后读取消息ID - const latestMessageId = getLatestMessageId() - if (latestMessageId && selectedConversation.value) { - updateLastReadMessage(String(selectedConversation.value.id), latestMessageId) + // 如果有图片,先发送图片 + if (hasImages) { + isUploadingImage.value = true + try { + for (const imageData of selectedImages.value) { + await sendImageMessage(imageData) } + // 清空图片预览 + selectedImages.value = [] + + // 确保所有图片发送完成后滚动到底部 + nextTick(() => { + scrollToBottom() + }) + } finally { + isUploadingImage.value = false + } + } + + // 如果有文本,发送文本消息 + if (hasText) { + + const response = await ChatApi.sendMessage({ + chat_id: String(selectedConversation.value.id), + content: messageContent, + messageType: 0, // 0=文本 + fileUrl: '' }) - console.log('✅ 消息发送成功') - } else { - console.error('❌ 消息发送失败:', response.data) - // 恢复输入内容 - messageInput.value = messageContent + + if (response.data && response.data.success) { + // 创建本地消息对象 + const newMessage: Message = { + id: response.data.result ? String(response.data.result) : Date.now().toString(), + type: 'text', + content: messageContent, + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + isSelf: true + } + + // 添加到当前会话 + selectedConversation.value.messages.push(newMessage) + selectedConversation.value.lastMessage = newMessage.content + selectedConversation.value.lastTime = newMessage.time + + // 同时更新对话列表中的最后消息 + updateConversationLastMessage(String(selectedConversation.value.id), newMessage) + + // 滚动到底部 + nextTick(() => { + scrollToBottom() + resetTextareaHeight() + + // 使用服务器返回的真实ID更新最后读取消息ID + if (response.data.result) { + updateLastReadMessage(response.data.result) + } else { + console.warn('⚠️ 服务器未返回消息ID,跳过更新最后读取消息') + } + }) + + } else { + console.error('❌ 文本消息发送失败:', response.data?.message) + messageInput.value = messageContent // 恢复输入内容 + } } } catch (error) { console.error('❌ 发送消息失败:', error) @@ -1085,14 +1807,21 @@ const closeDetailsPanel = () => { // 计算属性:过滤后的群成员列表 const filteredMembers = computed(() => { + if (!memberSearchKeyword.value.trim() || !selectedConversation.value) { + return [] + } + + const chatId = String(selectedConversation.value.id) + const members = groupMembers.value[chatId] || [] + if (!memberSearchKeyword.value.trim()) { - return groupMembers.value + return members } const keyword = memberSearchKeyword.value.toLowerCase().trim() - return groupMembers.value.filter((member: GroupMember) => - member.realname.toLowerCase().includes(keyword) || - member.username.toLowerCase().includes(keyword) + return members.filter((member: any) => + (member.realname || '').toLowerCase().includes(keyword) || + (member.username || '').toLowerCase().includes(keyword) ) }) @@ -1114,25 +1843,180 @@ const shouldShowViewMore = computed(() => { // 滚动到底部 const scrollToBottom = () => { if (messagesContainer.value) { - messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight + // 使用setTimeout确保DOM更新完成 + setTimeout(() => { + if (messagesContainer.value) { + const container = messagesContainer.value + const maxScrollTop = container.scrollHeight - container.clientHeight + + // 强制滚动到底部 + container.scrollTop = maxScrollTop + + // 如果第一次滚动没有成功,再尝试一次 + if (container.scrollTop < maxScrollTop - 10) { + setTimeout(() => { + container.scrollTop = container.scrollHeight + }, 50) + } + } + }, 100) } } // 更新最后读取消息ID -const updateLastReadMessage = async (chatId: string, messageId: string) => { +const updateLastReadMessage = async (messageId: string) => { + if (!selectedConversation.value || !messageId) { + console.warn('⚠️ 无法更新最后读取消息ID: 缺少必要参数', { + selectedConversation: selectedConversation.value?.id, + messageId + }) + return + } + try { - console.log('📝 更新最后读取消息ID:', { chatId, messageId }) - - const response = await ChatApi.updateLastRead(chatId, messageId) - console.log('📝 更新最后读取消息ID响应:', response) - - if (response.data && response.data.success) { - console.log('✅ 最后读取消息ID更新成功') - } else { - console.warn('⚠️ 更新最后读取消息ID失败:', response.data) - } + console.log('📖 更新最后读取消息ID:', { chatId: selectedConversation.value.id, messageId }) + await ChatApi.updateLastRead(String(selectedConversation.value.id), messageId) + console.log('✅ 最后读取消息ID更新成功') } catch (error) { console.error('❌ 更新最后读取消息ID失败:', error) + // 不显示错误提示给用户,因为这是后台操作 + // 但记录详细错误信息用于调试 + if (error instanceof Error) { + console.error('错误详情:', error.message) + } + } +} + +// 更新对话列表中的最后消息 +const updateConversationLastMessage = (chatId: string, lastMessage: Message) => { + const conversation = conversations.value.find((c: Conversation) => String(c.id) === chatId) + if (conversation) { + // 根据消息类型生成显示内容 + let displayContent = '' + if (lastMessage.type === 'text') { + displayContent = lastMessage.content + } else if (lastMessage.type === 'image') { + displayContent = '[图片]' + } else if (lastMessage.type === 'file') { + displayContent = `[文件] ${lastMessage.fileName || '未知文件'}` + } + + // 更新对话的最后消息和时间 + conversation.lastMessage = displayContent + conversation.lastTime = lastMessage.time + + console.log('📝 更新对话最后消息:', { + chatId, + conversationName: conversation.name, + lastMessage: displayContent, + lastMessageTime: lastMessage.time + }) + } +} + +// 加载所有会话的最后消息 +const loadAllConversationsLastMessages = async () => { + try { + console.log('🔄 开始加载所有会话的最后消息...') + + // 并行获取所有会话的最后消息 + const promises = conversations.value.map(async (conversation: Conversation) => { + try { + const response = await ChatApi.getChatMessages(String(conversation.id)) + if (response.data && response.data.success && response.data.result.length > 0) { + const messages = response.data.result + const lastMessage = messages[messages.length - 1] + + // 根据消息类型生成显示内容 + let displayContent = '' + if (lastMessage.messageType === 0) { + // 文本消息 + displayContent = lastMessage.content + } else if (lastMessage.messageType === 1) { + // 图片消息 + displayContent = '[图片]' + } else if (lastMessage.messageType === 2) { + // 文件消息 + displayContent = `[文件] ${lastMessage.fileName || '未知文件'}` + } + + // 更新对话的最后消息 + conversation.lastMessage = displayContent + conversation.lastTime = formatTime(lastMessage.createTime || lastMessage.timestamp) + + console.log('✅ 会话最后消息加载成功:', { + chatId: conversation.id, + conversationName: conversation.name, + lastMessage: displayContent + }) + } + } catch (error) { + console.warn('⚠️ 获取会话最后消息失败:', conversation.id, error) + } + }) + + await Promise.all(promises) + console.log('🎉 所有会话最后消息加载完成') + } catch (error) { + console.error('❌ 加载所有会话最后消息失败:', error) + } +} + +// 获取文件图标 +const getFileIcon = (type: string) => { + if (!type) return '/images/profile/file.png' + + const lowerType = type.toLowerCase() + + // 文档类型 + if (lowerType === 'pdf') return '/images/profile/pdf.png' + if (lowerType === 'doc' || lowerType === 'docx') return '/images/profile/word.png' + if (lowerType === 'xls' || lowerType === 'xlsx') return '/images/profile/xls.png' + if (lowerType === 'ppt' || lowerType === 'pptx') return '/images/profile/ppt.png' + if (lowerType === 'txt' || lowerType === 'text') return '/images/profile/doc.png' + + // 压缩文件 + if (lowerType === 'zip' || lowerType === 'rar' || lowerType === '7z' || lowerType === 'tar' || lowerType === 'gz') { + return '/images/profile/zip.png' + } + + // 图片文件 + if (lowerType === 'jpg' || lowerType === 'jpeg' || lowerType === 'png' || lowerType === 'gif' || lowerType === 'bmp' || lowerType === 'webp') { + return '/images/profile/image.png' + } + + // 视频文件 + if (lowerType === 'mp4' || lowerType === 'avi' || lowerType === 'mov' || lowerType === 'wmv' || lowerType === 'flv') { + return '/images/profile/video.png' + } + + // 音频文件 + if (lowerType === 'mp3' || lowerType === 'wav' || lowerType === 'flac' || lowerType === 'aac') { + return '/images/profile/audio.png' + } + + // 默认文件图标 + return '/images/profile/file.png' +} + +// 下载文件 +const downloadFile = (message: Message) => { + const downloadUrl = message.fileUrl || message.content + const fileName = message.fileName || '下载文件' + + if (downloadUrl) { + console.log('📥 下载文件:', { fileName, downloadUrl }) + const link = document.createElement('a') + link.href = downloadUrl + link.download = fileName + link.target = '_blank' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } else { + console.warn('⚠️ 文件下载链接不存在') + // 可以添加用户提示,比如使用 Naive UI 的 message 组件 + console.error('文件下载链接不存在') } } @@ -1144,7 +2028,7 @@ const getLatestMessageId = (): string | null => { // 获取最后一条消息的ID const latestMessage = selectedConversation.value.messages[selectedConversation.value.messages.length - 1] - return latestMessage.id.toString() + return latestMessage.id // 现在id已经是字符串类型 } // 防抖定时器 @@ -1154,24 +2038,8 @@ let scrollDebounceTimer: NodeJS.Timeout | null = null const handleMessagesScroll = () => { if (!messagesContainer.value || !selectedConversation.value) return - const container = messagesContainer.value - const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 10 // 10px容差 - - // 如果滚动到底部,使用防抖更新最后读取消息ID - if (isAtBottom) { - // 清除之前的定时器 - if (scrollDebounceTimer) { - clearTimeout(scrollDebounceTimer) - } - - // 设置新的定时器,500ms后执行 - scrollDebounceTimer = setTimeout(() => { - const latestMessageId = getLatestMessageId() - if (latestMessageId) { - updateLastReadMessage(String(selectedConversation.value!.id), latestMessageId) - } - }, 500) - } + // 滚动时不更新最后读取消息ID,因为使用的是历史消息ID + // 只在消息加载完成和发送新消息时更新 } // 群成员搜索处理 @@ -1330,6 +2198,173 @@ const cancelExitGroup = () => { showExitConfirmModal.value = false } +// 轮询获取新消息 +const pollForNewMessages = async () => { + if (!selectedConversation.value || isPolling.value) { + return + } + + try { + isPolling.value = true + const chatId = String(selectedConversation.value.id) + + console.log('🔄 开始轮询检查新消息,chatId:', chatId, '当前时间戳:', lastMessageTimestamp.value) + + // 获取当前会话的最新消息 + const response = await ChatApi.getChatMessages(chatId) + + if (response.data && response.data.success && response.data.result) { + const messages = response.data.result + console.log('🔄 轮询获取到消息数量:', messages.length) + + if (messages.length > 0) { + // 获取最新消息的ID和时间戳 + const latestMessage = messages[messages.length - 1] + const latestId = latestMessage.id + const latestTimestamp = latestMessage.timestamp || latestMessage.createTime + + console.log('🔄 最新消息ID:', latestId, '上次消息ID:', lastMessageId.value) + console.log('🔄 最新消息时间戳:', latestTimestamp, '上次时间戳:', lastMessageTimestamp.value) + + // 如果消息ID与上次不同,说明有新消息 + if (latestId && latestId !== lastMessageId.value) { + console.log('🔄 检测到新消息,更新消息列表') + + // 更新最后消息ID和时间戳 + lastMessageId.value = latestId + if (latestTimestamp) { + lastMessageTimestamp.value = latestTimestamp + } + + // 只添加新消息,而不是重新加载所有消息 + await addNewMessages(messages) + + // 滚动到底部显示新消息 + nextTick(() => { + scrollToBottom() + }) + } else { + console.log('🔄 没有新消息') + } + } else { + console.log('🔄 没有消息数据') + } + } else { + console.log('🔄 轮询API返回失败:', response.data) + } + } catch (error) { + console.error('❌ 轮询获取新消息失败:', error) + } finally { + isPolling.value = false + } +} + +// 添加新消息到当前会话 +const addNewMessages = async (allMessages: BackendMessage[]) => { + if (!selectedConversation.value) return + + // 先按时间正序排序 + const sortedMessages = allMessages.sort((a: BackendMessage, b: BackendMessage) => { + const timeA = new Date(a.timestamp || a.createTime || 0).getTime() + const timeB = new Date(b.timestamp || b.createTime || 0).getTime() + return timeA - timeB + }) + + // 处理消息数据,实现时间分组 + const processedMessages: Message[] = [] + let lastMessageTime: Date | null = null + + sortedMessages.forEach((backendItem: BackendMessage) => { + // 转换消息数据 + const message = transformMessageData(backendItem) + + // 计算时间差,判断是否需要显示时间 + const currentMessageTime = new Date(backendItem.timestamp || backendItem.createTime || new Date().toISOString()) + const timeDiff = lastMessageTime ? currentMessageTime.getTime() - lastMessageTime.getTime() : Infinity + const shouldShowTime = !lastMessageTime || timeDiff > 5 * 60 * 1000 // 5分钟间隔 + + // 设置时间显示标志 + message.showTime = shouldShowTime + + // 判断是否需要显示发送者姓名 + const shouldShowSender = !message.isSelf + message.showSender = shouldShowSender + + processedMessages.push(message) + + // 更新状态 + lastMessageTime = currentMessageTime + }) + + // 更新当前会话的消息列表 + selectedConversation.value.messages = processedMessages + + // 更新会话的最后消息信息 + if (processedMessages.length > 0) { + const lastMessage = processedMessages[processedMessages.length - 1] + selectedConversation.value.lastMessage = lastMessage.content + selectedConversation.value.lastTime = lastMessage.time + + // 同时更新对话列表中的最后消息 + updateConversationLastMessage(String(selectedConversation.value.id), lastMessage) + + // 更新轮询消息ID和时间戳 + const lastBackendMessage = sortedMessages[sortedMessages.length - 1] + const lastId = lastBackendMessage.id + const lastTimestamp = lastBackendMessage.timestamp || lastBackendMessage.createTime + + if (lastId) { + lastMessageId.value = lastId + console.log('🔄 更新轮询消息ID:', lastId) + } + if (lastTimestamp) { + lastMessageTimestamp.value = lastTimestamp + console.log('🔄 更新轮询时间戳:', lastTimestamp) + } + } +} + +// 启动轮询 +const startPolling = () => { + if (pollingInterval.value) { + clearInterval(pollingInterval.value) + } + + console.log('🔄 启动消息轮询,间隔:', pollingIntervalMs, 'ms') + pollingInterval.value = setInterval(pollForNewMessages, pollingIntervalMs) +} + +// 停止轮询 +const stopPolling = () => { + if (pollingInterval.value) { + console.log('⏹️ 停止消息轮询') + clearInterval(pollingInterval.value) + pollingInterval.value = null + } +} + +// 重置轮询状态 +const resetPollingState = () => { + lastMessageTimestamp.value = '' + lastMessageId.value = '' + isPolling.value = false +} + +// 页面可见性变化处理 +const handleVisibilityChange = () => { + if (document.hidden) { + // 页面不可见时暂停轮询 + console.log('📱 页面不可见,暂停轮询') + stopPolling() + } else { + // 页面可见时恢复轮询 + if (selectedConversation.value) { + console.log('📱 页面可见,恢复轮询') + startPolling() + } + } +} + // 调整文本框高度 const adjustTextareaHeight = () => { if (messageTextarea.value) { @@ -1346,10 +2381,10 @@ const resetTextareaHeight = () => { } // 选择文件 -const selectFile = () => { - // 这里可以实现文件选择逻辑 - console.log('选择文件') -} +// const _selectFile = () => { +// // 这里可以实现文件选择逻辑 +// console.log('选择文件') +// } // 组件挂载时加载对话列表 onMounted(async () => { @@ -1359,14 +2394,29 @@ onMounted(async () => { if (conversations.value.length > 0) { selectConversation(conversations.value[0]) } + + // 添加点击外部关闭表情选择器的事件监听 + document.addEventListener('click', handleClickOutside) + + // 添加页面可见性变化监听 + document.addEventListener('visibilitychange', handleVisibilityChange) }) -// 组件卸载时清理定时器 +// 组件卸载时清理定时器和事件监听器 onUnmounted(() => { + // 停止轮询 + stopPolling() + if (scrollDebounceTimer) { clearTimeout(scrollDebounceTimer) scrollDebounceTimer = null } + + // 移除点击外部关闭表情选择器的事件监听 + document.removeEventListener('click', handleClickOutside) + + // 移除页面可见性变化监听 + document.removeEventListener('visibilitychange', handleVisibilityChange) }) @@ -1442,401 +2492,509 @@ onUnmounted(() => { flex: 1; overflow-y: auto; /* 隐藏滚动条但保持滚动功能 */ - scrollbar-width: none; - /* Firefox */ - -ms-overflow-style: none; - /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ +} + +.conversation-items::-webkit-scrollbar { + display: none; + /* Chrome, Safari, Opera */ +} + +.conversation-item { + display: flex; + align-items: center; + padding: 12px 16px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; + border-radius: 0; +} + +.conversation-item:hover { + background-color: #f5f5f5; +} + +.conversation-item.active { + background-color: #e6f7ff; +} + +/* 免打扰状态样式 */ +.conversation-item.not-disturb { + background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f4 100%); + position: relative; +} + +.conversation-item.not-disturb::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(108, 117, 125, 0.03); + pointer-events: none; +} + +.conversation-item.not-disturb .conversation-name { + color: #6c757d; + font-weight: 400; +} + +.conversation-item.not-disturb .last-message { + color: #adb5bd; +} + +.not-disturb-badge { + display: inline-flex; + align-items: center; + background: #6c757d; + border-radius: 10px; + padding: 2px 6px; + margin-left: 6px; + font-size: 10px; + font-weight: 500; + box-shadow: 0 1px 3px rgba(108, 117, 125, 0.2); + animation: pulse 3s infinite; +} + +.not-disturb-badge svg { + color: #fff; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; } - - .conversation-items::-webkit-scrollbar { - display: none; - /* Chrome, Safari, Opera */ + + 50% { + opacity: 0.7; } - - .conversation-item { - display: flex; - align-items: center; - padding: 12px 16px; - cursor: pointer; - border-bottom: 1px solid #f0f0f0; - transition: background-color 0.2s; - border-radius: 0; - } - - .conversation-item:hover { - background-color: #f5f5f5; - } - - .conversation-item.active { - background-color: #e6f7ff; - } - - /* 免打扰状态样式 */ - .conversation-item.not-disturb { - background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f4 100%); - position: relative; - } - - .conversation-item.not-disturb::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(108, 117, 125, 0.03); - pointer-events: none; - } - - .conversation-item.not-disturb .conversation-name { - color: #6c757d; - font-weight: 400; - } - - .conversation-item.not-disturb .last-message { - color: #adb5bd; - } - - .not-disturb-badge { - display: inline-flex; - align-items: center; - background: #6c757d; - border-radius: 10px; - padding: 2px 6px; - margin-left: 6px; - font-size: 10px; - font-weight: 500; - box-shadow: 0 1px 3px rgba(108, 117, 125, 0.2); - animation: pulse 3s infinite; - } - - .not-disturb-badge svg { - color: #fff; - } - - @keyframes pulse { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.7; - } - } - - .member-count { - font-size: 12px; - color: #999; - font-weight: 400; - margin-left: 4px; - } - - .avatar-container { - position: relative; - margin-right: 12px; - } - - .conversation-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - } - - .unread-badge { - position: absolute; - top: -4px; - right: -4px; - width: 22px; - height: 16px; - background: #E2F5FF; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - font-family: PingFangSC, PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif; - font-weight: 500; - font-size: 10px; - color: #0088D1; - line-height: 14px; - text-align: center; - font-style: normal; - text-transform: none; - } - - .conversation-info { - flex: 1; - min-width: 0; - } - - .conversation-name { - font-size: 14px; - font-weight: 500; - margin-bottom: 4px; - } - - .last-message { - font-size: 12px; - color: #666; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .conversation-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - } - - .last-time { - font-size: 12px; - color: #999; - } - - .online-indicator { - width: 8px; - height: 8px; - background: #52c41a; - border-radius: 50%; - } - - /* 聊天区域样式 */ - .chat-area { - flex: 1; - display: flex; - flex-direction: column; - position: relative; - } - - .no-conversation { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #999; - } - - .placeholder-image { - width: 120px; - height: 120px; - margin-bottom: 16px; - opacity: 0.5; - } - - .chat-container { - flex: 1; - display: flex; - flex-direction: column; - height: 100%; - } - - .chat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - background: #F5F8FB; - position: relative; - } - - .chat-actions { - display: flex; - gap: 8px; - } - - .menu-btn { - width: 32px; - height: 32px; - border: none; - background: none; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: #666; - transition: all 0.2s; - } - - .menu-btn:hover { - background: #f0f0f0; - color: #1890ff; - } - - .menu-btn svg { - width: 18px; - height: 18px; - } - - .chat-header::after { - content: ''; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 90%; - height: 1px; - background-color: #e6e6e6; - } - - .chat-title { - width: 160px; - height: 20px; - font-family: PingFangSC, PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif; - font-weight: 400; - font-size: 16px; - color: #3F3F44; - line-height: 20px; - text-align: center; - font-style: normal; - text-transform: none; - } - - - - .messages-container { - flex: 1; - background: #F5F8FB; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 16px; - /* 隐藏滚动条但保持滚动功能 */ - scrollbar-width: none; - /* Firefox */ - -ms-overflow-style: none; - /* IE and Edge */ - } - - .messages-container::-webkit-scrollbar { - display: none; - /* Chrome, Safari, Opera */ - } - - .messages-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 16px; - } - - /* 消息包装器 */ - .message-wrapper { - margin-bottom: 8px; - } - - /* 消息时间行 - 独立一行 */ - .message-time-row { - display: flex; - justify-content: center; - margin-bottom: 8px; - } - - .message-time { - font-size: 12px; - color: #666666; - text-align: center; - } - - .message-item { - display: flex; - gap: 8px; - } - - .message-item.message-self { - justify-content: flex-end; - } - - .message-avatar img { - width: 32px; - height: 32px; - border-radius: 50%; - object-fit: cover; - } - - .message-avatar-right { - order: 2; - margin-left: 8px; - } - - .message-content { - max-width: 60%; - } - - .message-self .message-content { - display: flex; - flex-direction: column; - align-items: flex-end; - order: 1; - } - - .message-bubble { - max-width: 224px; - min-height: 40px; - background: #FFFFFF; - border-radius: 0px 8px 8px 8px; - padding: 8px 12px; - word-wrap: break-word; - display: inline-block; - width: auto; - } - - .message-other .message-bubble { - background-color: #f0f0f0; - border-radius: 0px 8px 8px 8px; - } - - .message-self .message-bubble { - background: #E2F5FF; - border-radius: 8px 0px 8px 8px; - color: #333; - } - - .message-text { - line-height: 1.4; - } - - /* 发送者信息样式 */ - .message-sender { - font-size: 12px; - color: #666; - margin-bottom: 6px; - display: flex; - align-items: center; - gap: 6px; - } - - /* 讲师标签样式 */ - .teacher-label { - background: #E2F5FF; - color: #0088D1; - border: 1px solid #0088D1; - padding: 0 5px; - border-radius: 2px; - font-size: 10px; - font-weight: 500; - } - - .message-image img { - max-width: 200px; - border-radius: 8px; - } - - .message-file { - display: flex; - align-items: center; - gap: 8px; - } - - .message-file img { - width: 24px; - height: 24px; +} + +.member-count { + font-size: 12px; + color: #999; + font-weight: 400; + margin-left: 4px; +} + +.avatar-container { + position: relative; + margin-right: 12px; +} + +.conversation-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +/* 群成员组合头像容器 */ +.group-avatar-container { + position: relative; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + flex-wrap: wrap; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + overflow: hidden; +} + +/* 群成员头像项 */ +.member-avatar-item { + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #f0f0f0; +} + +.member-avatar-item img { + height: 100%; + object-fit: cover; +} + +.member-avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%); + color: #333; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 500; +} + +/* 头像占位符 */ +.avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 500; +} + +.unread-badge { + position: absolute; + top: -4px; + right: -4px; + width: 22px; + height: 16px; + background: #E2F5FF; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-family: PingFangSC, PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif; + font-weight: 500; + font-size: 10px; + color: #0088D1; + line-height: 14px; + text-align: center; + font-style: normal; + text-transform: none; +} + +.conversation-info { + flex: 1; + min-width: 0; +} + +.conversation-name { + font-size: 14px; + font-weight: 500; + margin-bottom: 4px; +} + +.last-message { + font-size: 12px; + color: #666; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.last-time { + font-size: 12px; + color: #999; +} + +.online-indicator { + width: 8px; + height: 8px; + background: #52c41a; + border-radius: 50%; +} + +/* 聊天区域样式 */ +.chat-area { + flex: 1; + display: flex; + flex-direction: column; + position: relative; +} + +.no-conversation { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #999; +} + +.placeholder-image { + width: 120px; + height: 120px; + margin-bottom: 16px; + opacity: 0.5; +} + +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: #F5F8FB; + position: relative; +} + +.chat-actions { + display: flex; + gap: 8px; +} + +.menu-btn { + width: 32px; + height: 32px; + border: none; + background: none; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #666; + transition: all 0.2s; +} + +.menu-btn:hover { + background: #f0f0f0; + color: #1890ff; +} + +.menu-btn svg { + width: 18px; + height: 18px; +} + +.chat-header::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 90%; + height: 1px; + background-color: #e6e6e6; +} + +.chat-title { + width: 160px; + height: 20px; + font-family: PingFangSC, PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif; + font-weight: 400; + font-size: 16px; + color: #3F3F44; + line-height: 20px; + text-align: center; + font-style: normal; + text-transform: none; +} + + + +.messages-container { + flex: 1; + background: #F5F8FB; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + /* 隐藏滚动条但保持滚动功能 */ + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ +} + +.messages-container::-webkit-scrollbar { + display: none; + /* Chrome, Safari, Opera */ +} + +.messages-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* 消息包装器 */ +.message-wrapper { + margin-bottom: 8px; +} + +/* 消息时间行 - 独立一行 */ +.message-time-row { + display: flex; + justify-content: center; + margin-bottom: 8px; +} + +.message-time { + font-size: 12px; + color: #666666; + text-align: center; +} + +.message-item { + display: flex; + gap: 8px; +} + +.message-item.message-self { + justify-content: flex-end; +} + +.message-avatar img { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} + +.message-avatar-right { + order: 2; + margin-left: 8px; +} + +.message-content { + max-width: 60%; +} + +.message-self .message-content { + display: flex; + flex-direction: column; + align-items: flex-end; + order: 1; +} + +.message-bubble { + max-width: 224px; + min-height: 40px; + background: #FFFFFF; + border-radius: 0px 8px 8px 8px; + padding: 8px 12px; + word-wrap: break-word; + display: inline-block; + width: auto; +} + +.message-other .message-bubble { + background-color: #f0f0f0; + border-radius: 0px 8px 8px 8px; +} + +.message-self .message-bubble { + background: #E2F5FF; + border-radius: 8px 0px 8px 8px; + color: #333; +} + +.message-text { + line-height: 1.4; +} + +/* 发送者信息样式 */ +.message-sender { + font-size: 12px; + color: #666; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +/* 讲师标签样式 */ +.teacher-label { + background: #E2F5FF; + color: #0088D1; + border: 1px solid #0088D1; + padding: 0 5px; + border-radius: 2px; + font-size: 10px; + font-weight: 500; +} + +.message-image img { + max-width: 200px; + border-radius: 8px; +} + +.file-bubble { + background: transparent; + border: none; + padding: 0; + max-width: 400px; + width: 100%; +} + +.file-info { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: background-color 0.2s ease; + width: 100%; + min-height: 40px; + padding: 8px 0; +} + +.file-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + flex-shrink: 0; +} +.file-icon-image { + width: 24px; + height: 24px; + object-fit: contain; +} + +.file-details { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.file-name { + font-size: 14px; + color: #666666; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.file-download { + width: 18px; + height: 18px; + cursor: pointer; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.file-download:hover { + opacity: 0.7; } .message-time { @@ -1870,6 +3028,7 @@ onUnmounted(() => { color: #856404; font-weight: 500; } + .input-area { height: 128px; background: #FFFFFF; @@ -1954,6 +3113,267 @@ onUnmounted(() => { gap: 8px; } +/* 表情按钮包装器 */ +.emoji-button-wrapper { + position: relative; +} + +/* 表情选择器样式 */ +.emoji-picker { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 12px; + background: #ffffff; + border: 1px solid #E8E8E8; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08); + z-index: 1000; + width: 360px; + max-height: 300px; + overflow: hidden; + backdrop-filter: blur(8px); +} + +.emoji-picker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #F0F0F0; + background: #FAFAFA; + border-radius: 12px 12px 0 0; +} + +.emoji-title { + font-size: 13px; + color: #333; + font-weight: 600; + letter-spacing: 0.3px; +} + +.emoji-close { + width: 24px; + height: 24px; + border: none; + background: none; + font-size: 18px; + color: #999; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; + font-weight: 300; +} + +.emoji-close:hover { + background: #F0F0F0; + color: #666; + transform: scale(1.05); +} + +.emoji-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 4px; + padding: 16px; + max-height: 240px; + overflow-y: auto; + /* 隐藏滚动条但保留滚动功能 */ + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ +} + +.emoji-grid::-webkit-scrollbar { + display: none; + /* Chrome, Safari, Opera */ +} + +.emoji-item { + width: 36px; + height: 36px; + border: none; + background: none; + font-size: 20px; + cursor: pointer; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.emoji-item:hover { + background: #F5F5F5; + transform: scale(1.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.emoji-item:active { + transform: scale(1.05); + background: #E8F4FD; +} + +/* 激活状态的表情按钮 */ +.toolbar-btn.active { + background-color: #E8F4FD; + border-color: #1890ff; +} + +/* 图片预览样式 - 参考教师端 */ +.image-preview-container { + padding: 8px; + border-bottom: 1px solid #F0F0F0; + background: #FAFAFA; + margin: -12px -12px 8px -12px; + max-height: 100px; + overflow-y: auto; +} + +.image-preview-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.image-preview-item { + position: relative; + width: 60px; + height: 60px; + border: 1px solid #E0E0E0; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.preview-image { + width: 100%; + height: 100%; + object-fit: cover; + cursor: pointer; +} + +.remove-image-btn { + position: absolute; + top: -4px; + right: -4px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #ff4757; + color: white; + border: none; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all 0.2s ease; +} + +.remove-image-btn:hover { + background: #ff3742; + transform: scale(1.1); +} + +.remove-image-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* 上传加载状态 */ +.upload-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 0; + color: #666; + font-size: 12px; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid #f3f3f3; + border-top: 2px solid #1890ff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 12px; + color: #666; +} + +/* @用户列表样式 */ +.at-list { + position: fixed; + background: #fff; + border: 1px solid #E0E0E0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 200px; + overflow-y: auto; + z-index: 1000; + min-width: 200px; +} + +.at-list-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.at-list-item:hover { + background-color: #F5F5F5; +} + +.at-list-item-selected { + background-color: #E6F7FF !important; +} + +.at-user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + margin-right: 8px; + object-fit: cover; +} + +.at-user-name { + font-size: 14px; + color: #333; +} + +/* @标记样式 */ +.at-mention { + background-color: #E8F4FD; + color: #1890ff; + padding: 2px 4px; + border-radius: 4px; + font-weight: 500; +} .send-area { display: flex; align-items: center; @@ -2020,6 +3440,66 @@ onUnmounted(() => { font-size: 14px; } +/* 消息加载状态样式 */ +.messages-loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; + padding: 40px 20px; + text-align: center; +} + +.messages-loading-state .loading-spinner { + width: 32px; + height: 32px; + border: 3px solid #f3f3f3; + border-top: 3px solid #1890ff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +.messages-loading-state .loading-text { + font-size: 14px; + color: #666; + font-weight: 500; +} + +/* 消息列表空状态样式 */ +.messages-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; + padding: 40px 20px; + text-align: center; +} + +.messages-empty-state .empty-icon { + color: #d9d9d9; + margin-bottom: 24px; + opacity: 0.8; +} + +.messages-empty-state .empty-title { + font-size: 16px; + font-weight: 500; + color: #666; + margin-bottom: 8px; + line-height: 1.4; +} + +.messages-empty-state .empty-description { + font-size: 14px; + color: #999; + line-height: 1.4; + max-width: 280px; +} /* 详情面板样式 */ .details-panel { position: absolute; diff --git a/src/views/Ai/component/AiAppPublish-Simple.vue b/src/views/Ai/component/AiAppPublish-Simple.vue index 1b28819..55501b4 100644 --- a/src/views/Ai/component/AiAppPublish-Simple.vue +++ b/src/views/Ai/component/AiAppPublish-Simple.vue @@ -5,12 +5,8 @@
- +
@@ -19,13 +15,9 @@
- - + +
@@ -37,7 +29,7 @@
- +

嵌入配置

@@ -49,65 +41,43 @@
- +

{{ embedType === 'iframe' ? 'HTML' : 'JavaScript' }} 代码

复制代码
- +
- +
@@ -176,18 +143,20 @@ const menuConfig = reactive({ const embedCode = computed(() => { const baseUrl = window.location.origin const chatUrl = `${baseUrl}/ai/app/chat/${props.appData.id}` - + if (embedType.value === 'iframe') { return `` } else { + const scriptSrc = `${baseUrl}/js/ai-chat-widget.js` + const appId = props.appData.id return `` +<\/script>` } }) @@ -240,35 +209,35 @@ watch(() => props.appData, (newData) => { .web-embed { .embed-type-selector { margin-bottom: 24px; - + .embed-option { cursor: pointer; transition: all 0.3s ease; border: 2px solid transparent; - + &:hover { border-color: #1890ff; } - + &.active { border-color: #1890ff; background-color: #f0f8ff; } - + .embed-option-content { display: flex; align-items: center; gap: 12px; - + .embed-info { flex: 1; - + .embed-title { font-weight: 600; font-size: 16px; margin-bottom: 4px; } - + .embed-desc { color: #666; font-size: 14px; @@ -277,41 +246,41 @@ watch(() => props.appData, (newData) => { } } } - + .embed-config { margin-bottom: 24px; - + h4 { margin-bottom: 16px; } } - + .embed-code { .code-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; - + h4 { margin: 0; } } } } - + .menu-config { .menu-actions { margin: 24px 0; } - + .sql-script { .code-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; - + h4 { margin: 0; } diff --git a/src/views/teacher/message/components/MessageInput.vue b/src/views/teacher/message/components/MessageInput.vue index be735bc..4e66892 100644 --- a/src/views/teacher/message/components/MessageInput.vue +++ b/src/views/teacher/message/components/MessageInput.vue @@ -34,7 +34,17 @@
+ @input="handleInput" @keyup="handleAtInput" ref="messageTextarea" /> + + +
+
+ + {{ user.name }} +
+
@@ -76,7 +86,7 @@ @@ -351,8 +538,6 @@ defineExpose({ position: relative; width: 80px; height: 80px; - border-radius: 8px; - overflow: hidden; border: 1px solid #E0E0E0; } @@ -695,6 +880,53 @@ defineExpose({ box-shadow: none !important; } +/* @功能样式 */ +.at-list { + position: fixed; + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 200px; + overflow-y: auto; + z-index: 1000; + min-width: 200px; + max-width: 250px; +} + +.at-list-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; +} + +.at-list-item:hover { + background-color: #f5f5f5; +} + +.at-list-item:last-child { + border-bottom: none; +} + +.at-list-item-selected { + background-color: #E6F7FF !important; +} + +.at-user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + margin-right: 8px; + object-fit: cover; +} + +.at-user-name { + font-size: 14px; + color: #333; +} :deep(.n-button.n-button--primary-type) { background: #0088D1; border: 1px solid #0088D1; diff --git a/src/views/teacher/message/components/NotificationMessages.vue b/src/views/teacher/message/components/NotificationMessages.vue index a1d3ec7..8ee9848 100644 --- a/src/views/teacher/message/components/NotificationMessages.vue +++ b/src/views/teacher/message/components/NotificationMessages.vue @@ -29,6 +29,18 @@ }" @click="selectContact(contact.id)">
+
+
+ +
+ {{ member.name.charAt(0) }} +
+
+
+ {{ contact.name.charAt(0) }} +
+
{{ contact.name.charAt(0) }}
@@ -85,7 +97,7 @@

{{ activeContact?.name }} ({{ activeContact?.memberCount || 0 - }}人) + }}人)

@@ -189,7 +201,7 @@
-

{{ message.content }}

+

@@ -384,7 +396,8 @@
+ placeholder="这里输入..." ref="messageInputRef" :chatType="activeContact?.type === 'group' ? 'group' : 'single'" + :groupMembers="activeContact?.type === 'group' ? getGroupMembers(activeContactId) : []" />
@@ -432,12 +445,12 @@