feat: 学员端我的消息: 消息加载动画、@功能、表情/图片发送、5s轮询、空状态适配;接入课程教学统计接口
This commit is contained in:
parent
b52a954e86
commit
fed060545a
@ -360,6 +360,16 @@ export class StatisticsApi {
|
||||
return ApiRequest.get('/statistics/comments', params)
|
||||
}
|
||||
|
||||
// 获取课程教学建设数据统计
|
||||
static getCourseTeachingStats(courseId?: string): Promise<ApiResponse<{
|
||||
coursewareCount: number
|
||||
documentCount: number
|
||||
questionBankCount: number
|
||||
examPaperCount: number
|
||||
}>> {
|
||||
return ApiRequest.get('/aiol/statistics/course-teaching-stats', { courseId })
|
||||
}
|
||||
|
||||
// 导出统计报告
|
||||
static exportStatsReport(type: string, params?: {
|
||||
startDate?: string
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -5,12 +5,8 @@
|
||||
<div class="embed-type-selector">
|
||||
<n-radio-group v-model:value="embedType">
|
||||
<n-space>
|
||||
<n-card
|
||||
class="embed-option"
|
||||
:class="{ active: embedType === 'iframe' }"
|
||||
hoverable
|
||||
@click="embedType = 'iframe'"
|
||||
>
|
||||
<n-card class="embed-option" :class="{ active: embedType === 'iframe' }" hoverable
|
||||
@click="embedType = 'iframe'">
|
||||
<div class="embed-option-content">
|
||||
<n-radio value="iframe" />
|
||||
<div class="embed-info">
|
||||
@ -19,13 +15,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card
|
||||
class="embed-option"
|
||||
:class="{ active: embedType === 'script' }"
|
||||
hoverable
|
||||
@click="embedType = 'script'"
|
||||
>
|
||||
|
||||
<n-card class="embed-option" :class="{ active: embedType === 'script' }" hoverable
|
||||
@click="embedType = 'script'">
|
||||
<div class="embed-option-content">
|
||||
<n-radio value="script" />
|
||||
<div class="embed-info">
|
||||
@ -37,7 +29,7 @@
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="embed-config">
|
||||
<h4>嵌入配置</h4>
|
||||
<n-form label-placement="left" :label-width="100">
|
||||
@ -49,65 +41,43 @@
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="embed-code">
|
||||
<div class="code-header">
|
||||
<h4>{{ embedType === 'iframe' ? 'HTML' : 'JavaScript' }} 代码</h4>
|
||||
<n-button type="primary" @click="copyEmbedCode">
|
||||
<template #icon>
|
||||
<n-icon><CopyOutlined /></n-icon>
|
||||
<n-icon>
|
||||
<CopyOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
复制代码
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input
|
||||
type="textarea"
|
||||
:value="embedCode"
|
||||
readonly
|
||||
:rows="8"
|
||||
/>
|
||||
<n-input type="textarea" :value="embedCode" readonly :rows="8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 配置菜单 -->
|
||||
<div v-else-if="publishType === 'menu'" class="menu-config">
|
||||
<n-form
|
||||
:model="menuConfig"
|
||||
label-placement="left"
|
||||
:label-width="100"
|
||||
>
|
||||
<n-form :model="menuConfig" label-placement="left" :label-width="100">
|
||||
<n-form-item label="菜单名称">
|
||||
<n-input
|
||||
v-model:value="menuConfig.menuName"
|
||||
placeholder="请输入菜单名称"
|
||||
readonly
|
||||
/>
|
||||
<n-input v-model:value="menuConfig.menuName" placeholder="请输入菜单名称" readonly />
|
||||
</n-form-item>
|
||||
|
||||
|
||||
<n-form-item label="菜单地址">
|
||||
<n-input
|
||||
v-model:value="menuConfig.menuUrl"
|
||||
placeholder="菜单访问地址"
|
||||
readonly
|
||||
/>
|
||||
<n-input v-model:value="menuConfig.menuUrl" placeholder="菜单访问地址" readonly />
|
||||
</n-form-item>
|
||||
|
||||
|
||||
<n-form-item label="菜单图标">
|
||||
<n-input
|
||||
v-model:value="menuConfig.icon"
|
||||
placeholder="菜单图标"
|
||||
/>
|
||||
<n-input v-model:value="menuConfig.icon" placeholder="菜单图标" />
|
||||
</n-form-item>
|
||||
|
||||
|
||||
<n-form-item label="排序号">
|
||||
<n-input-number
|
||||
v-model:value="menuConfig.sortNo"
|
||||
:min="0"
|
||||
placeholder="排序号"
|
||||
/>
|
||||
<n-input-number v-model:value="menuConfig.sortNo" :min="0" placeholder="排序号" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
|
||||
<div class="menu-actions">
|
||||
<n-space>
|
||||
<n-button type="primary" @click="copyMenuConfig">
|
||||
@ -118,23 +88,20 @@
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="sql-script">
|
||||
<div class="code-header">
|
||||
<h4>SQL 脚本</h4>
|
||||
<n-button text @click="copySqlScript">
|
||||
<template #icon>
|
||||
<n-icon><CopyOutlined /></n-icon>
|
||||
<n-icon>
|
||||
<CopyOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
复制
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input
|
||||
type="textarea"
|
||||
:value="sqlScript"
|
||||
readonly
|
||||
:rows="10"
|
||||
/>
|
||||
<n-input type="textarea" :value="sqlScript" readonly :rows="10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 `<iframe src="${chatUrl}" width="${embedConfig.width}" height="${embedConfig.height}" frameborder="0"></iframe>`
|
||||
} else {
|
||||
const scriptSrc = `${baseUrl}/js/ai-chat-widget.js`
|
||||
const appId = props.appData.id
|
||||
return `<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = '${baseUrl}/js/ai-chat-widget.js';
|
||||
script.setAttribute('data-app-id', '${props.appData.id}');
|
||||
script.src = ${JSON.stringify(scriptSrc)};
|
||||
script.setAttribute('data-app-id', ${JSON.stringify(appId)});
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>`
|
||||
<\/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;
|
||||
}
|
||||
|
@ -34,7 +34,17 @@
|
||||
<div class="input-wrapper">
|
||||
<n-input v-model:value="messageText" type="textarea" :placeholder="placeholder"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }" :bordered="false" class="message-input" @keydown="handleKeyDown"
|
||||
@input="handleInput" />
|
||||
@input="handleInput" @keyup="handleAtInput" ref="messageTextarea" />
|
||||
|
||||
<!-- @用户列表 -->
|
||||
<div v-if="showAtList && atList.length > 0" class="at-list"
|
||||
:style="{ top: atListPosition.top + 'px', left: atListPosition.left + 'px' }">
|
||||
<div class="at-list-item" v-for="(user, index) in atList" :key="user.id"
|
||||
:class="{ 'at-list-item-selected': index === atSelectedIndex }" @mousedown.stop="() => selectAtUser(user)">
|
||||
<img :src="user.avatar" :alt="user.name" class="at-user-avatar" />
|
||||
<span class="at-user-name">{{ user.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -76,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { NInput, NButton } from 'naive-ui'
|
||||
|
||||
// Props
|
||||
@ -84,6 +94,8 @@ interface Props {
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
disabled?: boolean
|
||||
chatType?: 'single' | 'group'
|
||||
groupMembers?: Array<any>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@ -108,6 +120,15 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const selectedImages = ref<Array<{ file: File; url: string; name: string; size: number }>>([])
|
||||
const selectedFiles = ref<Array<{ file: File; name: string; size: number; type: string }>>([])
|
||||
|
||||
// @功能相关
|
||||
const showAtList = ref(false)
|
||||
const atList = ref<Array<{ id: string; name: string; avatar: string }>>([])
|
||||
const atKeyword = ref('')
|
||||
const atStartIndex = ref(-1)
|
||||
const atSelectedIndex = ref(0)
|
||||
const atListPosition = ref({ top: 0, left: 0 })
|
||||
const messageTextarea = ref<any>(null)
|
||||
|
||||
// 表情列表
|
||||
const emojiList = ref([
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
|
||||
@ -252,6 +273,11 @@ const formatFileSize = (bytes: number) => {
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
// 如果@列表正在显示,不发送消息
|
||||
if (showAtList.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (canSend.value) {
|
||||
// 发送文本消息
|
||||
if (messageText.value.trim()) {
|
||||
@ -283,7 +309,149 @@ const handleInput = (value: string) => {
|
||||
emit('input', value)
|
||||
}
|
||||
|
||||
// @功能相关方法
|
||||
const handleAtInput = (event: KeyboardEvent) => {
|
||||
// 如果是键盘导航键,不处理@输入检测
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = event.target as HTMLTextAreaElement
|
||||
const cursorPosition = textarea.selectionStart
|
||||
const text = textarea.value
|
||||
|
||||
// 查找@符号
|
||||
const atMatch = text.substring(0, cursorPosition).match(/@([^@\s]*)$/)
|
||||
|
||||
if (atMatch) {
|
||||
atStartIndex.value = cursorPosition - atMatch[0].length
|
||||
const newKeyword = atMatch[1]
|
||||
|
||||
// 更新关键词
|
||||
atKeyword.value = newKeyword
|
||||
|
||||
// 如果是群聊,显示成员列表
|
||||
if (props.chatType === 'group' && props.groupMembers) {
|
||||
loadAtList()
|
||||
showAtList.value = true
|
||||
updateAtListPosition(textarea)
|
||||
}
|
||||
} else {
|
||||
showAtList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAtList = () => {
|
||||
if (props.chatType === 'group' && props.groupMembers) {
|
||||
const members = props.groupMembers || []
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const selectAtUser = (user?: { id: string; name: string; avatar: string }) => {
|
||||
// 如果没有传入用户,使用当前选中的用户
|
||||
const selectedUser = user || atList.value[atSelectedIndex.value]
|
||||
|
||||
if (!selectedUser) {
|
||||
return
|
||||
}
|
||||
|
||||
if (messageTextarea.value && atStartIndex.value >= 0) {
|
||||
const text = messageText.value
|
||||
const beforeAt = text.substring(0, atStartIndex.value) // @符号之前的内容
|
||||
const afterAt = text.substring(atStartIndex.value + 1 + atKeyword.value.length) // @符号和关键词之后的内容
|
||||
|
||||
// 重新构建文本:beforeAt + @ + 用户名 + 空格 + afterAt
|
||||
const newText = beforeAt + `@${selectedUser.name} ` + afterAt
|
||||
messageText.value = newText
|
||||
|
||||
// 设置光标位置(Naive UI的n-input不需要手动设置光标位置)
|
||||
nextTick(() => {
|
||||
if (messageTextarea.value) {
|
||||
messageTextarea.value.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 updateAtListPosition = (textarea: HTMLTextAreaElement) => {
|
||||
const rect = textarea.getBoundingClientRect()
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
|
||||
|
||||
atListPosition.value = {
|
||||
top: rect.top + scrollTop - 100, // 在输入框上方显示
|
||||
left: rect.left + scrollLeft + 10 // 稍微向右偏移
|
||||
}
|
||||
}
|
||||
|
||||
// 清空输入框和相关状态
|
||||
const clearInput = () => {
|
||||
messageText.value = ''
|
||||
selectedImages.value = []
|
||||
selectedFiles.value = []
|
||||
showAtList.value = false
|
||||
atKeyword.value = ''
|
||||
atStartIndex.value = -1
|
||||
atSelectedIndex.value = 0
|
||||
}
|
||||
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// 如果@列表正在显示,优先处理@功能
|
||||
if (showAtList.value && atList.value.length > 0) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
atSelectedIndex.value = (atSelectedIndex.value + 1) % atList.value.length
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
atSelectedIndex.value = atSelectedIndex.value === 0
|
||||
? atList.value.length - 1
|
||||
: atSelectedIndex.value - 1
|
||||
return
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectAtUser()
|
||||
return
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
hideAtList()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Enter 键发送消息
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
@ -309,6 +477,24 @@ const insertEmoji = (emoji: string) => {
|
||||
|
||||
// 其他事件处理方法
|
||||
|
||||
// 点击外部隐藏@列表
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (showAtList.value) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.at-list') && !target.closest('.message-input')) {
|
||||
hideAtList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
@ -316,7 +502,8 @@ defineExpose({
|
||||
},
|
||||
clear: () => {
|
||||
messageText.value = ''
|
||||
}
|
||||
},
|
||||
clearInput
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -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;
|
||||
|
@ -29,6 +29,18 @@
|
||||
}" @click="selectContact(contact.id)">
|
||||
<div class="contact-avatar">
|
||||
<img v-if="contact.avatar" :src="contact.avatar" :alt="contact.name" />
|
||||
<div v-else-if="contact.type === 'group'" class="group-avatar-container">
|
||||
<div v-for="(member, index) in getGroupMemberAvatars(contact.id)" :key="member.id"
|
||||
class="member-avatar-item" :style="getMemberAvatarStyle(contact.id, index)">
|
||||
<img v-if="member.avatar" :src="member.avatar" :alt="member.name" />
|
||||
<div v-else class="member-avatar-placeholder">
|
||||
{{ member.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getGroupMemberAvatars(contact.id).length === 0" class="avatar-placeholder">
|
||||
{{ contact.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="avatar-placeholder">
|
||||
{{ contact.name.charAt(0) }}
|
||||
</div>
|
||||
@ -85,7 +97,7 @@
|
||||
<h4 class="chat-user-name">
|
||||
{{ activeContact?.name }}
|
||||
<span v-if="activeContact?.type === 'group'" class="member-count">({{ activeContact?.memberCount || 0
|
||||
}}人)</span>
|
||||
}}人)</span>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
@ -189,7 +201,7 @@
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<div v-if="message.type === 'text'" class="message-bubble">
|
||||
<p class="message-text">{{ message.content }}</p>
|
||||
<p class="message-text" v-html="renderMessageContent(message.content)"></p>
|
||||
</div>
|
||||
|
||||
<!-- 图片消息 -->
|
||||
@ -384,7 +396,8 @@
|
||||
<!-- 消息输入区域 -->
|
||||
<div class="chat-input-area">
|
||||
<MessageInput @send="handleSendMessage" @emoji="handleEmoji" @image="handleImage" @file="handleFile"
|
||||
placeholder="这里输入..." ref="messageInputRef" />
|
||||
placeholder="这里输入..." ref="messageInputRef" :chatType="activeContact?.type === 'group' ? 'group' : 'single'"
|
||||
:groupMembers="activeContact?.type === 'group' ? getGroupMembers(activeContactId) : []" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -432,12 +445,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from 'vue'
|
||||
import { NIcon, NBadge, useMessage, useDialog } from 'naive-ui'
|
||||
import {
|
||||
PeopleOutline,
|
||||
ChatbubbleEllipsesOutline
|
||||
} from '@vicons/ionicons5'
|
||||
// import {
|
||||
// PeopleOutline,
|
||||
// ChatbubbleEllipsesOutline
|
||||
// } from '@vicons/ionicons5'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
import { ChatApi } from '@/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@ -539,8 +552,8 @@ const contacts = ref<Contact[]>([])
|
||||
// 当前会话的消息数据
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
// 群成员数据
|
||||
const groupMembers = ref<ChatMember[]>([])
|
||||
// 群成员数据存储(按群ID存储)
|
||||
const groupMembers = ref<Record<string, ChatMember[]>>({})
|
||||
|
||||
// 群组信息数据
|
||||
const groupInfo = ref({
|
||||
@ -568,6 +581,12 @@ const currentChatShowLabel = ref(0) // 当前群聊的showLabel状态
|
||||
const showMemberModal = ref(false) // 是否显示成员弹框
|
||||
const selectedMember = ref<any>(null) // 选中的成员
|
||||
|
||||
// 消息轮询相关
|
||||
const messagePollingTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const isPolling = ref(false)
|
||||
const lastMessageId = ref<string>('') // 最后一条消息的ID
|
||||
const lastMessageTimestamp = ref<string>('') // 最后一条消息的时间戳
|
||||
|
||||
// 计算属性:过滤后的群成员列表
|
||||
const filteredMembers = computed(() => {
|
||||
if (!memberSearchKeyword.value.trim()) {
|
||||
@ -575,15 +594,16 @@ const filteredMembers = computed(() => {
|
||||
}
|
||||
|
||||
const keyword = memberSearchKeyword.value.toLowerCase().trim()
|
||||
return groupMembers.value.filter((member: any) =>
|
||||
member.realname.toLowerCase().includes(keyword) ||
|
||||
member.username.toLowerCase().includes(keyword)
|
||||
const allMembers = Object.values(groupMembers.value).flat()
|
||||
return allMembers.filter((member: any) =>
|
||||
(member.realname || '').toLowerCase().includes(keyword) ||
|
||||
(member.username || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
// 计算属性:显示的群成员列表
|
||||
const displayedMembers = computed(() => {
|
||||
const members = filteredMembers.value
|
||||
const members = filteredMembers.value as any[]
|
||||
|
||||
if (showAllMembers.value || members.length <= maxDisplayMembers) {
|
||||
return members
|
||||
@ -593,7 +613,7 @@ const displayedMembers = computed(() => {
|
||||
|
||||
// 计算属性:是否需要显示"查看更多"按钮
|
||||
const shouldShowViewMore = computed(() => {
|
||||
return filteredMembers.value.length > maxDisplayMembers && !showAllMembers.value
|
||||
return (filteredMembers.value as any[]).length > maxDisplayMembers && !showAllMembers.value
|
||||
})
|
||||
|
||||
// 生命周期钩子
|
||||
@ -602,6 +622,32 @@ onMounted(async () => {
|
||||
await loadAllGroupNotDisturbStatus()
|
||||
await loadAllGroupLastMessages()
|
||||
loadTeacherList()
|
||||
|
||||
// 添加页面可见性变化监听
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
// 页面可见性变化处理
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
// 页面不可见时暂停轮询
|
||||
console.log('📱 页面不可见,暂停消息轮询')
|
||||
stopMessagePolling()
|
||||
} else {
|
||||
// 页面可见时恢复轮询
|
||||
if (activeContactId.value) {
|
||||
console.log('📱 页面可见,恢复消息轮询')
|
||||
startMessagePolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 组件卸载时停止消息轮询
|
||||
stopMessagePolling()
|
||||
|
||||
// 移除页面可见性变化监听
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
// 获取教师用户ID列表
|
||||
@ -634,12 +680,297 @@ const shouldShowTeacherLabel = (senderId: string) => {
|
||||
return shouldShow
|
||||
}
|
||||
|
||||
// 获取群成员数据
|
||||
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] || []
|
||||
return members.slice(0, 4).map((member: any) => ({
|
||||
id: member.id,
|
||||
name: member.realname || member.username || member.name || '未知',
|
||||
avatar: member.avatar
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取群成员数据(用于@功能)
|
||||
const getGroupMembers = (chatId: string) => {
|
||||
return groupMembers.value[chatId] || []
|
||||
}
|
||||
|
||||
// 渲染消息内容,支持@功能
|
||||
const renderMessageContent = (content: string) => {
|
||||
if (!content) return ''
|
||||
|
||||
// 匹配@用户名模式
|
||||
const atPattern = /@([^@\s]+)/g
|
||||
return content.replace(atPattern, '<span class="at-mention">@$1</span>')
|
||||
}
|
||||
|
||||
// 消息轮询相关方法
|
||||
const startMessagePolling = () => {
|
||||
if (isPolling.value || !activeContactId.value) return
|
||||
|
||||
isPolling.value = true
|
||||
console.log('🔄 开始消息轮询,chatId:', activeContactId.value)
|
||||
|
||||
// 立即执行一次轮询
|
||||
pollNewMessages()
|
||||
|
||||
messagePollingTimer.value = setInterval(async () => {
|
||||
if (isPolling.value) { // 确保轮询状态仍然为true
|
||||
await pollNewMessages()
|
||||
}
|
||||
}, 5000) // 每5秒轮询一次,减少服务器压力
|
||||
}
|
||||
|
||||
const stopMessagePolling = () => {
|
||||
if (messagePollingTimer.value) {
|
||||
clearInterval(messagePollingTimer.value)
|
||||
messagePollingTimer.value = null
|
||||
}
|
||||
isPolling.value = false
|
||||
console.log('⏹️ 停止消息轮询')
|
||||
}
|
||||
|
||||
const pollNewMessages = async () => {
|
||||
if (!activeContactId.value) {
|
||||
console.log('🔄 轮询跳过:没有活跃联系人')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔄 开始轮询检查,chatId:', activeContactId.value, 'isPolling:', isPolling.value)
|
||||
|
||||
try {
|
||||
const response = await ChatApi.getChatMessages(activeContactId.value)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const newMessages = response.data.result || []
|
||||
|
||||
console.log('🔄 轮询获取到消息数量:', newMessages.length, '当前消息数量:', messages.value.length)
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
// 获取最新消息的ID和时间戳
|
||||
const latestMessage = newMessages[newMessages.length - 1]
|
||||
// 尝试多个可能的ID字段
|
||||
const latestId = (latestMessage?.id || latestMessage?.createTime || '').toString()
|
||||
const latestTimestamp = latestMessage.timestamp || latestMessage.createTime
|
||||
|
||||
console.log('🔄 轮询检查消息:', {
|
||||
latestId,
|
||||
lastMessageId: lastMessageId.value,
|
||||
latestTimestamp,
|
||||
lastMessageTimestamp: lastMessageTimestamp.value,
|
||||
newMessagesLength: newMessages.length,
|
||||
currentMessagesLength: messages.value.length,
|
||||
latestIdType: typeof latestId,
|
||||
lastMessageIdType: typeof lastMessageId.value,
|
||||
latestMessage: latestMessage, // 添加完整的最新消息对象用于调试
|
||||
allMessageIds: newMessages.slice(-5).map((msg: any) => ({
|
||||
id: msg.id,
|
||||
createTime: msg.createTime,
|
||||
timestamp: msg.timestamp
|
||||
})) // 显示最近5条消息的所有可能ID字段
|
||||
})
|
||||
|
||||
// 如果消息数量增加或者消息ID与上次不同,说明有新消息
|
||||
if (newMessages.length > messages.value.length || (latestId && latestId !== lastMessageId.value)) {
|
||||
console.log('🔄 检测到新消息,更新消息列表')
|
||||
|
||||
// 更新最后消息ID和时间戳
|
||||
lastMessageId.value = latestId
|
||||
if (latestTimestamp) {
|
||||
lastMessageTimestamp.value = latestTimestamp
|
||||
}
|
||||
|
||||
// 只添加新消息,而不是重新加载所有消息
|
||||
const currentMessageCount = messages.value.length
|
||||
const additionalMessages = newMessages.slice(currentMessageCount)
|
||||
|
||||
console.log('🔄 需要添加的新消息数量:', additionalMessages.length)
|
||||
|
||||
if (additionalMessages.length > 0) {
|
||||
// 转换新消息格式
|
||||
const convertedMessages = additionalMessages.map((msg: any) => {
|
||||
const messageType = msg.messageType === 1 ? 'text' :
|
||||
msg.messageType === 2 ? 'image' :
|
||||
msg.messageType === 3 ? 'file' : 'text'
|
||||
|
||||
let fileUrl = ''
|
||||
let fileName = ''
|
||||
let fileType = ''
|
||||
|
||||
if (messageType === 'file' && msg.content) {
|
||||
const urlParts = msg.content.split('/')
|
||||
fileName = urlParts[urlParts.length - 1] || '未知文件'
|
||||
fileUrl = msg.content
|
||||
// 简单的文件类型检测
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||
fileType = ext
|
||||
}
|
||||
|
||||
if (messageType === 'image' && msg.content) {
|
||||
fileUrl = msg.content
|
||||
}
|
||||
|
||||
// 获取发送者信息(与loadMessages方法保持一致)
|
||||
const messageSenderId = msg.senderId || msg.createBy || ''
|
||||
const senderName = msg.senderInfo?.realname || '未知用户'
|
||||
const senderAvatar = msg.senderInfo?.avatar || ''
|
||||
|
||||
// 判断是否为自己发送的消息
|
||||
const isOwn = teacherUserIds.value.includes(messageSenderId)
|
||||
|
||||
const convertedMessage = {
|
||||
id: (msg.id || msg.messageId || msg.msgId || msg.snowflakeId || msg.createTime).toString(),
|
||||
contactId: activeContactId.value || '',
|
||||
content: msg.content || '',
|
||||
type: messageType as 'text' | 'image' | 'file',
|
||||
senderId: messageSenderId,
|
||||
senderName: senderName,
|
||||
avatar: senderAvatar,
|
||||
time: formatTime(msg.createTime || new Date().toISOString()),
|
||||
isOwn: isOwn,
|
||||
isRead: true,
|
||||
showSender: !isOwn, // 与loadMessages保持一致
|
||||
fileUrl,
|
||||
fileName,
|
||||
fileType
|
||||
}
|
||||
|
||||
return convertedMessage
|
||||
})
|
||||
|
||||
// 添加新消息到现有消息列表
|
||||
messages.value.push(...convertedMessages)
|
||||
console.log('✅ 成功添加', convertedMessages.length, '条新消息')
|
||||
|
||||
// 更新联系人最后消息
|
||||
if (convertedMessages.length > 0) {
|
||||
const lastMessage = convertedMessages[convertedMessages.length - 1]
|
||||
updateContactLastMessage(activeContactId.value, lastMessage)
|
||||
}
|
||||
|
||||
// 滚动到底部显示新消息
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log('🔄 没有新消息')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 消息轮询失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算群成员头像样式
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载联系人列表
|
||||
const loadContacts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
console.log('🔄 开始获取会话信息...')
|
||||
const response = await ChatApi.getMyChats()
|
||||
|
||||
// 打印API原始响应
|
||||
console.log('📡 会话信息API响应:', {
|
||||
status: (response as any).status,
|
||||
data: response.data
|
||||
})
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
console.log('✅ 会话信息API成功:', {
|
||||
success: response.data.success,
|
||||
message: response.data.message,
|
||||
resultCount: response.data.result?.length || 0
|
||||
})
|
||||
|
||||
// 打印每个会话的原始数据
|
||||
console.log('📋 原始会话数据:', response.data.result.map((chat: any) => ({
|
||||
id: chat.id,
|
||||
name: chat.name,
|
||||
avatar: chat.avatar,
|
||||
type: chat.type,
|
||||
lastMessage: chat.lastMessage,
|
||||
lastMessageTime: chat.lastMessageTime,
|
||||
unreadCount: chat.unreadCount,
|
||||
isOnline: chat.isOnline,
|
||||
memberCount: chat.memberCount,
|
||||
izAllMuted: chat.izAllMuted,
|
||||
showLabel: chat.showLabel,
|
||||
izNotDisturb: chat.izNotDisturb
|
||||
})))
|
||||
|
||||
// 转换API数据为组件需要的格式
|
||||
contacts.value = response.data.result.map((chat: any) => {
|
||||
@ -662,33 +993,45 @@ const loadContacts = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 打印联系人列表
|
||||
console.log('📋 联系人列表:', {
|
||||
totalCount: contacts.value.length,
|
||||
contacts: contacts.value.map(contact => ({
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
type: contact.type,
|
||||
memberCount: contact.memberCount,
|
||||
isOnline: contact.isOnline,
|
||||
unreadCount: contact.unreadCount
|
||||
}))
|
||||
})
|
||||
// 打印转换后的联系人列表
|
||||
console.log('🔄 转换后的联系人列表:', contacts.value.map((contact: any) => ({
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
avatar: contact.avatar,
|
||||
type: contact.type,
|
||||
lastMessage: contact.lastMessage,
|
||||
lastMessageTime: contact.lastMessageTime,
|
||||
unreadCount: contact.unreadCount,
|
||||
isOnline: contact.isOnline,
|
||||
memberCount: contact.memberCount,
|
||||
izAllMuted: contact.izAllMuted,
|
||||
showLabel: contact.showLabel,
|
||||
izNotDisturb: contact.izNotDisturb
|
||||
})))
|
||||
|
||||
|
||||
// 如果是群聊且没有memberCount,尝试获取群成员数量
|
||||
// 如果是群聊,加载群成员数据
|
||||
for (const contact of contacts.value) {
|
||||
if (contact.type === 'group' && (!contact.memberCount || contact.memberCount === 0)) {
|
||||
loadGroupMemberCount(contact.id)
|
||||
if (contact.type === 'group') {
|
||||
// 加载群成员数量
|
||||
if (!contact.memberCount || contact.memberCount === 0) {
|
||||
loadGroupMemberCount(contact.id)
|
||||
}
|
||||
// 加载群成员数据用于头像显示
|
||||
loadGroupMembers(contact.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取我的会话失败:', error)
|
||||
console.error('❌ 获取会话信息失败:', {
|
||||
error: error,
|
||||
message: error instanceof Error ? error.message : '未知错误',
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
})
|
||||
message.error('获取会话列表失败')
|
||||
contacts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
console.log('✅ 会话信息加载完成')
|
||||
}
|
||||
}
|
||||
|
||||
@ -700,12 +1043,7 @@ const loadGroupMemberCount = async (chatId: string) => {
|
||||
if (response.data && response.data.success && response.data.result) {
|
||||
const memberCount = response.data.result.length
|
||||
|
||||
// 打印群聊成员数量
|
||||
console.log('📊 群聊成员数量:', {
|
||||
chatId,
|
||||
memberCount,
|
||||
contactName: contacts.value.find(c => c.id === chatId)?.name || '未知'
|
||||
})
|
||||
// 群聊成员数量获取完成
|
||||
|
||||
// 更新对应联系人的成员数量
|
||||
const contact = contacts.value.find((c: Contact) => c.id === chatId)
|
||||
@ -718,53 +1056,6 @@ const loadGroupMemberCount = async (chatId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载群成员列表
|
||||
const loadGroupMembers = async (chatId: string) => {
|
||||
try {
|
||||
const response = await ChatApi.getChatMembers(chatId)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
groupMembers.value = response.data.result || []
|
||||
|
||||
// 打印群成员列表 - 包含角色和禁言状态
|
||||
console.log('👥 群成员列表:', {
|
||||
chatId,
|
||||
memberCount: groupMembers.value.length,
|
||||
members: groupMembers.value.map(member => ({
|
||||
id: member.id,
|
||||
realname: member.realname,
|
||||
username: member.username,
|
||||
isTeacher: member.isTeacher,
|
||||
role: member.role, // 0=群主, 1=管理员, 2=成员
|
||||
roleText: member.role === 0 ? '群主' : member.role === 1 ? '管理员' : '成员',
|
||||
izMuted: member.izMuted, // 0=未禁言, 1=已禁言
|
||||
isMuted: member.izMuted === 1,
|
||||
avatar: member.avatar,
|
||||
email: member.email,
|
||||
phone: member.phone
|
||||
}))
|
||||
})
|
||||
|
||||
// 从联系人数据中获取群组名称和禁言状态
|
||||
const contact = contacts.value.find((c: Contact) => c.id === chatId)
|
||||
const groupName = contact ? contact.name : '暂无'
|
||||
|
||||
// 更新群组信息
|
||||
groupInfo.value = {
|
||||
name: groupName,
|
||||
memberCount: groupMembers.value.length
|
||||
}
|
||||
|
||||
// 注意:免打扰状态已在页面加载时通过 loadAllGroupNotDisturbStatus 获取
|
||||
|
||||
} else {
|
||||
console.warn('⚠️ 获取群成员失败:', response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取群成员失败:', error)
|
||||
message.error('获取群成员失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理群成员搜索
|
||||
const handleMemberSearch = () => {
|
||||
@ -782,11 +1073,11 @@ const loadAllGroupNotDisturbStatus = async () => {
|
||||
const currentUserId = userStore.user?.id
|
||||
if (!currentUserId) return
|
||||
|
||||
console.log('🔄 开始加载所有群聊免打扰状态...')
|
||||
// 开始加载所有群聊免打扰状态
|
||||
|
||||
// 并行获取所有群聊的成员信息
|
||||
const groupChats = contacts.value.filter(contact => contact.type === 'group')
|
||||
const promises = groupChats.map(async (contact) => {
|
||||
const groupChats = contacts.value.filter((contact: any) => contact.type === 'group')
|
||||
const promises = groupChats.map(async (contact: any) => {
|
||||
try {
|
||||
const response = await ChatApi.getChatMembers(contact.id)
|
||||
if (response.data && response.data.success) {
|
||||
@ -795,11 +1086,7 @@ const loadAllGroupNotDisturbStatus = async () => {
|
||||
|
||||
if (currentUserMember && currentUserMember.izNotDisturb !== undefined) {
|
||||
contact.izNotDisturb = currentUserMember.izNotDisturb
|
||||
console.log('✅ 群聊免打扰状态:', {
|
||||
chatId: contact.id,
|
||||
chatName: contact.name,
|
||||
izNotDisturb: contact.izNotDisturb
|
||||
})
|
||||
// 群聊免打扰状态获取成功
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -808,7 +1095,7 @@ const loadAllGroupNotDisturbStatus = async () => {
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
console.log('🎉 所有群聊免打扰状态加载完成')
|
||||
// 所有群聊免打扰状态加载完成
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 加载所有群聊免打扰状态失败:', error)
|
||||
@ -818,11 +1105,11 @@ const loadAllGroupNotDisturbStatus = async () => {
|
||||
// 获取所有群聊的最后消息
|
||||
const loadAllGroupLastMessages = async () => {
|
||||
try {
|
||||
console.log('🔄 开始加载所有群聊最后消息...')
|
||||
// 开始加载所有群聊最后消息
|
||||
|
||||
// 并行获取所有群聊的最后消息
|
||||
const groupChats = contacts.value.filter(contact => contact.type === 'group')
|
||||
const promises = groupChats.map(async (contact) => {
|
||||
const groupChats = contacts.value.filter((contact: any) => contact.type === 'group')
|
||||
const promises = groupChats.map(async (contact: any) => {
|
||||
try {
|
||||
const response = await ChatApi.getChatMessages(contact.id)
|
||||
if (response.data && response.data.success && response.data.result.length > 0) {
|
||||
@ -846,12 +1133,7 @@ const loadAllGroupLastMessages = async () => {
|
||||
contact.lastMessage = displayContent
|
||||
contact.lastMessageTime = formatTime(lastMessage.createTime || lastMessage.timestamp)
|
||||
|
||||
console.log('✅ 群聊最后消息:', {
|
||||
chatId: contact.id,
|
||||
chatName: contact.name,
|
||||
lastMessage: displayContent,
|
||||
lastMessageTime: contact.lastMessageTime
|
||||
})
|
||||
// 群聊最后消息获取成功
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 获取群聊最后消息失败:', contact.id, error)
|
||||
@ -859,7 +1141,7 @@ const loadAllGroupLastMessages = async () => {
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
console.log('🎉 所有群聊最后消息加载完成')
|
||||
// 所有群聊最后消息加载完成
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 加载所有群聊最后消息失败:', error)
|
||||
@ -1282,7 +1564,7 @@ const loadMessages = async (chatId: string) => {
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
id: msg.id,
|
||||
id: (msg.id || msg.messageId || msg.msgId || msg.snowflakeId || msg.createTime).toString(),
|
||||
contactId: msg.chatId,
|
||||
type: messageType as 'text' | 'image' | 'file',
|
||||
content: msg.content,
|
||||
@ -1316,6 +1598,16 @@ const loadMessages = async (chatId: string) => {
|
||||
|
||||
// 更新联系人的最后消息
|
||||
updateContactLastMessage(chatId, lastMessage)
|
||||
|
||||
// 设置轮询的初始消息ID和时间戳
|
||||
lastMessageId.value = lastMessage.id
|
||||
const lastBackendMessage = sortedMessages[sortedMessages.length - 1]
|
||||
const lastTimestamp = lastBackendMessage.timestamp || lastBackendMessage.createTime
|
||||
if (lastTimestamp) {
|
||||
lastMessageTimestamp.value = lastTimestamp
|
||||
console.log('🔄 设置初始轮询消息ID和时间戳:', lastMessage.id, lastTimestamp)
|
||||
console.log('🔄 后端消息对象:', lastBackendMessage) // 添加后端消息对象调试
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
@ -1331,6 +1623,9 @@ const loadMessages = async (chatId: string) => {
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
|
||||
// 启动消息轮询
|
||||
startMessagePolling()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1428,9 +1723,24 @@ const selectContact = async (contactId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 停止之前的轮询
|
||||
stopMessagePolling()
|
||||
|
||||
// 重置轮询状态
|
||||
lastMessageId.value = ''
|
||||
lastMessageTimestamp.value = ''
|
||||
|
||||
// 加载该会话的消息
|
||||
await loadMessages(contactId)
|
||||
|
||||
// 清空输入框和相关状态
|
||||
if (messageInputRef.value) {
|
||||
messageInputRef.value.clearInput()
|
||||
}
|
||||
|
||||
// 启动消息轮询
|
||||
startMessagePolling()
|
||||
|
||||
// 暂时移除标记消息已读的API调用,避免错误
|
||||
// try {
|
||||
// await ChatApi.markAsRead(contactId)
|
||||
@ -1495,6 +1805,11 @@ const handleSendMessage = async (content: string) => {
|
||||
|
||||
// 使用服务器返回的真实ID更新最后读取消息ID
|
||||
await updateLastReadMessage(newMessage.id)
|
||||
|
||||
// 更新轮询状态,确保轮询能检测到刚发送的消息
|
||||
lastMessageId.value = newMessage.id
|
||||
lastMessageTimestamp.value = new Date().toISOString()
|
||||
console.log('🔄 更新轮询状态:', lastMessageId.value, lastMessageTimestamp.value)
|
||||
} else {
|
||||
console.warn('⚠️ 服务器未返回消息ID,跳过更新最后读取消息')
|
||||
}
|
||||
@ -1700,6 +2015,11 @@ const handleImage = async (imageData: any) => {
|
||||
// 使用服务器返回的真实ID更新最后读取消息ID
|
||||
await updateLastReadMessage(imageMessage.id)
|
||||
|
||||
// 更新轮询状态,确保轮询能检测到刚发送的消息
|
||||
lastMessageId.value = imageMessage.id
|
||||
lastMessageTimestamp.value = new Date().toISOString()
|
||||
console.log('🔄 更新轮询状态:', lastMessageId.value, lastMessageTimestamp.value)
|
||||
|
||||
// 不需要重新加载消息列表,因为消息已经添加到本地列表
|
||||
console.log('✅ 图片消息发送成功,无需重新加载')
|
||||
} else {
|
||||
@ -1824,6 +2144,11 @@ const handleFile = async (fileData: any) => {
|
||||
// 使用服务器返回的真实ID更新最后读取消息ID
|
||||
await updateLastReadMessage(fileMessage.id)
|
||||
|
||||
// 更新轮询状态,确保轮询能检测到刚发送的消息
|
||||
lastMessageId.value = fileMessage.id
|
||||
lastMessageTimestamp.value = new Date().toISOString()
|
||||
console.log('🔄 更新轮询状态:', lastMessageId.value, lastMessageTimestamp.value)
|
||||
|
||||
// 不需要重新加载消息列表,因为消息已经添加到本地列表
|
||||
console.log('✅ 文件消息发送成功,无需重新加载')
|
||||
} else {
|
||||
@ -1980,6 +2305,45 @@ onMounted(() => {
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
/* 群成员组合头像容器 */
|
||||
.group-avatar-container {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
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;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
padding-top: 8px;
|
||||
flex: 1;
|
||||
@ -2934,6 +3298,14 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* @消息样式 */
|
||||
.at-mention {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* 成员弹框样式 - 符合项目风格 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
@ -66,7 +66,7 @@
|
||||
<img src="/images/teacher/teaching-construction1.png" alt="课件/视频" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-number">20</div>
|
||||
<div class="card-number">{{ teachingStats.coursewareCount }}</div>
|
||||
<div class="card-label">课件/视频</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -78,7 +78,7 @@
|
||||
<img src="/images/teacher/teaching-construction2.png" alt="资料/文档" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-number">139</div>
|
||||
<div class="card-number">{{ teachingStats.documentCount }}</div>
|
||||
<div class="card-label">资料/文档</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -90,7 +90,7 @@
|
||||
<img src="/images/teacher/teaching-construction3.png" alt="题库总数" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-number">862</div>
|
||||
<div class="card-number">{{ teachingStats.questionBankCount }}</div>
|
||||
<div class="card-label">题库总数</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -102,7 +102,7 @@
|
||||
<img src="/images/teacher/teaching-construction4.png" alt="试卷总数" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-number">10</div>
|
||||
<div class="card-number">{{ teachingStats.examPaperCount }}</div>
|
||||
<div class="card-label">试卷总数</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,7 +112,69 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
console.log('BasicData component loaded')
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { StatisticsApi } from '@/api/modules/statistics'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
// 教学建设数据统计
|
||||
const teachingStats = ref({
|
||||
coursewareCount: 0, // 课件/视频
|
||||
documentCount: 0, // 资料/文档
|
||||
questionBankCount: 0, // 题库总数
|
||||
examPaperCount: 0 // 试卷总数
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取消息提示实例
|
||||
const message = useMessage()
|
||||
|
||||
// 获取路由实例
|
||||
const route = useRoute()
|
||||
|
||||
// 获取课程教学建设数据统计
|
||||
const loadTeachingStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
// 从路由参数中获取课程ID,尝试多个可能的参数名
|
||||
const courseId = route.params.id as string || route.params.courseId as string
|
||||
console.log('🔍 从路由获取的courseId:', courseId, '类型:', typeof courseId)
|
||||
console.log('🔍 完整路由参数:', route.params)
|
||||
console.log('🔍 当前路由路径:', route.path)
|
||||
console.log('🔍 准备调用API: /aiol/statistics/course-teaching-stats')
|
||||
const response = await StatisticsApi.getCourseTeachingStats(courseId)
|
||||
console.log('🔍 API响应:', response)
|
||||
if (response.data) {
|
||||
const result = response.data
|
||||
console.log('🔍 API返回的原始数据:', result)
|
||||
|
||||
// 根据实际API返回的字段名映射数据
|
||||
teachingStats.value = {
|
||||
coursewareCount: result.coursewareCount || 0, // 课件/视频
|
||||
documentCount: result.documentCount || 0, // 资料/文档
|
||||
questionBankCount: result.questionBankCount || 0, // 题库总数
|
||||
examPaperCount: result.examPaperCount || 0 // 试卷总数
|
||||
}
|
||||
|
||||
console.log('✅ 教学建设数据统计加载成功:', teachingStats.value)
|
||||
} else {
|
||||
console.warn('⚠️ 教学建设数据统计加载失败:', response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 教学建设数据统计加载失败:', error)
|
||||
message.error('教学建设数据统计加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
console.log('BasicData component loaded')
|
||||
loadTeachingStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
Loading…
x
Reference in New Issue
Block a user