feat: 学员端我的消息: 消息加载动画、@功能、表情/图片发送、5s轮询、空状态适配;接入课程教学统计接口

This commit is contained in:
QDKF 2025-09-27 20:37:31 +08:00
parent b52a954e86
commit fed060545a
6 changed files with 2912 additions and 787 deletions

View File

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

View File

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

View File

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

View File

@ -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
})) // 5ID
})
// 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) => {
// 使IDID
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) => {
// 使IDID
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) => {
// 使IDID
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;

View File

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