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 UploadApi } from './modules/upload'
export { default as StatisticsApi } from './modules/statistics' export { default as StatisticsApi } from './modules/statistics'
export { default as ExamApi } from './modules/exam' export { default as ExamApi } from './modules/exam'
export { ChatApi } from './modules/chat'
// API 基础配置 // API 基础配置
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/jeecgboot' export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/jeecgboot'
@ -222,6 +223,17 @@ export const API_ENDPOINTS = {
MY_COURSES: '/my-courses', 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: { RESOURCES: {
DOWNLOAD: '/resources/:id/download', DOWNLOAD: '/resources/:id/download',

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

View File

@ -4,20 +4,10 @@
<NSpace> <NSpace>
<n-select v-model:value="selectedDepartment" :options="departmentOptions" placeholder="班级名称" <n-select v-model:value="selectedDepartment" :options="departmentOptions" placeholder="班级名称"
style="width: 200px" /> style="width: 200px" />
<n-button <n-button type="info" ghost :disabled="selectedRowKeys.length === 0" @click="handleBatchTransfer">
type="info"
ghost
:disabled="selectedRowKeys.length === 0"
@click="handleBatchTransfer"
>
批量调班({{ selectedRowKeys.length }}) 批量调班({{ selectedRowKeys.length }})
</n-button> </n-button>
<n-button <n-button type="error" ghost :disabled="selectedRowKeys.length === 0" @click="handleBatchDelete">
type="error"
ghost
:disabled="selectedRowKeys.length === 0"
@click="handleBatchDelete"
>
批量移除({{ selectedRowKeys.length }}) 批量移除({{ selectedRowKeys.length }})
</n-button> </n-button>
</NSpace> </NSpace>
@ -38,7 +28,8 @@
</template> </template>
管理班级 管理班级
</n-button> </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> <template #icon>
<NIcon> <NIcon>
<QrCode /> <QrCode />
@ -57,7 +48,8 @@
全部学员 全部学员
</div> </div>
<NSpace> <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 type="primary">
添加学员 添加学员
</n-button> </n-button>
@ -82,23 +74,16 @@
</div> </div>
<n-divider v-if="props.type === 'student'" /> <n-divider v-if="props.type === 'student'" />
<n-data-table <n-data-table :columns="columns" :data="data" :pagination="pagination" :loading="loading"
:columns="columns" :row-key="(row: StudentItem) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered
:data="data" size="small" />
: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-modal v-model:show="showAddClassModal" :title="isRenameMode ? '重命名' : '添加班级'">
<n-card style="width: 500px" :title="isRenameMode ? '重命名' : '添加班级'" :bordered="false" size="huge" role="dialog" aria-modal="true"> <n-card style="width: 500px" :title="isRenameMode ? '重命名' : '添加班级'" :bordered="false" size="huge"
<n-form ref="classFormRef" :model="classFormData" :rules="classRules" label-placement="left" label-width="80px" role="dialog" aria-modal="true">
require-mark-placement="right-hanging"> <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-form-item label="班级名称" path="className">
<n-input v-model:value="classFormData.className" placeholder="请输入班级名称" clearable /> <n-input v-model:value="classFormData.className" placeholder="请输入班级名称" clearable />
</n-form-item> </n-form-item>
@ -136,8 +121,10 @@
<div class="row-item creator-col">{{ classItem.creator }}</div> <div class="row-item creator-col">{{ classItem.creator }}</div>
<div class="row-item time-col">{{ classItem.createTime }}</div> <div class="row-item time-col">{{ classItem.createTime }}</div>
<div class="row-item action-col"> <div class="row-item action-col">
<n-button size="small" type="info" ghost @click="handleRenameClass(classItem)">重命名</n-button> <n-button size="small" type="info" ghost
<n-button size="small" type="error" ghost @click="handleDeleteClass(classItem)">删除</n-button> @click="handleRenameClass(classItem)">重命名</n-button>
<n-button size="small" type="error" ghost
@click="handleDeleteClass(classItem)">删除</n-button>
</div> </div>
</div> </div>
</div> </div>
@ -152,11 +139,7 @@
<p>将选中的 <strong>{{ selectedRowKeys.length }}</strong> 名学员调至以下班级</p> <p>将选中的 <strong>{{ selectedRowKeys.length }}</strong> 名学员调至以下班级</p>
<div class="selected-students"> <div class="selected-students">
<div class="student-list"> <div class="student-list">
<div <div v-for="student in selectedStudents" :key="student.id" class="student-item">
v-for="student in selectedStudents"
:key="student.id"
class="student-item"
>
<span class="student-name">{{ student.studentName }}</span> <span class="student-name">{{ student.studentName }}</span>
<span class="student-account">({{ student.accountNumber }})</span> <span class="student-account">({{ student.accountNumber }})</span>
</div> </div>
@ -166,17 +149,11 @@
<div class="class-selection"> <div class="class-selection">
<div class="selection-title">选择目标班级</div> <div class="selection-title">选择目标班级</div>
<div class="class-list"> <div class="class-list">
<div <div v-for="option in classOptions" :key="option.value" class="class-item"
v-for="option in classOptions"
:key="option.value"
class="class-item"
:class="{ 'selected': selectedTargetClass === option.value }" :class="{ 'selected': selectedTargetClass === option.value }"
@click="selectedTargetClass = option.value" @click="selectedTargetClass = option.value">
> <n-checkbox :checked="selectedTargetClass === option.value"
<n-checkbox @update:checked="() => selectedTargetClass = option.value" />
:checked="selectedTargetClass === option.value"
@update:checked="() => selectedTargetClass = option.value"
/>
<span class="class-name">{{ option.label }}</span> <span class="class-name">{{ option.label }}</span>
</div> </div>
</div> </div>
@ -193,7 +170,8 @@
<!-- 添加/编辑学员弹窗 --> <!-- 添加/编辑学员弹窗 -->
<n-modal v-model:show="showAddModal" :title="isEditMode ? '编辑学员' : '添加学员'"> <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" <n-form ref="formRef" :model="formData" :rules="rules" label-placement="left" label-width="80px"
require-mark-placement="right-hanging"> require-mark-placement="right-hanging">
<n-form-item label="姓名" path="studentName"> <n-form-item label="姓名" path="studentName">
@ -203,30 +181,16 @@
<n-input v-model:value="formData.studentId" placeholder="请输入学员学号" clearable /> <n-input v-model:value="formData.studentId" placeholder="请输入学员学号" clearable />
</n-form-item> </n-form-item>
<n-form-item label="登录密码" path="loginPassword"> <n-form-item label="登录密码" path="loginPassword">
<n-input <n-input v-model:value="formData.loginPassword" type="password"
v-model:value="formData.loginPassword" :placeholder="isEditMode ? '不填写则不修改密码' : '请输入登录密码'" show-password-on="click" clearable />
type="password"
:placeholder="isEditMode ? '不填写则不修改密码' : '请输入登录密码'"
show-password-on="click"
clearable
/>
</n-form-item> </n-form-item>
<n-form-item label="所在学校" path="college"> <n-form-item label="所在学校" path="college">
<n-select <n-select v-model:value="formData.college" :options="collegeOptions" placeholder="请选择学校"
v-model:value="formData.college" clearable />
:options="collegeOptions"
placeholder="请选择学校"
clearable
/>
</n-form-item> </n-form-item>
<n-form-item label="所在班级" path="className"> <n-form-item label="所在班级" path="className">
<n-select <n-select v-model:value="formData.className" :options="classSelectOptions" placeholder="请选择班级"
v-model:value="formData.className" multiple clearable />
:options="classSelectOptions"
placeholder="请选择班级"
multiple
clearable
/>
</n-form-item> </n-form-item>
</n-form> </n-form>
<template #footer> <template #footer>
@ -251,22 +215,13 @@
<div class="class-selection"> <div class="class-selection">
<div class="selection-title">选择目标班级</div> <div class="selection-title">选择目标班级</div>
<div class="class-list"> <div class="class-list">
<div <div v-for="option in classOptions" :key="option.value" class="class-item"
v-for="option in classOptions"
:key="option.value"
class="class-item"
:class="{ 'selected': selectedTargetClass === option.value }" :class="{ 'selected': selectedTargetClass === option.value }"
@click="selectedTargetClass = option.value" @click="selectedTargetClass = option.value">
> <n-checkbox :checked="selectedTargetClass === option.value"
<n-checkbox @update:checked="() => selectedTargetClass = option.value" />
:checked="selectedTargetClass === option.value"
@update:checked="() => selectedTargetClass = option.value"
/>
<span class="class-name">{{ option.label }}</span> <span class="class-name">{{ option.label }}</span>
<span <span v-if="isCurrentClass(option.value)" class="class-desc">当前</span>
v-if="isCurrentClass(option.value)"
class="class-desc"
>当前</span>
</div> </div>
</div> </div>
</div> </div>
@ -287,7 +242,7 @@
<div class="invite-code-display"> <div class="invite-code-display">
<div class="invite-title">班级邀请码</div> <div class="invite-title">班级邀请码</div>
<div class="invite-note" v-if="currentInviteClassId"> <div class="invite-note" v-if="currentInviteClassId">
班级{{ masterClassList.find(item => item.id === currentInviteClassId)?.className || '未知班级' }} 班级{{masterClassList.find(item => item.id === currentInviteClassId)?.className || '未知班级'}}
</div> </div>
<div class="invite-code">{{ inviteCode }}</div> <div class="invite-code">{{ inviteCode }}</div>
<n-button ghost type="primary" @click="copyInviteCode">复制</n-button> <n-button ghost type="primary" @click="copyInviteCode">复制</n-button>
@ -297,18 +252,10 @@
</n-modal> </n-modal>
<!-- 导入学员弹窗 --> <!-- 导入学员弹窗 -->
<ImportModal <ImportModal v-model:show="showImportModal" title="导入学员" :show-radio-options="true" radio-label="导入重复学员信息"
v-model:show="showImportModal" :radio-options="importRadioOptions" radio-field="updateMode" import-type="student"
title="导入学员" template-name="student_import_template.xlsx" @success="handleImportSuccess"
:show-radio-options="true" @template-download="handleTemplateDownload" />
radio-label="导入重复学员信息"
:radio-options="importRadioOptions"
radio-field="updateMode"
import-type="student"
template-name="student_import_template.xlsx"
@success="handleImportSuccess"
@template-download="handleTemplateDownload"
/>
</div> </div>
</template> </template>
@ -342,7 +289,7 @@ import ImportModal from '@/components/common/ImportModal.vue'
// props // props
interface Props { interface Props {
type: 'course' | 'student' type: 'course' | 'student'
classId?: number | null // ID classId?: string | number | null // ID
} }
// props // props
@ -830,8 +777,8 @@ const handleDeleteStudent = (row: StudentItem) => {
return return
} }
// API // API - 使ID
await ClassApi.removeStudent(props.classId.toString(), row.id) await ClassApi.removeStudent(String(props.classId), row.accountNumber)
const studentName = row.studentName const studentName = row.studentName
message.success(`已删除学员:${studentName}`) message.success(`已删除学员:${studentName}`)
@ -1172,18 +1119,15 @@ const loadClassList = async () => {
let loadDataTimer: NodeJS.Timeout | null = null let loadDataTimer: NodeJS.Timeout | null = null
// API // API
const loadData = async (classId?: number | null) => { const loadData = async (classId?: string | number | null) => {
// //
if (loadDataTimer) { if (loadDataTimer) {
clearTimeout(loadDataTimer) clearTimeout(loadDataTimer)
} }
loadDataTimer = setTimeout(async () => { loadDataTimer = setTimeout(async () => {
console.log(`🚀 开始加载班级数据 - classId: ${classId}, 调用栈:`, new Error().stack?.split('\n')[2]?.trim())
// //
if (loading.value) { if (loading.value) {
console.log('⚠️ 数据正在加载中,跳过重复请求')
return return
} }
@ -1193,11 +1137,9 @@ const loadData = async (classId?: number | null) => {
// //
data.value = [] data.value = []
totalStudents.value = 0 totalStudents.value = 0
console.log('📝 未选择班级,显示空数据')
} else { } else {
// API // API
console.log(`📡 正在获取班级 ${classId} 的学生数据...`) const response = await ClassApi.getClassStudents(String(classId))
const response = await ClassApi.getClassStudents(classId.toString())
// API // API
const studentsData = response.data.result || [] const studentsData = response.data.result || []
@ -1219,11 +1161,9 @@ const loadData = async (classId?: number | null) => {
data.value = transformedData data.value = transformedData
totalStudents.value = transformedData.length totalStudents.value = transformedData.length
console.log(`✅ 成功加载班级 ${classId} 的数据,共 ${transformedData.length} 名学员`)
} }
} catch (error) { } catch (error) {
console.error('加载班级学生数据失败:', error) console.error('加载班级学生数据失败:', error)
message.error('加载学生数据失败,请重试') message.error('加载学生数据失败,请重试')
data.value = [] data.value = []
totalStudents.value = 0 totalStudents.value = 0
@ -1254,10 +1194,9 @@ const handleTemplateDownload = (type?: string) => {
watch( watch(
() => props.classId, () => props.classId,
(newClassId, oldClassId) => { (newClassId, oldClassId) => {
console.log(`班级ID从 ${oldClassId} 变更为 ${newClassId}`)
if (newClassId !== oldClassId) { if (newClassId !== oldClassId) {
// watch // watch
selectedDepartment.value = newClassId ? newClassId.toString() : '' selectedDepartment.value = newClassId ? String(newClassId) : ''
loadData(newClassId) loadData(newClassId)
} }
}, },
@ -1269,12 +1208,11 @@ watch(
watch( watch(
() => selectedDepartment.value, () => selectedDepartment.value,
(newDepartmentId, oldDepartmentId) => { (newDepartmentId, oldDepartmentId) => {
console.log(`选择的班级从 ${oldDepartmentId} 变更为 ${newDepartmentId}`)
// props.classId // props.classId
// props.classIdprops // props.classIdprops
const currentPropsClassId = props.classId?.toString() const currentPropsClassId = props.classId ? String(props.classId) : ''
if (newDepartmentId !== oldDepartmentId && newDepartmentId !== currentPropsClassId) { if (newDepartmentId !== oldDepartmentId && newDepartmentId !== currentPropsClassId) {
const targetClassId = newDepartmentId ? Number(newDepartmentId) : null const targetClassId = newDepartmentId || null
loadData(targetClassId) loadData(targetClassId)
} }
}, },
@ -1283,7 +1221,6 @@ watch(
const loadSchoolList = () => { const loadSchoolList = () => {
TeachCourseApi.getSchoolList().then(res => { TeachCourseApi.getSchoolList().then(res => {
console.log('获取学校列表:', res)
collegeOptions.value = res.data.result.map((school: any) => ({ collegeOptions.value = res.data.result.map((school: any) => ({
label: school, label: school,
value: school value: school
@ -1299,13 +1236,11 @@ onMounted(async () => {
loadSchoolList() loadSchoolList()
// 使使classId使 // 使使classId使
const initialClassId = props.classId ? props.classId : Number(selectedDepartment.value) const initialClassId = props.classId ? props.classId : selectedDepartment.value
loadData(initialClassId) loadData(initialClassId)
// id id // id id
if(route.path.includes('/teacher/course-editor')){ if (route.path.includes('/teacher/course-editor')) {
console.log('当前路由路径:', route.path)
console.log('课程ID:', router.currentRoute.value.params.id)
courseId.value = router.currentRoute.value.params.id.toString() courseId.value = router.currentRoute.value.params.id.toString()
} }
}) })

View File

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

View File

@ -162,7 +162,12 @@
<div class="section"> <div class="section">
<h3 class="section-title">学员信息</h3> <h3 class="section-title">学员信息</h3>
<div class="info-fields"> <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> <div class="field">班级名称</div>
<div class="field">自定义文字</div> <div class="field">自定义文字</div>
@ -173,11 +178,37 @@
<div class="section"> <div class="section">
<h3 class="section-title">考试信息</h3> <h3 class="section-title">考试信息</h3>
<div class="info-fields"> <div class="info-fields">
<div class="field">考试名称</div> <div class="field editable-field" @mouseenter="startEdit('examName')" @mouseleave="stopEdit('examName')">
<div class="field">考试分数</div> <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 class="field">考试评语</div>
</div> </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> </div>
<!-- 收起/展开按钮 --> <!-- 收起/展开按钮 -->
@ -189,7 +220,26 @@
<div class="certificate-content" :class="{ 'preview-mode': isPreviewMode }" <div class="certificate-content" :class="{ 'preview-mode': isPreviewMode }"
:style="{ backgroundColor: selectedColor }"> :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> </div>
@ -238,6 +288,20 @@ const validityDuration = ref(60)
const validityEndDate = ref('2000-10-11T09:00') const validityEndDate = ref('2000-10-11T09:00')
const currentValidityText = ref('永久有效') 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 = () => { const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value 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; opacity: 0;
transform: scale(0.8); transform: scale(0.8);
} }
.top-section { .top-section {
width: 100%; width: 100%;
background-color: #fff; background-color: #fff;
@ -750,6 +825,7 @@ onUnmounted(() => {
.btn-confirm:hover { .btn-confirm:hover {
background: #0277BD; background: #0277BD;
} }
.right-section { .right-section {
position: absolute; position: absolute;
top: 0; top: 0;
@ -768,6 +844,7 @@ onUnmounted(() => {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
} }
.collapse-button { .collapse-button {
position: absolute; position: absolute;
left: -40px; left: -40px;
@ -998,6 +1075,28 @@ onUnmounted(() => {
background: #E8E8E8; 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 { .color-input-inline {
width: 100%; width: 100%;
@ -1025,8 +1124,77 @@ onUnmounted(() => {
align-items: center; align-items: center;
} }
.certificate-content img { .certificate-wrapper {
width: 800px; 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> </style>

View File

@ -65,7 +65,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' 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' import NotificationMessages from './components/NotificationMessages.vue'
@ -75,13 +76,17 @@ import SystemMessages from './components/SystemMessages.vue'
// //
const activeTab = ref('notification') // tab const activeTab = ref('notification') // tab
const message = useMessage()
// //
const notificationCount = ref(5) // const notificationCount = ref(0) //
const commentCount = ref(3) // @ const commentCount = ref(0) // @
const favoriteCount = ref(0) // const favoriteCount = ref(0) //
const systemCount = ref(0) // const systemCount = ref(0) //
//
const loading = ref(false)
// //
onMounted(() => { onMounted(() => {
// //
@ -89,9 +94,48 @@ onMounted(() => {
}) })
// //
const loadMessageCounts = () => { const loadMessageCounts = async () => {
// TODO: API 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> </script>

View File

@ -1,7 +1,25 @@
<template> <template>
<div class="message-center"> <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 v-for="message in messages" :key="message.id" class="message-item">
<!-- 用户头像 --> <!-- 用户头像 -->
<div class="avatar-container"> <div class="avatar-container">
@ -36,14 +54,7 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="message-btns"> <div class="message-btns">
<div class="message-actions"> <div class="message-actions">
<button class="action-btn" @click="toggleReply(message.id)"> <button class="action-btn" :class="{ liked: message.isLiked }" @click="toggleLike(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> -->
<n-icon size="16"> <n-icon size="16">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32"> viewBox="0 0 32 32">
@ -52,22 +63,46 @@
fill="currentColor"></path> fill="currentColor"></path>
</svg> </svg>
</n-icon> </n-icon>
点赞 {{ message.isLiked ? '已赞' : '点赞' }}
<span v-if="message.likeCount && message.likeCount > 0" class="count">({{ message.likeCount }})</span>
</button> </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"> <n-icon size="16">
<TrashOutline /> <TrashOutline />
</n-icon> </n-icon>
删除 删除
</button> </button>
</div>
<button class="action-btn delete-btn"> <button class="action-btn delete-btn" @click="reportMessage(message.id)">
<n-icon size="16"> <n-icon size="16">
<WarningOutline /> <WarningOutline />
</n-icon> </n-icon>
举报 举报
</button> </button>
</div> </div>
</div>
<!-- 回复输入框 --> <!-- 回复输入框 -->
<div v-if="message.showReplyBox" class="reply-box"> <div v-if="message.showReplyBox" class="reply-box">
@ -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(currentPage + 1)">下一页</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(totalPages)">尾页</button> <button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(totalPages)">尾页</button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useMessage, NIcon } from 'naive-ui'
import { ChatbubbleEllipsesOutline, TrashOutline, WarningOutline } from '@vicons/ionicons5' import { ChatbubbleEllipsesOutline, TrashOutline, WarningOutline } from '@vicons/ionicons5'
import { CourseApi } from '@/api'
// //
interface Message { interface Message {
id: number id: string
type: number type: number // 0=, 1=@
username: string username: string
avatar: string avatar: string
courseInfo: string courseInfo: string
@ -117,54 +155,29 @@ interface Message {
isFavorited: boolean isFavorited: boolean
showReplyBox: boolean showReplyBox: boolean
replyContent: string replyContent: string
courseId?: string
userId?: string
images?: string[]
likeCount?: number
} }
// //
const messages = ref<Message[]>([ const messages = ref<Message[]>([])
{ const message = useMessage()
id: 1, const loading = ref(false)
type: 1,
username: '王建华化学老师', //
avatar: 'https://picsum.photos/200/200', const availableCourses = ref<Array<{ id: string, name: string }>>([
courseInfo: '《教师小学期制实验》', { id: '1', name: '测试课程1' },
content: '这里是老师留言的内容了', { id: '2', name: '测试课程2' },
timestamp: '7月20日', { id: '3', name: '测试课程3' }
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 currentPage = ref(1) const currentPage = ref(1)
const totalPages = ref(29) const pageSize = ref(10)
const totalPages = ref(1)
const totalCount = ref(0)
// //
const visiblePages = computed(() => { const visiblePages = computed(() => {
@ -189,20 +202,90 @@ onMounted(() => {
}) })
// //
const loadMessages = () => { const loadMessages = async () => {
// TODO: API loading.value = true
} try {
//
const allComments: Message[] = []
const toggleFavorite = (messageId: number) => { for (const course of availableCourses.value) {
const message = messages.value.find(m => m.id === messageId) try {
if (message) { const response = await CourseApi.getCourseComments(course.id)
message.isFavorited = !message.isFavorited 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 toggleFavorite = (messageId: string) => {
const message = messages.value.find(m => m.id === messageId) const msg = messages.value.find(m => m.id === messageId)
if (message) { 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 => { messages.value.forEach(m => {
if (m.id !== messageId) { if (m.id !== messageId) {
@ -212,28 +295,71 @@ const toggleReply = (messageId: number) => {
}) })
// //
message.showReplyBox = !message.showReplyBox msg.showReplyBox = !msg.showReplyBox
if (message.showReplyBox) { if (msg.showReplyBox) {
message.replyContent = '' msg.replyContent = ''
} }
} }
} }
const cancelReply = (messageId: number) => { const cancelReply = (messageId: string) => {
const message = messages.value.find(m => m.id === messageId) const msg = messages.value.find(m => m.id === messageId)
if (message) { if (msg) {
message.showReplyBox = false msg.showReplyBox = false
message.replyContent = '' msg.replyContent = ''
} }
} }
const sendReply = (messageId: number) => { const sendReply = async (messageId: string) => {
const message = messages.value.find(m => m.id === messageId) const msg = messages.value.find(m => m.id === messageId)
if (message && message.replyContent.trim()) { if (msg && msg.replyContent.trim()) {
try {
// TODO: API // TODO: API
console.log('发送回复:', message.replyContent) console.log('发送回复:', {
message.showReplyBox = false commentId: messageId,
message.replyContent = '' 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() loadMessages()
} }
} }
</script> </script>
<style scoped> <style scoped>
@ -338,7 +465,7 @@ const goToPage = (page: number) => {
word-break: break-word; word-break: break-word;
} }
.message-btns{ .message-btns {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -372,6 +499,82 @@ const goToPage = (page: number) => {
color: #ff4d4f; 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 { .action-btn i {
font-size: 14px; font-size: 14px;
} }

View File

@ -9,11 +9,27 @@
<!-- 联系人列表 --> <!-- 联系人列表 -->
<div class="contacts-list"> <div class="contacts-list">
<!-- 空状态 -->
<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 v-else>
<div v-for="contact in contacts" :key="contact.id" class="contact-item" <div v-for="contact in contacts" :key="contact.id" class="contact-item"
:class="{ active: contact.id === activeContactId, unread: contact.unreadCount > 0 }" :class="{ active: contact.id === activeContactId, unread: contact.unreadCount > 0 }"
@click="selectContact(contact.id)"> @click="selectContact(contact.id)">
<div class="contact-avatar"> <div class="contact-avatar">
<img :src="contact.avatar" :alt="contact.name" /> <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"> <div v-if="contact.type === 'group'" class="group-indicator">
<n-icon size="12" color="#fff"> <n-icon size="12" color="#fff">
<PeopleOutline /> <PeopleOutline />
@ -23,7 +39,10 @@
<div class="contact-info"> <div class="contact-info">
<div class="contact-header"> <div class="contact-header">
<span class="contact-name">{{ contact.name }}</span> <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> <span class="contact-time">{{ contact.lastMessageTime }}</span>
</div> </div>
<div class="contact-preview"> <div class="contact-preview">
@ -34,6 +53,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 右侧聊天区域 --> <!-- 右侧聊天区域 -->
<div class="chat-panel"> <div class="chat-panel">
@ -52,7 +72,11 @@
<div></div> <div></div>
<div class="chat-user-info"> <div class="chat-user-info">
<div class="chat-user-details"> <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> </div>
<div class="chat-actions"> <div class="chat-actions">
@ -67,6 +91,19 @@
<!-- 聊天消息区域 --> <!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesContainer"> <div class="chat-messages" ref="messagesContainer">
<div class="messages-content"> <div class="messages-content">
<!-- 消息空状态 -->
<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 v-else>
<div v-for="message in currentMessages" :key="message.id" class="message-wrapper"> <div v-for="message in currentMessages" :key="message.id" class="message-wrapper">
<!-- 日期分隔符 --> <!-- 日期分隔符 -->
<div v-if="message.showDateDivider" class="date-divider"> <div v-if="message.showDateDivider" class="date-divider">
@ -76,7 +113,10 @@
<!-- 消息内容 --> <!-- 消息内容 -->
<div class="message-item" :class="{ 'message-own': message.isOwn }"> <div class="message-item" :class="{ 'message-own': message.isOwn }">
<div v-if="!message.isOwn" class="message-avatar"> <div v-if="!message.isOwn" class="message-avatar">
<img :src="message.avatar" :alt="message.senderName" /> <img v-if="message.avatar" :src="message.avatar" :alt="message.senderName" />
<div v-else class="avatar-placeholder">
{{ message.senderName.charAt(0) }}
</div>
</div> </div>
<div class="message-content"> <div class="message-content">
@ -104,7 +144,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="message-time">{{ message.time }}</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> </div>
</div> </div>
</div> </div>
@ -122,7 +170,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import { NIcon, NBadge } from 'naive-ui' import { NIcon, NBadge, useMessage } from 'naive-ui'
import { import {
EllipsisVertical, EllipsisVertical,
PeopleOutline, PeopleOutline,
@ -130,10 +178,11 @@ import {
DocumentOutline DocumentOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import MessageInput from './MessageInput.vue' import MessageInput from './MessageInput.vue'
import { ChatApi } from '@/api'
// // API
interface Contact { interface Contact {
id: number id: string
name: string name: string
avatar: string avatar: string
type: 'user' | 'group' type: 'user' | 'group'
@ -144,148 +193,182 @@ interface Contact {
memberCount?: number memberCount?: number
} }
// // API
interface Message { interface Message {
id: number id: string
contactId: number contactId: string
type: 'text' | 'image' | 'file' type: 'text' | 'image' | 'file'
content: string content: string
senderName: string senderName: string
avatar: string avatar: string
time: string time: string
isOwn: boolean isOwn: boolean
isRead: boolean //
showDateDivider?: boolean showDateDivider?: boolean
dateText?: string dateText?: string
fileName?: string fileName?: string
fileSize?: string fileSize?: string
fileUrl?: string
} }
// //
const activeContactId = ref<number | null>(null) const activeContactId = ref<string | null>(null)
const messagesContainer = ref<HTMLElement>() const messagesContainer = ref<HTMLElement>()
const messageInputRef = ref() const messageInputRef = ref()
const message = useMessage()
// //
const contacts = ref<Contact[]>([ const loading = ref(false)
{ const messagesLoading = ref(false)
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
}
])
// // API
const messages = ref<Message[]>([ const contacts = ref<Contact[]>([])
{
id: 1, //
contactId: 1, const messages = ref<Message[]>([])
type: 'text',
content: '这里新是智慧你人的语法数字和信息章,多归程回目记录', //
senderName: '李小多', onMounted(() => {
avatar: 'https://picsum.photos/40/40?random=1', loadContacts()
time: '10:22', })
isOwn: false,
showDateDivider: true, //
dateText: '2024年7月23日' const loadContacts = async () => {
}, loading.value = true
{ try {
id: 2, const response = await ChatApi.getMyChats()
contactId: 1, if (response.data && response.data.success) {
type: 'text', // API
content: '收到', contacts.value = response.data.result.map((chat: any) => {
senderName: '我', // API0=1=
avatar: 'https://picsum.photos/40/40?random=me', const contactType = chat.type === 1 ? 'group' : 'user'
time: '10:23',
isOwn: true return {
}, id: chat.id,
{ name: chat.name,
id: 3, avatar: chat.avatar || '', //
contactId: 2, type: contactType,
type: 'image', lastMessage: chat.lastMessage || '暂无消息',
content: 'https://picsum.photos/300/200?random=1', lastMessageTime: formatTime(chat.lastMessageTime || chat.updateTime),
senderName: '张三', unreadCount: chat.unreadCount || 0,
avatar: 'https://picsum.photos/40/40?random=8', isOnline: chat.isOnline,
time: '10:25', memberCount: chat.memberCount || (contactType === 'group' ? 0 : undefined)
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'
} }
]) })
// 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(() => { const activeContact = computed(() => {
@ -297,46 +380,83 @@ const currentMessages = computed(() => {
}) })
// //
const selectContact = (contactId: number) => { const selectContact = async (contactId: string) => {
activeContactId.value = contactId activeContactId.value = contactId
// //
const contact = contacts.value.find(c => c.id === contactId) const contact = contacts.value.find((c: Contact) => c.id === contactId)
if (contact) { if (contact) {
contact.unreadCount = 0 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(() => { nextTick(() => {
scrollToBottom() scrollToBottom()
}) })
} }
const handleSendMessage = (content: string) => { const handleSendMessage = async (content: string) => {
if (!activeContactId.value) return if (!activeContactId.value) return
const newMessage: Message = { const newMessage: Message = {
id: Date.now(), id: Date.now().toString(),
contactId: activeContactId.value, contactId: activeContactId.value,
type: 'text', type: 'text',
content, content,
senderName: '我', senderName: '我',
avatar: 'https://picsum.photos/40/40?random=me', avatar: '',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
isOwn: true isOwn: true,
isRead: false //
} }
//
messages.value.push(newMessage) 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) { if (contact) {
contact.lastMessage = content contact.lastMessage = content
contact.lastMessageTime = newMessage.time 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(() => { nextTick(() => {
scrollToBottom() scrollToBottom()
}) })
} }
const scrollToBottom = () => { const scrollToBottom = () => {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
@ -455,6 +575,20 @@ onMounted(() => {
object-fit: cover; 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 { .group-indicator {
position: absolute; position: absolute;
bottom: -2px; bottom: -2px;
@ -490,6 +624,13 @@ onMounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.member-count {
font-size: 12px;
color: #999;
font-weight: 400;
margin-left: 4px;
}
.contact-time { .contact-time {
font-size: 12px; font-size: 12px;
color: #999; color: #999;
@ -515,6 +656,57 @@ onMounted(() => {
flex-shrink: 0; 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 { .chat-panel {
flex: 1; flex: 1;
@ -574,6 +766,13 @@ onMounted(() => {
color: #333; color: #333;
} }
.chat-user-details .member-count {
font-size: 14px;
color: #999;
font-weight: 400;
margin-left: 6px;
}
.chat-user-status { .chat-user-status {
font-size: 12px; font-size: 12px;
color: #999; color: #999;
@ -638,6 +837,20 @@ onMounted(() => {
object-fit: cover; 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 { .message-content {
max-width: 60%; max-width: 60%;
} }

View File

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