feat: 对接我的会话,查询会话消息,查询群聊成员列表,查询课程评论接口;添加会话消息空状态样式,添加证书中心内容样式,修复班级管理组件删除学员功能

This commit is contained in:
QDKF 2025-09-15 19:34:25 +08:00
parent ad2ec33c6a
commit ef49c7b6d3
11 changed files with 1216 additions and 465 deletions

View File

@ -12,6 +12,7 @@ export { default as OrderApi } from './modules/order'
export { default as UploadApi } from './modules/upload'
export { default as StatisticsApi } from './modules/statistics'
export { default as ExamApi } from './modules/exam'
export { ChatApi } from './modules/chat'
// API 基础配置
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/jeecgboot'
@ -221,6 +222,17 @@ export const API_ENDPOINTS = {
PROGRESS: '/learning-progress',
MY_COURSES: '/my-courses',
},
// 聊天相关
CHAT: {
MY_CHATS: '/aiol/aiolChat/my_chats',
MESSAGES: '/aiol/aiolChat/:chatId/messages',
MEMBERS: '/aiol/aiolChat/:chatId/members',
SEND: '/aiol/aiolChat/send',
MARK_READ: '/aiol/aiolChat/mark-read',
UNREAD_COUNT: '/aiol/aiolChat/unread-count',
FOLLOW: '/aiol/aiolUserFollow/follow',
},
// 资源相关
RESOURCES: {

177
src/api/modules/chat.ts Normal file
View File

@ -0,0 +1,177 @@
// 聊天相关API接口
import { ApiRequest } from '../request'
import type { ApiResponse } from '../types'
// 聊天会话接口类型定义
export interface ChatSession {
id: string
name: string
type: number // 0=私聊1=群聊
avatar?: string
lastMessage?: string
lastMessageTime?: string
unreadCount?: number
isOnline?: boolean
createTime?: string
updateTime?: string
// 根据实际API响应添加的字段
refId?: string // 关联ID
createBy?: string
updateBy?: string
izAllMuted?: boolean
showLabel?: string
memberCount?: number // 群成员数量
status?: number // 会话状态
description?: string // 会话描述
}
// 聊天消息接口类型定义
export interface ChatMessage {
id: string
chatId: string
senderId: string
senderName: string
senderAvatar?: string
content: string
messageType: 'text' | 'image' | 'file' | 'system'
timestamp: string
isRead: boolean
replyTo?: string
// 根据接口文档优化的字段
status?: number // 消息状态
fileUrl?: string // 文件URL
fileSize?: number // 文件大小
fileType?: string // 文件类型
}
// 群聊成员接口类型定义
export interface ChatMember {
id: string
chatId: string
userId: string
userName: string
userAvatar?: string
role: 'admin' | 'member'
joinTime: string
isOnline?: boolean
// 根据接口文档优化的字段
status?: number // 成员状态
lastActiveTime?: string // 最后活跃时间
}
// 我的会话列表响应类型
export interface MyChatsResponse {
success: boolean
message: string
code: number
result: ChatSession[]
timestamp: number
}
// 会话消息列表响应类型
export interface ChatMessagesResponse {
success: boolean
message: string
code: number
result: ChatMessage[]
timestamp: number
}
// 群聊成员列表响应类型
export interface ChatMembersResponse {
success: boolean
message: string
code: number
result: ChatMember[]
timestamp: number
}
// 分页查询参数
export interface ChatQueryParams {
page?: number
size?: number
keyword?: string
type?: 'private' | 'group'
status?: number
}
/**
* API
*/
export const ChatApi = {
/**
*
* GET /aiol/aiolChat/my_chats
*
*/
getMyChats: (params?: ChatQueryParams): Promise<ApiResponse<MyChatsResponse>> => {
return ApiRequest.get('/aiol/aiolChat/my_chats', { params })
},
/**
*
* GET /aiol/aiolChat/{chatId}/messages
*
*/
getChatMessages: (chatId: string, params?: ChatQueryParams): Promise<ApiResponse<ChatMessagesResponse>> => {
console.log('🔍 调用getChatMessages接口chatId:', chatId, 'params:', params)
return ApiRequest.get(`/aiol/aiolChat/${chatId}/messages`, { params })
},
/**
*
* GET /aiol/aiolChat/{chatId}/members
*/
getChatMembers: (chatId: string): Promise<ApiResponse<ChatMembersResponse>> => {
return ApiRequest.get(`/aiol/aiolChat/${chatId}/members`)
},
/**
* /
* POST /aiol/aiolUserFollow/follow
*/
followTeacher: (followedId: string): Promise<ApiResponse<any>> => {
return ApiRequest.post('/aiol/aiolUserFollow/follow', {
followedId
})
},
/**
*
* POST /aiol/aiolChat/send
*
*/
sendMessage: (data: {
chatId: string
content: string
messageType: 'text' | 'image' | 'file'
replyTo?: string
fileUrl?: string
fileSize?: number
fileType?: string
}): Promise<ApiResponse<any>> => {
return ApiRequest.post('/aiol/aiolChat/send', data)
},
/**
*
* PUT /aiol/aiolChat/mark-read
*
*/
markAsRead: (chatId: string, messageIds?: string[]): Promise<ApiResponse<any>> => {
return ApiRequest.put('/aiol/aiolChat/mark-read', {
chatId,
messageIds
})
},
/**
*
* GET /aiol/aiolChat/unread-count
*
*/
getUnreadCount: (): Promise<ApiResponse<{ total: number; chats: { chatId: string; count: number }[] }>> => {
return ApiRequest.get('/aiol/aiolChat/unread-count')
}
}

View File

@ -489,7 +489,7 @@ export class ClassApi {
*
*/
static async removeStudent(classId: string, studentId: string): Promise<ApiResponse<any>> {
return ApiRequest.delete(`/aiol/aiolClass/${classId}/remove_student/${studentId}`);
return ApiRequest.delete(`/aiol/aiolClass/${classId}/remove_student/${studentId}`, undefined);
}
/**

View File

@ -94,7 +94,7 @@
<!-- 题目列表 -->
<div class="question-list-section">
<n-data-table ref="tableRef" :columns="columns" :data="questionList" :pagination="false"
:loading="loading" :row-key="(row) => row.id" :checked-row-keys="selectedRowKeys"
:loading="loading" :row-key="(row: any) => row.id" :checked-row-keys="selectedRowKeys"
@update:checked-row-keys="handleCheck" striped>
<template #empty>
<div class="empty-state">
@ -124,8 +124,8 @@
<div v-if="paginationItemCount > 0" class="pagination-wrapper">
<n-pagination v-model:page="paginationPage" v-model:page-size="paginationPageSize"
:item-count="paginationItemCount" :page-sizes="[10, 20, 50]" show-size-picker show-quick-jumper
:prefix="({ itemCount }) => `共${itemCount}题`" @update:page="handlePageChange"
@update:page-size="handlePageSizeChange" />
:prefix="({ itemCount }: { itemCount: number }) => `共${itemCount}题`"
@update:page="handlePageChange" @update:page-size="handlePageSizeChange" />
</div>
</div>

View File

@ -3,21 +3,11 @@
<div class="toolbar" v-if="props.type === 'course'">
<NSpace>
<n-select v-model:value="selectedDepartment" :options="departmentOptions" placeholder="班级名称"
style="width: 200px" />
<n-button
type="info"
ghost
:disabled="selectedRowKeys.length === 0"
@click="handleBatchTransfer"
>
style="width: 200px" />
<n-button type="info" ghost :disabled="selectedRowKeys.length === 0" @click="handleBatchTransfer">
批量调班({{ selectedRowKeys.length }})
</n-button>
<n-button
type="error"
ghost
:disabled="selectedRowKeys.length === 0"
@click="handleBatchDelete"
>
<n-button type="error" ghost :disabled="selectedRowKeys.length === 0" @click="handleBatchDelete">
批量移除({{ selectedRowKeys.length }})
</n-button>
</NSpace>
@ -38,7 +28,8 @@
</template>
管理班级
</n-button>
<n-button type="primary" ghost @click="openInviteModal(selectedDepartment || props.classId?.toString() || '1')">
<n-button type="primary" ghost
@click="openInviteModal(selectedDepartment || String(props.classId || '1'))">
<template #icon>
<NIcon>
<QrCode />
@ -57,7 +48,8 @@
全部学员
</div>
<NSpace>
<n-dropdown trigger="hover" :options="addStudentOptions" @select="handleAddStudentSelect" v-if="props.type === 'course'" >
<n-dropdown trigger="hover" :options="addStudentOptions" @select="handleAddStudentSelect"
v-if="props.type === 'course'">
<n-button type="primary">
添加学员
</n-button>
@ -82,23 +74,16 @@
</div>
<n-divider v-if="props.type === 'student'" />
<n-data-table
:columns="columns"
:data="data"
:pagination="pagination"
:loading="loading"
:row-key="(row: StudentItem) => row.id"
v-model:checked-row-keys="selectedRowKeys"
striped
bordered
size="small"
/>
<n-data-table :columns="columns" :data="data" :pagination="pagination" :loading="loading"
:row-key="(row: StudentItem) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered
size="small" />
<!-- 添加班级弹窗 -->
<n-modal v-model:show="showAddClassModal" :title="isRenameMode ? '重命名' : '添加班级'">
<n-card style="width: 500px" :title="isRenameMode ? '重命名' : '添加班级'" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-form ref="classFormRef" :model="classFormData" :rules="classRules" label-placement="left" label-width="80px"
require-mark-placement="right-hanging">
<n-card style="width: 500px" :title="isRenameMode ? '重命名' : '添加班级'" :bordered="false" size="huge"
role="dialog" aria-modal="true">
<n-form ref="classFormRef" :model="classFormData" :rules="classRules" label-placement="left"
label-width="80px" require-mark-placement="right-hanging">
<n-form-item label="班级名称" path="className">
<n-input v-model:value="classFormData.className" placeholder="请输入班级名称" clearable />
</n-form-item>
@ -136,8 +121,10 @@
<div class="row-item creator-col">{{ classItem.creator }}</div>
<div class="row-item time-col">{{ classItem.createTime }}</div>
<div class="row-item action-col">
<n-button size="small" type="info" ghost @click="handleRenameClass(classItem)">重命名</n-button>
<n-button size="small" type="error" ghost @click="handleDeleteClass(classItem)">删除</n-button>
<n-button size="small" type="info" ghost
@click="handleRenameClass(classItem)">重命名</n-button>
<n-button size="small" type="error" ghost
@click="handleDeleteClass(classItem)">删除</n-button>
</div>
</div>
</div>
@ -152,11 +139,7 @@
<p>将选中的 <strong>{{ selectedRowKeys.length }}</strong> 名学员调至以下班级</p>
<div class="selected-students">
<div class="student-list">
<div
v-for="student in selectedStudents"
:key="student.id"
class="student-item"
>
<div v-for="student in selectedStudents" :key="student.id" class="student-item">
<span class="student-name">{{ student.studentName }}</span>
<span class="student-account">({{ student.accountNumber }})</span>
</div>
@ -166,17 +149,11 @@
<div class="class-selection">
<div class="selection-title">选择目标班级</div>
<div class="class-list">
<div
v-for="option in classOptions"
:key="option.value"
class="class-item"
<div v-for="option in classOptions" :key="option.value" class="class-item"
:class="{ 'selected': selectedTargetClass === option.value }"
@click="selectedTargetClass = option.value"
>
<n-checkbox
:checked="selectedTargetClass === option.value"
@update:checked="() => selectedTargetClass = option.value"
/>
@click="selectedTargetClass = option.value">
<n-checkbox :checked="selectedTargetClass === option.value"
@update:checked="() => selectedTargetClass = option.value" />
<span class="class-name">{{ option.label }}</span>
</div>
</div>
@ -193,7 +170,8 @@
<!-- 添加/编辑学员弹窗 -->
<n-modal v-model:show="showAddModal" :title="isEditMode ? '编辑学员' : '添加学员'">
<n-card style="width: 600px" :title="isEditMode ? '编辑学员' : '添加学员'" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card style="width: 600px" :title="isEditMode ? '编辑学员' : '添加学员'" :bordered="false" size="huge"
role="dialog" aria-modal="true">
<n-form ref="formRef" :model="formData" :rules="rules" label-placement="left" label-width="80px"
require-mark-placement="right-hanging">
<n-form-item label="姓名" path="studentName">
@ -203,30 +181,16 @@
<n-input v-model:value="formData.studentId" placeholder="请输入学员学号" clearable />
</n-form-item>
<n-form-item label="登录密码" path="loginPassword">
<n-input
v-model:value="formData.loginPassword"
type="password"
:placeholder="isEditMode ? '不填写则不修改密码' : '请输入登录密码'"
show-password-on="click"
clearable
/>
<n-input v-model:value="formData.loginPassword" type="password"
:placeholder="isEditMode ? '不填写则不修改密码' : '请输入登录密码'" show-password-on="click" clearable />
</n-form-item>
<n-form-item label="所在学校" path="college">
<n-select
v-model:value="formData.college"
:options="collegeOptions"
placeholder="请选择学校"
clearable
/>
<n-select v-model:value="formData.college" :options="collegeOptions" placeholder="请选择学校"
clearable />
</n-form-item>
<n-form-item label="所在班级" path="className">
<n-select
v-model:value="formData.className"
:options="classSelectOptions"
placeholder="请选择班级"
multiple
clearable
/>
<n-select v-model:value="formData.className" :options="classSelectOptions" placeholder="请选择班级"
multiple clearable />
</n-form-item>
</n-form>
<template #footer>
@ -251,22 +215,13 @@
<div class="class-selection">
<div class="selection-title">选择目标班级</div>
<div class="class-list">
<div
v-for="option in classOptions"
:key="option.value"
class="class-item"
<div v-for="option in classOptions" :key="option.value" class="class-item"
:class="{ 'selected': selectedTargetClass === option.value }"
@click="selectedTargetClass = option.value"
>
<n-checkbox
:checked="selectedTargetClass === option.value"
@update:checked="() => selectedTargetClass = option.value"
/>
@click="selectedTargetClass = option.value">
<n-checkbox :checked="selectedTargetClass === option.value"
@update:checked="() => selectedTargetClass = option.value" />
<span class="class-name">{{ option.label }}</span>
<span
v-if="isCurrentClass(option.value)"
class="class-desc"
>当前</span>
<span v-if="isCurrentClass(option.value)" class="class-desc">当前</span>
</div>
</div>
</div>
@ -287,7 +242,7 @@
<div class="invite-code-display">
<div class="invite-title">班级邀请码</div>
<div class="invite-note" v-if="currentInviteClassId">
班级{{ masterClassList.find(item => item.id === currentInviteClassId)?.className || '未知班级' }}
班级{{masterClassList.find(item => item.id === currentInviteClassId)?.className || '未知班级'}}
</div>
<div class="invite-code">{{ inviteCode }}</div>
<n-button ghost type="primary" @click="copyInviteCode">复制</n-button>
@ -296,19 +251,11 @@
</n-card>
</n-modal>
<!-- 导入学员弹窗 -->
<ImportModal
v-model:show="showImportModal"
title="导入学员"
:show-radio-options="true"
radio-label="导入重复学员信息"
:radio-options="importRadioOptions"
radio-field="updateMode"
import-type="student"
template-name="student_import_template.xlsx"
@success="handleImportSuccess"
@template-download="handleTemplateDownload"
/>
<!-- 导入学员弹窗 -->
<ImportModal v-model:show="showImportModal" title="导入学员" :show-radio-options="true" radio-label="导入重复学员信息"
:radio-options="importRadioOptions" radio-field="updateMode" import-type="student"
template-name="student_import_template.xlsx" @success="handleImportSuccess"
@template-download="handleTemplateDownload" />
</div>
</template>
@ -342,7 +289,7 @@ import ImportModal from '@/components/common/ImportModal.vue'
// props
interface Props {
type: 'course' | 'student'
classId?: number | null // ID
classId?: string | number | null // ID
}
// props
@ -447,12 +394,12 @@ const rules: FormRules = {
{ required: true, message: '请选择所在学院', trigger: 'blur' }
],
className: [
{
required: true,
{
required: true,
type: 'array',
min: 1,
message: '请选择至少一个班级',
trigger: 'blur'
message: '请选择至少一个班级',
trigger: 'blur'
}
]
}
@ -482,7 +429,7 @@ const addStudentOptions = [
key: 'manual'
},
{
label: '学员库添加',
label: '学员库添加',
key: 'library'
}
]
@ -504,7 +451,7 @@ const departmentOptions = computed(() => [
])
//
const classOptions = computed(() =>
const classOptions = computed(() =>
masterClassList.value.map(item => ({
label: item.className,
value: item.id
@ -512,7 +459,7 @@ const classOptions = computed(() =>
)
//
const classSelectOptions = computed(() =>
const classSelectOptions = computed(() =>
masterClassList.value.map(item => ({
label: item.className,
value: item.id
@ -550,8 +497,8 @@ const columns: DataTableColumns<StudentItem> = [
//
return h('div', {
class: 'class-cell'
}, classNames.map((name, index) =>
h('div', {
}, classNames.map((name, index) =>
h('div', {
key: index,
class: 'class-cell-item'
}, name)
@ -687,7 +634,7 @@ const handleTransfer = (row: StudentItem) => {
currentTransferStudent.value = row
selectedTargetClass.value = ''
showTransferModal.value = true
console.log('打开调班弹窗:', {
学员信息: row,
可选班级: classOptions.value
@ -714,10 +661,10 @@ const handleBatchDelete = () => {
message.warning('请先选择要移除的学员')
return
}
const selectedStudentsList = selectedStudents.value
const studentNames = selectedStudentsList.map(s => s.studentName).join('、')
dialog.info({
title: '批量移除确认',
content: `确定要移除选中的 ${selectedRowKeys.value.length} 名学员吗?\n\n学员名单${studentNames}\n\n移除后这些学员将无法访问班级资源`,
@ -727,17 +674,17 @@ const handleBatchDelete = () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 1000))
const removedCount = selectedRowKeys.value.length
//
data.value = data.value.filter(student => !selectedRowKeys.value.includes(student.id))
//
selectedRowKeys.value = []
message.success(`成功移除 ${removedCount} 名学员`)
//
loadData(props.classId)
} catch (error) {
@ -772,17 +719,17 @@ const confirmBatchTransfer = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 1000))
const transferCount = selectedRowKeys.value.length
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
message.success(`已将 ${transferCount} 名学员调至 ${targetClassName}`)
//
showBatchTransferModal.value = false
selectedTargetClass.value = ''
selectedRowKeys.value = []
//
loadData(props.classId)
} catch (error) {
@ -829,13 +776,13 @@ const handleDeleteStudent = (row: StudentItem) => {
message.error('班级ID不存在无法删除学员')
return
}
// API
await ClassApi.removeStudent(props.classId.toString(), row.id)
// API - 使ID
await ClassApi.removeStudent(String(props.classId), row.accountNumber)
const studentName = row.studentName
message.success(`已删除学员:${studentName}`)
//
loadData(props.classId)
} catch (error) {
@ -866,15 +813,15 @@ const handleConfirmTransfer = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
message.success(`已将学员 ${currentTransferStudent.value.studentName} 调至 ${targetClassName}`)
//
showTransferModal.value = false
currentTransferStudent.value = null
selectedTargetClass.value = ''
//
loadData(props.classId)
} catch (error) {
@ -888,11 +835,11 @@ const isCurrentClass = (classValue: string) => {
console.log('调班判断: 未选中学员')
return false
}
// classNamevalue
const studentClassName = currentTransferStudent.value.className
const classOption = masterClassList.value.find(item => item.className === studentClassName)
console.log('调班判断:', {
学员姓名: currentTransferStudent.value.studentName,
学员班级: studentClassName,
@ -900,7 +847,7 @@ const isCurrentClass = (classValue: string) => {
找到的班级: classOption,
是否匹配: classOption?.id === classValue
})
return classOption?.id === classValue
}
@ -916,9 +863,9 @@ const getClassNameById = (classId: string): string => {
//
const formatClassNames = (classInfo: string): string[] => {
if (!classInfo) return ['未分配班级']
if (classInfo.includes(',')) {
//
return classInfo.split(',').map(id => id.trim()).map(getClassNameById)
@ -941,7 +888,7 @@ const openInviteModal = (classId: string) => {
currentInviteClassId.value = classId
inviteCode.value = generateInviteCode(classId)
showInviteModal.value = true
console.log('打开邀请码弹窗:', {
班级ID: classId,
邀请码: inviteCode.value
@ -959,7 +906,7 @@ const copyInviteCode = () => {
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (isEditMode.value) {
//
message.info('编辑功能暂未实现,敬请期待')
@ -969,13 +916,13 @@ const handleSubmit = async () => {
console.log('🚀 开始新增学员...')
console.log('表单数据:', formData.value)
console.log('当前班级ID:', props.classId)
//
if (!formData.value.className || formData.value.className.length === 0) {
message.error('请选择班级')
return
}
// API
const payload = {
realName: formData.value.studentName,
@ -984,14 +931,14 @@ const handleSubmit = async () => {
school: formData.value.college,
classId: formData.value.className.join(',')
}
console.log('📝 API请求参数:', payload)
// API
const response = await ClassApi.createdStudents(payload)
console.log('✅ 创建学员响应:', response)
if (response.data && (response.data.success || response.data.code === 200)) {
message.success(`已成功添加学员 ${formData.value.studentName}`)
} else {
@ -999,11 +946,11 @@ const handleSubmit = async () => {
return
}
}
//
showAddModal.value = false
resetForm()
//
loadData(props.classId)
} catch (error: any) {
@ -1085,7 +1032,7 @@ const handleAddClass = async () => {
//
emit('class-changed')
}
//
await loadClassList()
closeAddClassModal()
@ -1119,7 +1066,7 @@ const handleDeleteClass = (classItem: any) => {
try {
await ClassApi.deleteClass(classItem.id)
message.success(`已删除班级:${classItem.className}`)
//
await loadClassList()
//
@ -1136,7 +1083,7 @@ const loadClassList = async () => {
try {
console.log('🚀 开始加载班级列表数据...')
const response = await ClassApi.queryClassList({ course_id: null })
// API
const classListData = response.data.result || []
const transformedClassData: ClassItem[] = classListData.map((classItem: any) => ({
@ -1158,7 +1105,7 @@ const loadClassList = async () => {
minute: '2-digit'
}).replace(/\//g, '.').replace(',', '')
}))
masterClassList.value = transformedClassData
console.log(`✅ 成功加载班级列表,共 ${transformedClassData.length} 个班级`)
} catch (error) {
@ -1172,33 +1119,28 @@ const loadClassList = async () => {
let loadDataTimer: NodeJS.Timeout | null = null
// API
const loadData = async (classId?: number | null) => {
const loadData = async (classId?: string | number | null) => {
//
if (loadDataTimer) {
clearTimeout(loadDataTimer)
}
loadDataTimer = setTimeout(async () => {
console.log(`🚀 开始加载班级数据 - classId: ${classId}, 调用栈:`, new Error().stack?.split('\n')[2]?.trim())
//
if (loading.value) {
console.log('⚠️ 数据正在加载中,跳过重复请求')
return
}
loading.value = true
try {
if (classId === null || classId === undefined) {
//
data.value = []
totalStudents.value = 0
console.log('📝 未选择班级,显示空数据')
} else {
// API
console.log(`📡 正在获取班级 ${classId} 的学生数据...`)
const response = await ClassApi.getClassStudents(classId.toString())
const response = await ClassApi.getClassStudents(String(classId))
// API
const studentsData = response.data.result || []
const transformedData: StudentItem[] = studentsData.map((student: any) => ({
@ -1216,14 +1158,12 @@ const loadData = async (classId?: number | null) => {
minute: '2-digit'
}).replace(/\//g, '.').replace(',', '') : '未知时间'
}))
data.value = transformedData
totalStudents.value = transformedData.length
console.log(`✅ 成功加载班级 ${classId} 的数据,共 ${transformedData.length} 名学员`)
}
} catch (error) {
console.error('加载班级学生数据失败:', error)
console.error('加载班级学生数据失败:', error)
message.error('加载学生数据失败,请重试')
data.value = []
totalStudents.value = 0
@ -1238,7 +1178,7 @@ const loadData = async (classId?: number | null) => {
const handleImportSuccess = (result: any) => {
console.log('导入成功:', result)
message.success(`导入完成!成功:${result.details?.success || 0} 条,失败:${result.details?.failed || 0}`)
//
loadData(props.classId)
}
@ -1254,10 +1194,9 @@ const handleTemplateDownload = (type?: string) => {
watch(
() => props.classId,
(newClassId, oldClassId) => {
console.log(`班级ID从 ${oldClassId} 变更为 ${newClassId}`)
if (newClassId !== oldClassId) {
// watch
selectedDepartment.value = newClassId ? newClassId.toString() : ''
selectedDepartment.value = newClassId ? String(newClassId) : ''
loadData(newClassId)
}
},
@ -1269,12 +1208,11 @@ watch(
watch(
() => selectedDepartment.value,
(newDepartmentId, oldDepartmentId) => {
console.log(`选择的班级从 ${oldDepartmentId} 变更为 ${newDepartmentId}`)
// props.classId
// props.classIdprops
const currentPropsClassId = props.classId?.toString()
const currentPropsClassId = props.classId ? String(props.classId) : ''
if (newDepartmentId !== oldDepartmentId && newDepartmentId !== currentPropsClassId) {
const targetClassId = newDepartmentId ? Number(newDepartmentId) : null
const targetClassId = newDepartmentId || null
loadData(targetClassId)
}
},
@ -1283,7 +1221,6 @@ watch(
const loadSchoolList = () => {
TeachCourseApi.getSchoolList().then(res => {
console.log('获取学校列表:', res)
collegeOptions.value = res.data.result.map((school: any) => ({
label: school,
value: school
@ -1297,15 +1234,13 @@ onMounted(async () => {
//
await loadClassList()
loadSchoolList()
// 使使classId使
const initialClassId = props.classId ? props.classId : Number(selectedDepartment.value)
const initialClassId = props.classId ? props.classId : selectedDepartment.value
loadData(initialClassId)
// id id
if(route.path.includes('/teacher/course-editor')){
console.log('当前路由路径:', route.path)
console.log('课程ID:', router.currentRoute.value.params.id)
if (route.path.includes('/teacher/course-editor')) {
courseId.value = router.currentRoute.value.params.id.toString()
}
})

View File

@ -138,7 +138,7 @@
<div class="question-component-wrapper">
<!-- 单选题 -->
<SingleChoiceQuestion v-if="subQuestion.type === 'single_choice'"
v-model="subQuestion.options!" :correctAnswer="subQuestion.correctAnswer"
v-model="subQuestion.options!" :correctAnswer="subQuestion.correctAnswer || null"
@update:correctAnswer="(val: number | null) => subQuestion.correctAnswer = val"
v-model:title="subQuestion.title" v-model:explanation="subQuestion.explanation" />
@ -719,7 +719,7 @@ const ensureSubQuestionFields = (subQuestion: SubQuestion): void => {
if (!subQuestion.correctAnswers) subQuestion.correctAnswers = [];
if (!subQuestion.fillBlanks) subQuestion.fillBlanks = [];
if (!subQuestion.subQuestions) subQuestion.subQuestions = [];
if (subQuestion.correctAnswer === undefined || subQuestion.correctAnswer === '' || subQuestion.correctAnswer === null) subQuestion.correctAnswer = null;
if (subQuestion.correctAnswer === undefined || subQuestion.correctAnswer === null) subQuestion.correctAnswer = null;
if (subQuestion.trueFalseAnswer === undefined) subQuestion.trueFalseAnswer = null;
if (!subQuestion.textAnswer) subQuestion.textAnswer = '';
if (!subQuestion.explanation) subQuestion.explanation = '';

View File

@ -162,7 +162,12 @@
<div class="section">
<h3 class="section-title">学员信息</h3>
<div class="info-fields">
<div class="field">学员姓名</div>
<div class="field editable-field" @mouseenter="startEdit('studentName')"
@mouseleave="stopEdit('studentName')">
<span v-if="!editingField.studentName">{{ studentName }}</span>
<input v-else v-model="studentName" @blur="stopEdit('studentName')"
@keyup.enter="stopEdit('studentName')" class="field-input" />
</div>
<div class="field">学员帐号</div>
<div class="field">班级名称</div>
<div class="field">自定义文字</div>
@ -173,11 +178,37 @@
<div class="section">
<h3 class="section-title">考试信息</h3>
<div class="info-fields">
<div class="field">考试名称</div>
<div class="field">考试分数</div>
<div class="field editable-field" @mouseenter="startEdit('examName')" @mouseleave="stopEdit('examName')">
<span v-if="!editingField.examName">{{ examName }}</span>
<input v-else v-model="examName" @blur="stopEdit('examName')" @keyup.enter="stopEdit('examName')"
class="field-input" />
</div>
<div class="field editable-field" @mouseenter="startEdit('examScore')"
@mouseleave="stopEdit('examScore')">
<span v-if="!editingField.examScore">{{ examScore }}</span>
<input v-else v-model="examScore" @blur="stopEdit('examScore')" @keyup.enter="stopEdit('examScore')"
class="field-input" />
</div>
<div class="field">考试评语</div>
</div>
</div>
<!-- 证书信息 -->
<div class="section">
<h3 class="section-title">证书信息</h3>
<div class="info-fields">
<div class="field">证书编号</div>
<div class="field">发证日期</div>
<div class="field">颁发机构</div>
<div class="field">有效期至</div>
<div class="field editable-field" @mouseenter="startEdit('certificationDate')"
@mouseleave="stopEdit('certificationDate')">
<span v-if="!editingField.certificationDate">{{ certificationDate }}</span>
<input v-else v-model="certificationDate" @blur="stopEdit('certificationDate')"
@keyup.enter="stopEdit('certificationDate')" class="field-input" />
</div>
</div>
</div>
</div>
<!-- 收起/展开按钮 -->
@ -189,7 +220,26 @@
<div class="certificate-content" :class="{ 'preview-mode': isPreviewMode }"
:style="{ backgroundColor: selectedColor }">
<img src="/images/teacher/certificate.png" alt="">
<div class="certificate-wrapper">
<img src="/images/teacher/certificate.png" alt="证书背景" class="certificate-bg">
<!-- 荣誉证书标题 -->
<h1 class="certificate-title">荣誉证书</h1>
<!-- 学员姓名和成就描述 -->
<div class="achievement-section">
<div class="student-name">{{ studentName }}</div>
<div class="achievement-middle">
<span class="exam-name">{{ examName }}</span>恭喜您取得<span class="exam-score">{{ examScore }}</span>的优异成绩
</div>
<div class="achievement-end">特发此状,以资鼓励!</div>
</div>
<!-- 认证时间 -->
<div class="certification-date">
<span class="label">{{ certificationDate }}</span>
</div>
</div>
</div>
@ -238,6 +288,20 @@ const validityDuration = ref(60)
const validityEndDate = ref('2000-10-11T09:00')
const currentValidityText = ref('永久有效')
//
const studentName = ref('学员姓名')
const examName = ref('考试名称')
const examScore = ref('考试分数')
const certificationDate = ref('认证时间')
//
const editingField = ref({
studentName: false,
examName: false,
examScore: false,
certificationDate: false
})
// /
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
@ -309,6 +373,16 @@ const closeCategoryPopover = () => {
}
}
//
const startEdit = (fieldName: keyof typeof editingField.value) => {
editingField.value[fieldName] = true
}
//
const stopEdit = (fieldName: keyof typeof editingField.value) => {
editingField.value[fieldName] = false
}
@ -402,6 +476,7 @@ onUnmounted(() => {
opacity: 0;
transform: scale(0.8);
}
.top-section {
width: 100%;
background-color: #fff;
@ -750,6 +825,7 @@ onUnmounted(() => {
.btn-confirm:hover {
background: #0277BD;
}
.right-section {
position: absolute;
top: 0;
@ -768,6 +844,7 @@ onUnmounted(() => {
opacity: 0;
visibility: hidden;
}
.collapse-button {
position: absolute;
left: -40px;
@ -998,6 +1075,28 @@ onUnmounted(() => {
background: #E8E8E8;
}
.editable-field {
cursor: pointer;
position: relative;
}
.editable-field:hover {
background: #E8E8E8;
}
.field-input {
width: 100%;
border: none;
background: transparent;
font-size: 12px;
color: #666666;
outline: none;
}
.field-input:focus {
background: #fff;
}
/* 内联颜色选择器样式 */
.color-input-inline {
width: 100%;
@ -1025,8 +1124,77 @@ onUnmounted(() => {
align-items: center;
}
.certificate-content img {
width: 800px;
.certificate-wrapper {
position: relative;
display: inline-block;
}
.certificate-bg {
width: 800px;
height: auto;
display: block;
}
/* 荣誉证书标题 */
.certificate-title {
position: absolute;
top: 23%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 40px;
color: #CC9E4A;
margin: 0;
z-index: 1;
}
/* 成就描述区域 */
.achievement-section {
width: 68%;
position: absolute;
top: 48%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
.student-name {
font-size: 22px;
color: #333333;
font-weight: 500;
margin-bottom: 19px;
}
.achievement-middle {
margin-left: 55px;
font-size: 22px;
color: #333333;
line-height: 1.6;
margin-bottom: 20px;
}
.achievement-end {
margin-left: 55px;
font-size: 22px;
color: #333333;
font-weight: 500;
}
.exam-name,
.exam-score {
color: #333333;
margin: 0 12px;
}
/* 认证时间 */
.certification-date {
position: absolute;
bottom: 13%;
right: 18%;
z-index: 1;
}
.certification-date .label {
font-size: 22px;
color: #333333;
}
</style>

View File

@ -65,7 +65,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NBadge, NTabs, NTabPane } from 'naive-ui'
import { NBadge, NTabs, NTabPane, useMessage } from 'naive-ui'
import { ChatApi } from '@/api'
//
import NotificationMessages from './components/NotificationMessages.vue'
@ -75,13 +76,17 @@ import SystemMessages from './components/SystemMessages.vue'
//
const activeTab = ref('notification') // tab
const message = useMessage()
//
const notificationCount = ref(5) //
const commentCount = ref(3) // @
const notificationCount = ref(0) //
const commentCount = ref(0) // @
const favoriteCount = ref(0) //
const systemCount = ref(0) //
//
const loading = ref(false)
//
onMounted(() => {
//
@ -89,9 +94,48 @@ onMounted(() => {
})
//
const loadMessageCounts = () => {
// TODO: API
// 使
const loadMessageCounts = async () => {
loading.value = true
try {
//
await loadNotificationCount()
// TODO:
// await loadCommentCount()
// await loadFavoriteCount()
// await loadSystemCount()
} catch (error) {
console.error('加载消息数量失败:', error)
message.error('加载消息数量失败')
} finally {
loading.value = false
}
}
//
const loadNotificationCount = async () => {
try {
const response = await ChatApi.getUnreadCount()
if (response.data) {
notificationCount.value = response.data.total || 0
}
} catch (error) {
console.error('获取未读消息数量失败:', error)
// API
try {
const chatsResponse = await ChatApi.getMyChats()
if (chatsResponse.data && chatsResponse.data.success) {
notificationCount.value = chatsResponse.data.result.reduce((total: number, chat: any) => {
return total + (chat.unreadCount || 0)
}, 0)
}
} catch (chatError) {
console.error('获取会话列表失败:', chatError)
// 0
notificationCount.value = 0
}
}
}
</script>

View File

@ -1,7 +1,25 @@
<template>
<div class="message-center">
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p class="loading-text">加载中...</p>
</div>
<!-- 空状态 -->
<div v-else-if="messages.length === 0" class="empty-state">
<div class="empty-icon">
<n-icon size="48" color="#d9d9d9">
<ChatbubbleEllipsesOutline />
</n-icon>
</div>
<p class="empty-text">暂无评论和@消息</p>
<p class="empty-desc">当有人评论或@你时消息会显示在这里</p>
</div>
<!-- 消息列表 -->
<div class="message-list">
<div v-else class="message-list">
<div v-for="message in messages" :key="message.id" class="message-item">
<!-- 用户头像 -->
<div class="avatar-container">
@ -36,14 +54,7 @@
<!-- 操作按钮 -->
<div class="message-btns">
<div class="message-actions">
<button class="action-btn" @click="toggleReply(message.id)">
<n-icon size="16">
<ChatbubbleEllipsesOutline />
</n-icon>
回复
</button>
<button class="action-btn" @click="toggleFavorite(message.id)">
<!-- <i class="icon-favorite" :class="{ active: message.isFavorited }"></i> -->
<button class="action-btn" :class="{ liked: message.isLiked }" @click="toggleLike(message.id)">
<n-icon size="16">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32">
@ -52,21 +63,45 @@
fill="currentColor"></path>
</svg>
</n-icon>
点赞
{{ message.isLiked ? '已赞' : '点赞' }}
<span v-if="message.likeCount && message.likeCount > 0" class="count">({{ message.likeCount }})</span>
</button>
<button class="action-btn delete-btn">
<button class="action-btn" :class="{ favorited: message.isFavorited }"
@click="toggleFavorite(message.id)">
<n-icon size="16">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32">
<path d="M24 4H8a2.002 2.002 0 0 0-2 2v20l6-6h12a2.002 2.002 0 0 0 2-2V6a2.002 2.002 0 0 0-2-2z"
fill="currentColor"></path>
</svg>
</n-icon>
{{ message.isFavorited ? '已收藏' : '收藏' }}
</button>
<button class="action-btn" @click="toggleReply(message.id)">
<n-icon size="16">
<ChatbubbleEllipsesOutline />
</n-icon>
回复
</button>
</div>
<div class="message-actions">
<button class="action-btn delete-btn" @click="deleteMessage(message.id)">
<n-icon size="16">
<TrashOutline />
</n-icon>
删除
</button>
<button class="action-btn delete-btn" @click="reportMessage(message.id)">
<n-icon size="16">
<WarningOutline />
</n-icon>
举报
</button>
</div>
<button class="action-btn delete-btn">
<n-icon size="16">
<WarningOutline />
</n-icon>
举报
</button>
</div>
<!-- 回复输入框 -->
@ -97,17 +132,20 @@
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(totalPages)">尾页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useMessage, NIcon } from 'naive-ui'
import { ChatbubbleEllipsesOutline, TrashOutline, WarningOutline } from '@vicons/ionicons5'
import { CourseApi } from '@/api'
//
interface Message {
id: number
type: number
id: string
type: number // 0=, 1=@
username: string
avatar: string
courseInfo: string
@ -117,54 +155,29 @@ interface Message {
isFavorited: boolean
showReplyBox: boolean
replyContent: string
courseId?: string
userId?: string
images?: string[]
likeCount?: number
}
//
const messages = ref<Message[]>([
{
id: 1,
type: 1,
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '这里是老师留言的内容了',
timestamp: '7月20日',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 2,
type: 0,
username: '叶仲学习分子',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '还好期末考成分学的记忆',
timestamp: '7月20日 12:41',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 3,
type: 1,
username: '课程学习端课学',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '没事多看看课程你就懂了',
timestamp: '7月20日',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
}
const messages = ref<Message[]>([])
const message = useMessage()
const loading = ref(false)
//
const availableCourses = ref<Array<{ id: string, name: string }>>([
{ id: '1', name: '测试课程1' },
{ id: '2', name: '测试课程2' },
{ id: '3', name: '测试课程3' }
])
//
const currentPage = ref(1)
const totalPages = ref(29)
const pageSize = ref(10)
const totalPages = ref(1)
const totalCount = ref(0)
//
const visiblePages = computed(() => {
@ -189,20 +202,90 @@ onMounted(() => {
})
//
const loadMessages = () => {
// TODO: API
}
const loadMessages = async () => {
loading.value = true
try {
//
const allComments: Message[] = []
const toggleFavorite = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message) {
message.isFavorited = !message.isFavorited
for (const course of availableCourses.value) {
try {
const response = await CourseApi.getCourseComments(course.id)
if (response.data && response.data.length > 0) {
//
const courseComments = response.data.map((comment: any) => ({
id: comment.id,
type: 0, //
username: comment.userName || '匿名用户',
avatar: comment.userAvatar || '',
courseInfo: course.name,
content: comment.content || '',
timestamp: comment.timeAgo || comment.createTime,
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: '',
courseId: course.id,
userId: comment.userId,
images: comment.images || [],
likeCount: comment.likeCount || 0
}))
allComments.push(...courseComments)
}
} catch (error) {
console.warn(`获取课程 ${course.name} 评论失败:`, error)
}
}
//
allComments.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
//
const startIndex = (currentPage.value - 1) * pageSize.value
const endIndex = startIndex + pageSize.value
messages.value = allComments.slice(startIndex, endIndex)
//
totalCount.value = allComments.length
totalPages.value = Math.ceil(totalCount.value / pageSize.value)
console.log('✅ 加载评论和@消息成功:', {
total: totalCount.value,
current: messages.value.length,
page: currentPage.value
})
} catch (error) {
console.error('❌ 加载评论和@消息失败:', error)
message.error('加载消息失败')
} finally {
loading.value = false
}
}
const toggleReply = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message) {
const toggleFavorite = (messageId: string) => {
const msg = messages.value.find(m => m.id === messageId)
if (msg) {
msg.isFavorited = !msg.isFavorited
// TODO: API
console.log('切换收藏状态:', messageId, msg.isFavorited)
}
}
const toggleLike = (messageId: string) => {
const msg = messages.value.find(m => m.id === messageId)
if (msg) {
msg.isLiked = !msg.isLiked
msg.likeCount = (msg.likeCount || 0) + (msg.isLiked ? 1 : -1)
// TODO: API
console.log('切换点赞状态:', messageId, msg.isLiked)
}
}
const toggleReply = (messageId: string) => {
const msg = messages.value.find(m => m.id === messageId)
if (msg) {
//
messages.value.forEach(m => {
if (m.id !== messageId) {
@ -212,28 +295,71 @@ const toggleReply = (messageId: number) => {
})
//
message.showReplyBox = !message.showReplyBox
if (message.showReplyBox) {
message.replyContent = ''
msg.showReplyBox = !msg.showReplyBox
if (msg.showReplyBox) {
msg.replyContent = ''
}
}
}
const cancelReply = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message) {
message.showReplyBox = false
message.replyContent = ''
const cancelReply = (messageId: string) => {
const msg = messages.value.find(m => m.id === messageId)
if (msg) {
msg.showReplyBox = false
msg.replyContent = ''
}
}
const sendReply = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message && message.replyContent.trim()) {
// TODO: API
console.log('发送回复:', message.replyContent)
message.showReplyBox = false
message.replyContent = ''
const sendReply = async (messageId: string) => {
const msg = messages.value.find(m => m.id === messageId)
if (msg && msg.replyContent.trim()) {
try {
// TODO: API
console.log('发送回复:', {
commentId: messageId,
content: msg.replyContent,
courseId: msg.courseId
})
//
message.success('回复发送成功')
msg.showReplyBox = false
msg.replyContent = ''
//
await loadMessages()
} catch (error) {
console.error('发送回复失败:', error)
message.error('发送回复失败')
}
}
}
const deleteMessage = async (messageId: string) => {
try {
// TODO: API
console.log('删除评论:', messageId)
//
message.success('删除成功')
//
await loadMessages()
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
}
}
const reportMessage = async (messageId: string) => {
try {
// TODO: API
console.log('举报评论:', messageId)
message.success('举报已提交')
} catch (error) {
console.error('举报失败:', error)
message.error('举报失败')
}
}
@ -243,6 +369,7 @@ const goToPage = (page: number) => {
loadMessages()
}
}
</script>
<style scoped>
@ -338,7 +465,7 @@ const goToPage = (page: number) => {
word-break: break-word;
}
.message-btns{
.message-btns {
display: flex;
justify-content: space-between;
align-items: center;
@ -372,6 +499,82 @@ const goToPage = (page: number) => {
color: #ff4d4f;
}
.action-btn.liked {
color: #ff4d4f;
}
.action-btn.favorited {
color: #1890ff;
}
.count {
font-size: 11px;
margin-left: 2px;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f0f0f0;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-text {
color: #999;
font-size: 14px;
margin: 0;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.empty-icon {
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #666;
margin: 0 0 8px 0;
font-weight: 500;
}
.empty-desc {
font-size: 14px;
color: #999;
margin: 0;
}
.action-btn i {
font-size: 14px;
}

View File

@ -9,26 +9,46 @@
<!-- 联系人列表 -->
<div class="contacts-list">
<div v-for="contact in contacts" :key="contact.id" class="contact-item"
:class="{ active: contact.id === activeContactId, unread: contact.unreadCount > 0 }"
@click="selectContact(contact.id)">
<div class="contact-avatar">
<img :src="contact.avatar" :alt="contact.name" />
<div v-if="contact.type === 'group'" class="group-indicator">
<n-icon size="12" color="#fff">
<PeopleOutline />
</n-icon>
</div>
<!-- 空状态 -->
<div v-if="contacts.length === 0 && !loading" class="contacts-empty">
<div class="empty-content">
<n-icon size="48" color="#d9d9d9">
<ChatbubbleEllipsesOutline />
</n-icon>
<p class="empty-text">暂无会话</p>
<p class="empty-desc">还没有任何聊天会话</p>
</div>
</div>
<div class="contact-info">
<div class="contact-header">
<span class="contact-name">{{ contact.name }}</span>
<span class="contact-time">{{ contact.lastMessageTime }}</span>
<!-- 联系人列表 -->
<div v-else>
<div v-for="contact in contacts" :key="contact.id" class="contact-item"
:class="{ active: contact.id === activeContactId, unread: contact.unreadCount > 0 }"
@click="selectContact(contact.id)">
<div class="contact-avatar">
<img v-if="contact.avatar" :src="contact.avatar" :alt="contact.name" />
<div v-else class="avatar-placeholder">
{{ contact.name.charAt(0) }}
</div>
<div v-if="contact.type === 'group'" class="group-indicator">
<n-icon size="12" color="#fff">
<PeopleOutline />
</n-icon>
</div>
</div>
<div class="contact-preview">
<span class="last-message">{{ contact.lastMessage }}</span>
<n-badge v-if="contact.unreadCount > 0" :value="contact.unreadCount" :max="99" class="unread-badge" />
<div class="contact-info">
<div class="contact-header">
<span class="contact-name">
{{ contact.name }}
<span v-if="contact.type === 'group'" class="member-count">({{ contact.memberCount || 0 }})</span>
</span>
<span class="contact-time">{{ contact.lastMessageTime }}</span>
</div>
<div class="contact-preview">
<span class="last-message">{{ contact.lastMessage }}</span>
<n-badge v-if="contact.unreadCount > 0" :value="contact.unreadCount" :max="99" class="unread-badge" />
</div>
</div>
</div>
</div>
@ -52,7 +72,11 @@
<div></div>
<div class="chat-user-info">
<div class="chat-user-details">
<h4 class="chat-user-name">{{ activeContact?.name }}</h4>
<h4 class="chat-user-name">
{{ activeContact?.name }}
<span v-if="activeContact?.type === 'group'" class="member-count">({{ activeContact?.memberCount || 0
}})</span>
</h4>
</div>
</div>
<div class="chat-actions">
@ -67,44 +91,68 @@
<!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesContainer">
<div class="messages-content">
<div v-for="message in currentMessages" :key="message.id" class="message-wrapper">
<!-- 日期分隔符 -->
<div v-if="message.showDateDivider" class="date-divider">
<span class="date-text">{{ message.dateText }}</span>
<!-- 消息空状态 -->
<div v-if="currentMessages.length === 0 && !messagesLoading" class="messages-empty">
<div class="empty-content">
<n-icon size="48" color="#d9d9d9">
<ChatbubbleEllipsesOutline />
</n-icon>
<p class="empty-text">暂无消息</p>
<p class="empty-desc">开始你们的对话吧</p>
</div>
</div>
<!-- 消息内容 -->
<div class="message-item" :class="{ 'message-own': message.isOwn }">
<div v-if="!message.isOwn" class="message-avatar">
<img :src="message.avatar" :alt="message.senderName" />
<!-- 消息列表 -->
<div v-else>
<div v-for="message in currentMessages" :key="message.id" class="message-wrapper">
<!-- 日期分隔符 -->
<div v-if="message.showDateDivider" class="date-divider">
<span class="date-text">{{ message.dateText }}</span>
</div>
<div class="message-content">
<div v-if="!message.isOwn" class="message-sender">{{ message.senderName }}</div>
<!-- 文本消息 -->
<div v-if="message.type === 'text'" class="message-bubble">
<p class="message-text">{{ message.content }}</p>
</div>
<!-- 图片消息 -->
<div v-else-if="message.type === 'image'" class="message-bubble image-bubble">
<img :src="message.content" class="message-image" @click="previewImage(message.content)" />
</div>
<!-- 文件消息 -->
<div v-else-if="message.type === 'file'" class="message-bubble file-bubble">
<div class="file-info">
<n-icon size="20" color="#1890ff">
<DocumentOutline />
</n-icon>
<div class="file-details">
<span class="file-name">{{ message.fileName }}</span>
<span class="file-size">{{ message.fileSize }}</span>
</div>
<!-- 消息内容 -->
<div class="message-item" :class="{ 'message-own': message.isOwn }">
<div v-if="!message.isOwn" class="message-avatar">
<img v-if="message.avatar" :src="message.avatar" :alt="message.senderName" />
<div v-else class="avatar-placeholder">
{{ message.senderName.charAt(0) }}
</div>
</div>
<div class="message-content">
<div v-if="!message.isOwn" class="message-sender">{{ message.senderName }}</div>
<!-- 文本消息 -->
<div v-if="message.type === 'text'" class="message-bubble">
<p class="message-text">{{ message.content }}</p>
</div>
<!-- 图片消息 -->
<div v-else-if="message.type === 'image'" class="message-bubble image-bubble">
<img :src="message.content" class="message-image" @click="previewImage(message.content)" />
</div>
<!-- 文件消息 -->
<div v-else-if="message.type === 'file'" class="message-bubble file-bubble">
<div class="file-info">
<n-icon size="20" color="#1890ff">
<DocumentOutline />
</n-icon>
<div class="file-details">
<span class="file-name">{{ message.fileName }}</span>
<span class="file-size">{{ message.fileSize }}</span>
</div>
</div>
</div>
<div class="message-time">
{{ message.time }}
<span v-if="message.isOwn" class="read-status"
:class="{ 'read': message.isRead, 'unread': !message.isRead }">
{{ message.isRead ? '已读' : '未读' }}
</span>
</div>
</div>
<div class="message-time">{{ message.time }}</div>
</div>
</div>
</div>
@ -122,7 +170,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { NIcon, NBadge } from 'naive-ui'
import { NIcon, NBadge, useMessage } from 'naive-ui'
import {
EllipsisVertical,
PeopleOutline,
@ -130,10 +178,11 @@ import {
DocumentOutline
} from '@vicons/ionicons5'
import MessageInput from './MessageInput.vue'
import { ChatApi } from '@/api'
//
// API
interface Contact {
id: number
id: string
name: string
avatar: string
type: 'user' | 'group'
@ -144,148 +193,182 @@ interface Contact {
memberCount?: number
}
//
// API
interface Message {
id: number
contactId: number
id: string
contactId: string
type: 'text' | 'image' | 'file'
content: string
senderName: string
avatar: string
time: string
isOwn: boolean
isRead: boolean //
showDateDivider?: boolean
dateText?: string
fileName?: string
fileSize?: string
fileUrl?: string
}
//
const activeContactId = ref<number | null>(null)
const activeContactId = ref<string | null>(null)
const messagesContainer = ref<HTMLElement>()
const messageInputRef = ref()
const message = useMessage()
//
const contacts = ref<Contact[]>([
{
id: 1,
name: '李小多',
avatar: 'https://picsum.photos/40/40?random=1',
type: 'user',
lastMessage: '这里是智慧你人的语法数字和信息',
lastMessageTime: '10:22',
unreadCount: 0,
isOnline: true
},
{
id: 2,
name: '直播学习小组群 (9)',
avatar: 'https://picsum.photos/40/40?random=2',
type: 'group',
lastMessage: '这里新是智慧你人的语法数字和信息',
lastMessageTime: '2024年7月23日',
unreadCount: 0,
memberCount: 9
},
{
id: 3,
name: '王明',
avatar: 'https://picsum.photos/40/40?random=3',
type: 'user',
lastMessage: '好的,我知道了',
lastMessageTime: '昨天',
unreadCount: 2,
isOnline: false
},
{
id: 4,
name: '张老师',
avatar: 'https://picsum.photos/40/40?random=4',
type: 'user',
lastMessage: '明天的课程安排已经发布',
lastMessageTime: '昨天',
unreadCount: 1,
isOnline: true
},
{
id: 5,
name: '陆娜娜',
avatar: 'https://picsum.photos/40/40?random=5',
type: 'user',
lastMessage: '课程资料我已经整理好了',
lastMessageTime: '昨天',
unreadCount: 0,
isOnline: false
},
{
id: 6,
name: '李科度',
avatar: 'https://picsum.photos/40/40?random=6',
type: 'user',
lastMessage: '下次见面详谈',
lastMessageTime: '昨天',
unreadCount: 0,
isOnline: true
},
{
id: 7,
name: '王小滑',
avatar: 'https://picsum.photos/40/40?random=7',
type: 'user',
lastMessage: '收到,谢谢!',
lastMessageTime: '昨天',
unreadCount: 0,
isOnline: false
}
])
//
const loading = ref(false)
const messagesLoading = ref(false)
//
const messages = ref<Message[]>([
{
id: 1,
contactId: 1,
type: 'text',
content: '这里新是智慧你人的语法数字和信息章,多归程回目记录',
senderName: '李小多',
avatar: 'https://picsum.photos/40/40?random=1',
time: '10:22',
isOwn: false,
showDateDivider: true,
dateText: '2024年7月23日'
},
{
id: 2,
contactId: 1,
type: 'text',
content: '收到',
senderName: '我',
avatar: 'https://picsum.photos/40/40?random=me',
time: '10:23',
isOwn: true
},
{
id: 3,
contactId: 2,
type: 'image',
content: 'https://picsum.photos/300/200?random=1',
senderName: '张三',
avatar: 'https://picsum.photos/40/40?random=8',
time: '10:25',
isOwn: false
},
{
id: 4,
contactId: 2,
type: 'file',
content: '',
senderName: '李四',
avatar: 'https://picsum.photos/40/40?random=9',
time: '10:30',
isOwn: false,
fileName: '2025年全家爱词学习人工智能老师考级试卷-点击下载.pptx',
fileSize: '2.5MB'
// API
const contacts = ref<Contact[]>([])
//
const messages = ref<Message[]>([])
//
onMounted(() => {
loadContacts()
})
//
const loadContacts = async () => {
loading.value = true
try {
const response = await ChatApi.getMyChats()
if (response.data && response.data.success) {
// API
contacts.value = response.data.result.map((chat: any) => {
// API0=1=
const contactType = chat.type === 1 ? 'group' : 'user'
return {
id: chat.id,
name: chat.name,
avatar: chat.avatar || '', //
type: contactType,
lastMessage: chat.lastMessage || '暂无消息',
lastMessageTime: formatTime(chat.lastMessageTime || chat.updateTime),
unreadCount: chat.unreadCount || 0,
isOnline: chat.isOnline,
memberCount: chat.memberCount || (contactType === 'group' ? 0 : undefined)
}
})
// memberCount
for (const contact of contacts.value) {
if (contact.type === 'group' && (!contact.memberCount || contact.memberCount === 0)) {
loadGroupMemberCount(contact.id)
}
}
}
} catch (error) {
console.error('获取我的会话失败:', error)
message.error('获取会话列表失败')
contacts.value = []
} finally {
loading.value = false
}
])
}
//
const loadGroupMemberCount = async (chatId: string) => {
try {
const response = await ChatApi.getChatMembers(chatId)
if (response.data && response.data.success && response.data.result) {
const memberCount = response.data.result.length
//
const contact = contacts.value.find(c => c.id === chatId)
if (contact) {
contact.memberCount = memberCount
}
}
} catch (error) {
console.warn('获取群聊成员数量失败:', error)
}
}
//
const loadMessages = async (chatId: string) => {
messagesLoading.value = true
try {
console.log('🚀 开始获取会话消息chatId:', chatId)
const response = await ChatApi.getChatMessages(chatId)
console.log('✅ 会话消息API响应:', response)
if (response.data && response.data.success) {
console.log('📝 消息数据:', response.data.result)
// API
messages.value = response.data.result.map((msg: any): Message => {
console.log('🔍 处理消息:', msg)
console.log('🔍 消息类型:', msg.messageType, '发送者信息:', msg.senderInfo)
// messageType0=1=2=
let messageType = 'text'
if (msg.messageType === 1) {
messageType = 'image'
} else if (msg.messageType === 2) {
messageType = 'file'
}
return {
id: msg.id,
contactId: msg.chatId,
type: messageType as 'text' | 'image' | 'file',
content: msg.content,
senderName: msg.senderInfo?.realname || '未知用户',
avatar: msg.senderInfo?.avatar || '',
time: formatTime(msg.createTime),
isOwn: false, // TODO: ID
isRead: msg.isRead || false, //
fileName: msg.fileName,
fileSize: msg.fileSize,
fileUrl: msg.fileUrl
}
})
console.log('✅ 转换后的消息列表:', messages.value)
} else {
console.warn('⚠️ API返回失败:', response.data)
}
} catch (error) {
console.error('获取会话消息失败:', error)
message.error('获取消息失败')
messages.value = []
} finally {
messagesLoading.value = false
}
}
//
const formatTime = (timeStr: string) => {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
//
if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
//
if (diff < 48 * 60 * 60 * 1000 && date.getDate() === now.getDate() - 1) {
return '昨天'
}
//
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
//
return date.toLocaleDateString('zh-CN')
}
//
const activeContact = computed(() => {
@ -297,46 +380,83 @@ const currentMessages = computed(() => {
})
//
const selectContact = (contactId: number) => {
const selectContact = async (contactId: string) => {
activeContactId.value = contactId
//
const contact = contacts.value.find(c => c.id === contactId)
const contact = contacts.value.find((c: Contact) => c.id === contactId)
if (contact) {
contact.unreadCount = 0
//
if (contact.type === 'group' && (!contact.memberCount || contact.memberCount === 0)) {
loadGroupMemberCount(contactId)
}
}
//
await loadMessages(contactId)
//
try {
await ChatApi.markAsRead(contactId)
} catch (error) {
console.warn('标记消息已读失败:', error)
}
nextTick(() => {
scrollToBottom()
})
}
const handleSendMessage = (content: string) => {
const handleSendMessage = async (content: string) => {
if (!activeContactId.value) return
const newMessage: Message = {
id: Date.now(),
id: Date.now().toString(),
contactId: activeContactId.value,
type: 'text',
content,
senderName: '我',
avatar: 'https://picsum.photos/40/40?random=me',
avatar: '',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
isOwn: true
isOwn: true,
isRead: false //
}
//
messages.value.push(newMessage)
//
const contact = contacts.value.find(c => c.id === activeContactId.value)
const contact = contacts.value.find((c: Contact) => c.id === activeContactId.value)
if (contact) {
contact.lastMessage = content
contact.lastMessageTime = newMessage.time
}
//
try {
await ChatApi.sendMessage({
chatId: activeContactId.value,
content,
messageType: 'text'
})
} catch (error) {
console.error('发送消息失败:', error)
message.error('发送消息失败')
//
const index = messages.value.findIndex(msg => msg.id === newMessage.id)
if (index > -1) {
messages.value.splice(index, 1)
}
}
nextTick(() => {
scrollToBottom()
})
}
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
@ -455,6 +575,20 @@ onMounted(() => {
object-fit: cover;
}
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 16px;
text-transform: uppercase;
}
.group-indicator {
position: absolute;
bottom: -2px;
@ -490,6 +624,13 @@ onMounted(() => {
text-overflow: ellipsis;
}
.member-count {
font-size: 12px;
color: #999;
font-weight: 400;
margin-left: 4px;
}
.contact-time {
font-size: 12px;
color: #999;
@ -515,6 +656,57 @@ onMounted(() => {
flex-shrink: 0;
}
.read-status {
margin-left: 8px;
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
}
.read-status.read {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.read-status.unread {
background: #fff2e8;
color: #fa8c16;
border: 1px solid #ffd591;
}
/* 空状态样式 */
.contacts-empty,
.messages-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
}
.contacts-empty .empty-content,
.messages-empty .empty-content {
text-align: center;
color: #999;
}
.contacts-empty .empty-text,
.messages-empty .empty-text {
font-size: 16px;
font-weight: 500;
margin: 12px 0 8px 0;
color: #666;
}
.contacts-empty .empty-desc,
.messages-empty .empty-desc {
font-size: 14px;
color: #999;
margin: 0;
}
/* 右侧聊天面板 */
.chat-panel {
flex: 1;
@ -574,6 +766,13 @@ onMounted(() => {
color: #333;
}
.chat-user-details .member-count {
font-size: 14px;
color: #999;
font-weight: 400;
margin-left: 6px;
}
.chat-user-status {
font-size: 12px;
color: #999;
@ -638,6 +837,20 @@ onMounted(() => {
object-fit: cover;
}
.message-avatar .avatar-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
}
.message-content {
max-width: 60%;
}
@ -777,4 +990,4 @@ onMounted(() => {
max-width: 75%;
}
}
</style>
</style>

View File

@ -31,9 +31,8 @@
</n-collapse>
</div>
<div class="class-right">
<ClassManagement ref="classManagementRef" :class-id="activeClassId"
:class-name="classList.find(item => item.id === activeClassId)?.name"
@class-changed="handleClassChanged" />
<ClassManagement ref="classManagementRef" type="student" :class-id="activeClassId"
:class-name="classList.find(item => item.id === activeClassId)?.name" @class-changed="handleClassChanged" />
</div>
</div>
</template>
@ -44,10 +43,10 @@ import { CaretForward, EllipsisVertical } from '@vicons/ionicons5'
import { onMounted, ref } from "vue"
import { ClassApi } from '@/api/modules/teachCourse'
const classList = ref<Array<{ id: number; name: string }>>([])
const classList = ref<Array<{ id: string; name: string }>>([])
// ID
const activeClassId = ref<number | null>(1)
const activeClassId = ref<string | null>('1')
// ClassManagement
const classManagementRef = ref<InstanceType<typeof ClassManagement> | null>(null)
@ -77,7 +76,7 @@ const getClassItemOptions = () => [
]
//
const handleClassClick = (classId: number) => {
const handleClassClick = (classId: string) => {
console.log(`🖱️ 用户点击班级: ${classId}, 当前激活班级: ${activeClassId.value}`)
if (activeClassId.value !== classId) {
activeClassId.value = classId
@ -98,7 +97,7 @@ const handleClassMenuSelect = (value: string) => {
}
//
const handleClassItemMenuSelect = (value: string, classId: number) => {
const handleClassItemMenuSelect = (value: string, classId: string) => {
if (!classManagementRef.value) return
const selectedClass = classList.value.find(item => item.id === classId)