feat: 对接我的会话,查询会话消息,查询群聊成员列表,查询课程评论接口;添加会话消息空状态样式,添加证书中心内容样式,修复班级管理组件删除学员功能
This commit is contained in:
parent
ad2ec33c6a
commit
ef49c7b6d3
@ -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'
|
||||||
@ -221,6 +222,17 @@ export const API_ENDPOINTS = {
|
|||||||
PROGRESS: '/learning-progress',
|
PROGRESS: '/learning-progress',
|
||||||
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: {
|
||||||
|
177
src/api/modules/chat.ts
Normal file
177
src/api/modules/chat.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -3,21 +3,11 @@
|
|||||||
<div class="toolbar" v-if="props.type === 'course'">
|
<div class="toolbar" v-if="props.type === 'course'">
|
||||||
<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>
|
||||||
@ -296,19 +251,11 @@
|
|||||||
</n-card>
|
</n-card>
|
||||||
</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
|
||||||
@ -447,12 +394,12 @@ const rules: FormRules = {
|
|||||||
{ required: true, message: '请选择所在学院', trigger: 'blur' }
|
{ required: true, message: '请选择所在学院', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
className: [
|
className: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
min: 1,
|
min: 1,
|
||||||
message: '请选择至少一个班级',
|
message: '请选择至少一个班级',
|
||||||
trigger: 'blur'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -482,7 +429,7 @@ const addStudentOptions = [
|
|||||||
key: 'manual'
|
key: 'manual'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '学员库添加',
|
label: '学员库添加',
|
||||||
key: 'library'
|
key: 'library'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -504,7 +451,7 @@ const departmentOptions = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 班级选项(用于调班弹窗)
|
// 班级选项(用于调班弹窗)
|
||||||
const classOptions = computed(() =>
|
const classOptions = computed(() =>
|
||||||
masterClassList.value.map(item => ({
|
masterClassList.value.map(item => ({
|
||||||
label: item.className,
|
label: item.className,
|
||||||
value: item.id
|
value: item.id
|
||||||
@ -512,7 +459,7 @@ const classOptions = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 班级选择器选项(用于表单中的班级选择)
|
// 班级选择器选项(用于表单中的班级选择)
|
||||||
const classSelectOptions = computed(() =>
|
const classSelectOptions = computed(() =>
|
||||||
masterClassList.value.map(item => ({
|
masterClassList.value.map(item => ({
|
||||||
label: item.className,
|
label: item.className,
|
||||||
value: item.id
|
value: item.id
|
||||||
@ -550,8 +497,8 @@ const columns: DataTableColumns<StudentItem> = [
|
|||||||
// 渲染班级名称,支持多行显示
|
// 渲染班级名称,支持多行显示
|
||||||
return h('div', {
|
return h('div', {
|
||||||
class: 'class-cell'
|
class: 'class-cell'
|
||||||
}, classNames.map((name, index) =>
|
}, classNames.map((name, index) =>
|
||||||
h('div', {
|
h('div', {
|
||||||
key: index,
|
key: index,
|
||||||
class: 'class-cell-item'
|
class: 'class-cell-item'
|
||||||
}, name)
|
}, name)
|
||||||
@ -687,7 +634,7 @@ const handleTransfer = (row: StudentItem) => {
|
|||||||
currentTransferStudent.value = row
|
currentTransferStudent.value = row
|
||||||
selectedTargetClass.value = ''
|
selectedTargetClass.value = ''
|
||||||
showTransferModal.value = true
|
showTransferModal.value = true
|
||||||
|
|
||||||
console.log('打开调班弹窗:', {
|
console.log('打开调班弹窗:', {
|
||||||
学员信息: row,
|
学员信息: row,
|
||||||
可选班级: classOptions.value
|
可选班级: classOptions.value
|
||||||
@ -714,10 +661,10 @@ const handleBatchDelete = () => {
|
|||||||
message.warning('请先选择要移除的学员')
|
message.warning('请先选择要移除的学员')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedStudentsList = selectedStudents.value
|
const selectedStudentsList = selectedStudents.value
|
||||||
const studentNames = selectedStudentsList.map(s => s.studentName).join('、')
|
const studentNames = selectedStudentsList.map(s => s.studentName).join('、')
|
||||||
|
|
||||||
dialog.info({
|
dialog.info({
|
||||||
title: '批量移除确认',
|
title: '批量移除确认',
|
||||||
content: `确定要移除选中的 ${selectedRowKeys.value.length} 名学员吗?\n\n学员名单:${studentNames}\n\n移除后这些学员将无法访问班级资源!`,
|
content: `确定要移除选中的 ${selectedRowKeys.value.length} 名学员吗?\n\n学员名单:${studentNames}\n\n移除后这些学员将无法访问班级资源!`,
|
||||||
@ -727,17 +674,17 @@ const handleBatchDelete = () => {
|
|||||||
try {
|
try {
|
||||||
// 这里模拟 API 调用
|
// 这里模拟 API 调用
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
const removedCount = selectedRowKeys.value.length
|
const removedCount = selectedRowKeys.value.length
|
||||||
|
|
||||||
// 从数据中移除选中的学员
|
// 从数据中移除选中的学员
|
||||||
data.value = data.value.filter(student => !selectedRowKeys.value.includes(student.id))
|
data.value = data.value.filter(student => !selectedRowKeys.value.includes(student.id))
|
||||||
|
|
||||||
// 清空选中状态
|
// 清空选中状态
|
||||||
selectedRowKeys.value = []
|
selectedRowKeys.value = []
|
||||||
|
|
||||||
message.success(`成功移除 ${removedCount} 名学员`)
|
message.success(`成功移除 ${removedCount} 名学员`)
|
||||||
|
|
||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
loadData(props.classId)
|
loadData(props.classId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -772,17 +719,17 @@ const confirmBatchTransfer = async () => {
|
|||||||
try {
|
try {
|
||||||
// 这里模拟 API 调用
|
// 这里模拟 API 调用
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
const transferCount = selectedRowKeys.value.length
|
const transferCount = selectedRowKeys.value.length
|
||||||
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
|
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
|
||||||
|
|
||||||
message.success(`已将 ${transferCount} 名学员调至 ${targetClassName}`)
|
message.success(`已将 ${transferCount} 名学员调至 ${targetClassName}`)
|
||||||
|
|
||||||
// 关闭弹窗并重置状态
|
// 关闭弹窗并重置状态
|
||||||
showBatchTransferModal.value = false
|
showBatchTransferModal.value = false
|
||||||
selectedTargetClass.value = ''
|
selectedTargetClass.value = ''
|
||||||
selectedRowKeys.value = []
|
selectedRowKeys.value = []
|
||||||
|
|
||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
loadData(props.classId)
|
loadData(props.classId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -829,13 +776,13 @@ const handleDeleteStudent = (row: StudentItem) => {
|
|||||||
message.error('班级ID不存在,无法删除学员')
|
message.error('班级ID不存在,无法删除学员')
|
||||||
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}`)
|
||||||
|
|
||||||
// 重新加载数据以确保数据同步
|
// 重新加载数据以确保数据同步
|
||||||
loadData(props.classId)
|
loadData(props.classId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -866,15 +813,15 @@ const handleConfirmTransfer = async () => {
|
|||||||
try {
|
try {
|
||||||
// 这里模拟 API 调用
|
// 这里模拟 API 调用
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
|
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
|
||||||
message.success(`已将学员 ${currentTransferStudent.value.studentName} 调至 ${targetClassName}`)
|
message.success(`已将学员 ${currentTransferStudent.value.studentName} 调至 ${targetClassName}`)
|
||||||
|
|
||||||
// 关闭弹窗并重置状态
|
// 关闭弹窗并重置状态
|
||||||
showTransferModal.value = false
|
showTransferModal.value = false
|
||||||
currentTransferStudent.value = null
|
currentTransferStudent.value = null
|
||||||
selectedTargetClass.value = ''
|
selectedTargetClass.value = ''
|
||||||
|
|
||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
loadData(props.classId)
|
loadData(props.classId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -888,11 +835,11 @@ const isCurrentClass = (classValue: string) => {
|
|||||||
console.log('调班判断: 未选中学员')
|
console.log('调班判断: 未选中学员')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据学员的className匹配班级value
|
// 根据学员的className匹配班级value
|
||||||
const studentClassName = currentTransferStudent.value.className
|
const studentClassName = currentTransferStudent.value.className
|
||||||
const classOption = masterClassList.value.find(item => item.className === studentClassName)
|
const classOption = masterClassList.value.find(item => item.className === studentClassName)
|
||||||
|
|
||||||
console.log('调班判断:', {
|
console.log('调班判断:', {
|
||||||
学员姓名: currentTransferStudent.value.studentName,
|
学员姓名: currentTransferStudent.value.studentName,
|
||||||
学员班级: studentClassName,
|
学员班级: studentClassName,
|
||||||
@ -900,7 +847,7 @@ const isCurrentClass = (classValue: string) => {
|
|||||||
找到的班级: classOption,
|
找到的班级: classOption,
|
||||||
是否匹配: classOption?.id === classValue
|
是否匹配: classOption?.id === classValue
|
||||||
})
|
})
|
||||||
|
|
||||||
return classOption?.id === classValue
|
return classOption?.id === classValue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -916,9 +863,9 @@ const getClassNameById = (classId: string): string => {
|
|||||||
|
|
||||||
// 处理多班级显示的辅助函数
|
// 处理多班级显示的辅助函数
|
||||||
const formatClassNames = (classInfo: string): string[] => {
|
const formatClassNames = (classInfo: string): string[] => {
|
||||||
|
|
||||||
if (!classInfo) return ['未分配班级']
|
if (!classInfo) return ['未分配班级']
|
||||||
|
|
||||||
if (classInfo.includes(',')) {
|
if (classInfo.includes(',')) {
|
||||||
// 多个班级,用逗号分隔
|
// 多个班级,用逗号分隔
|
||||||
return classInfo.split(',').map(id => id.trim()).map(getClassNameById)
|
return classInfo.split(',').map(id => id.trim()).map(getClassNameById)
|
||||||
@ -941,7 +888,7 @@ const openInviteModal = (classId: string) => {
|
|||||||
currentInviteClassId.value = classId
|
currentInviteClassId.value = classId
|
||||||
inviteCode.value = generateInviteCode(classId)
|
inviteCode.value = generateInviteCode(classId)
|
||||||
showInviteModal.value = true
|
showInviteModal.value = true
|
||||||
|
|
||||||
console.log('打开邀请码弹窗:', {
|
console.log('打开邀请码弹窗:', {
|
||||||
班级ID: classId,
|
班级ID: classId,
|
||||||
邀请码: inviteCode.value
|
邀请码: inviteCode.value
|
||||||
@ -959,7 +906,7 @@ const copyInviteCode = () => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
// 编辑模式暂不实现
|
// 编辑模式暂不实现
|
||||||
message.info('编辑功能暂未实现,敬请期待')
|
message.info('编辑功能暂未实现,敬请期待')
|
||||||
@ -969,13 +916,13 @@ const handleSubmit = async () => {
|
|||||||
console.log('🚀 开始新增学员...')
|
console.log('🚀 开始新增学员...')
|
||||||
console.log('表单数据:', formData.value)
|
console.log('表单数据:', formData.value)
|
||||||
console.log('当前班级ID:', props.classId)
|
console.log('当前班级ID:', props.classId)
|
||||||
|
|
||||||
// 验证必要参数
|
// 验证必要参数
|
||||||
if (!formData.value.className || formData.value.className.length === 0) {
|
if (!formData.value.className || formData.value.className.length === 0) {
|
||||||
message.error('请选择班级')
|
message.error('请选择班级')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建API请求参数,根据接口文档字段映射
|
// 构建API请求参数,根据接口文档字段映射
|
||||||
const payload = {
|
const payload = {
|
||||||
realName: formData.value.studentName,
|
realName: formData.value.studentName,
|
||||||
@ -984,14 +931,14 @@ const handleSubmit = async () => {
|
|||||||
school: formData.value.college,
|
school: formData.value.college,
|
||||||
classId: formData.value.className.join(',')
|
classId: formData.value.className.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📝 API请求参数:', payload)
|
console.log('📝 API请求参数:', payload)
|
||||||
|
|
||||||
// 调用创建学员API
|
// 调用创建学员API
|
||||||
const response = await ClassApi.createdStudents(payload)
|
const response = await ClassApi.createdStudents(payload)
|
||||||
|
|
||||||
console.log('✅ 创建学员响应:', response)
|
console.log('✅ 创建学员响应:', response)
|
||||||
|
|
||||||
if (response.data && (response.data.success || response.data.code === 200)) {
|
if (response.data && (response.data.success || response.data.code === 200)) {
|
||||||
message.success(`已成功添加学员 ${formData.value.studentName}`)
|
message.success(`已成功添加学员 ${formData.value.studentName}`)
|
||||||
} else {
|
} else {
|
||||||
@ -999,11 +946,11 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗并重置表单
|
// 关闭弹窗并重置表单
|
||||||
showAddModal.value = false
|
showAddModal.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
|
|
||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
loadData(props.classId)
|
loadData(props.classId)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -1085,7 +1032,7 @@ const handleAddClass = async () => {
|
|||||||
// 通知父组件更新列表
|
// 通知父组件更新列表
|
||||||
emit('class-changed')
|
emit('class-changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载班级列表数据
|
// 重新加载班级列表数据
|
||||||
await loadClassList()
|
await loadClassList()
|
||||||
closeAddClassModal()
|
closeAddClassModal()
|
||||||
@ -1119,7 +1066,7 @@ const handleDeleteClass = (classItem: any) => {
|
|||||||
try {
|
try {
|
||||||
await ClassApi.deleteClass(classItem.id)
|
await ClassApi.deleteClass(classItem.id)
|
||||||
message.success(`已删除班级:${classItem.className}`)
|
message.success(`已删除班级:${classItem.className}`)
|
||||||
|
|
||||||
// 重新加载班级列表数据
|
// 重新加载班级列表数据
|
||||||
await loadClassList()
|
await loadClassList()
|
||||||
// 通知父组件更新列表
|
// 通知父组件更新列表
|
||||||
@ -1136,7 +1083,7 @@ const loadClassList = async () => {
|
|||||||
try {
|
try {
|
||||||
console.log('🚀 开始加载班级列表数据...')
|
console.log('🚀 开始加载班级列表数据...')
|
||||||
const response = await ClassApi.queryClassList({ course_id: null })
|
const response = await ClassApi.queryClassList({ course_id: null })
|
||||||
|
|
||||||
// 转换API响应数据为组件需要的格式
|
// 转换API响应数据为组件需要的格式
|
||||||
const classListData = response.data.result || []
|
const classListData = response.data.result || []
|
||||||
const transformedClassData: ClassItem[] = classListData.map((classItem: any) => ({
|
const transformedClassData: ClassItem[] = classListData.map((classItem: any) => ({
|
||||||
@ -1158,7 +1105,7 @@ const loadClassList = async () => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).replace(/\//g, '.').replace(',', '')
|
}).replace(/\//g, '.').replace(',', '')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
masterClassList.value = transformedClassData
|
masterClassList.value = transformedClassData
|
||||||
console.log(`✅ 成功加载班级列表,共 ${transformedClassData.length} 个班级`)
|
console.log(`✅ 成功加载班级列表,共 ${transformedClassData.length} 个班级`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1172,33 +1119,28 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (classId === null || classId === undefined) {
|
if (classId === null || classId === undefined) {
|
||||||
// 未选择班级时显示空数据
|
// 未选择班级时显示空数据
|
||||||
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 || []
|
||||||
const transformedData: StudentItem[] = studentsData.map((student: any) => ({
|
const transformedData: StudentItem[] = studentsData.map((student: any) => ({
|
||||||
@ -1216,14 +1158,12 @@ const loadData = async (classId?: number | null) => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).replace(/\//g, '.').replace(',', '') : '未知时间'
|
}).replace(/\//g, '.').replace(',', '') : '未知时间'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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
|
||||||
@ -1238,7 +1178,7 @@ const loadData = async (classId?: number | null) => {
|
|||||||
const handleImportSuccess = (result: any) => {
|
const handleImportSuccess = (result: any) => {
|
||||||
console.log('导入成功:', result)
|
console.log('导入成功:', result)
|
||||||
message.success(`导入完成!成功:${result.details?.success || 0} 条,失败:${result.details?.failed || 0} 条`)
|
message.success(`导入完成!成功:${result.details?.success || 0} 条,失败:${result.details?.failed || 0} 条`)
|
||||||
|
|
||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
loadData(props.classId)
|
loadData(props.classId)
|
||||||
}
|
}
|
||||||
@ -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.classId存在且与选择器值一致,说明是props驱动的变化,不需要重复加载
|
// 如果当前props.classId存在且与选择器值一致,说明是props驱动的变化,不需要重复加载
|
||||||
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
|
||||||
@ -1297,15 +1234,13 @@ onMounted(async () => {
|
|||||||
// 首先加载班级列表数据
|
// 首先加载班级列表数据
|
||||||
await loadClassList()
|
await loadClassList()
|
||||||
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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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 = '';
|
||||||
|
@ -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>
|
@ -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>
|
||||||
|
|
||||||
|
@ -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,21 +63,45 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
<button class="action-btn delete-btn" @click="reportMessage(message.id)">
|
||||||
|
<n-icon size="16">
|
||||||
|
<WarningOutline />
|
||||||
|
</n-icon>
|
||||||
|
举报
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="action-btn delete-btn">
|
|
||||||
<n-icon size="16">
|
|
||||||
<WarningOutline />
|
|
||||||
</n-icon>
|
|
||||||
举报
|
|
||||||
</button>
|
|
||||||
</div>
|
</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(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()) {
|
||||||
// TODO: 调用API发送回复
|
try {
|
||||||
console.log('发送回复:', message.replyContent)
|
// TODO: 调用API发送回复
|
||||||
message.showReplyBox = false
|
console.log('发送回复:', {
|
||||||
message.replyContent = ''
|
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()
|
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;
|
||||||
}
|
}
|
||||||
|
@ -9,26 +9,46 @@
|
|||||||
|
|
||||||
<!-- 联系人列表 -->
|
<!-- 联系人列表 -->
|
||||||
<div class="contacts-list">
|
<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 }"
|
<div v-if="contacts.length === 0 && !loading" class="contacts-empty">
|
||||||
@click="selectContact(contact.id)">
|
<div class="empty-content">
|
||||||
<div class="contact-avatar">
|
<n-icon size="48" color="#d9d9d9">
|
||||||
<img :src="contact.avatar" :alt="contact.name" />
|
<ChatbubbleEllipsesOutline />
|
||||||
<div v-if="contact.type === 'group'" class="group-indicator">
|
</n-icon>
|
||||||
<n-icon size="12" color="#fff">
|
<p class="empty-text">暂无会话</p>
|
||||||
<PeopleOutline />
|
<p class="empty-desc">还没有任何聊天会话</p>
|
||||||
</n-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="contact-info">
|
<!-- 联系人列表 -->
|
||||||
<div class="contact-header">
|
<div v-else>
|
||||||
<span class="contact-name">{{ contact.name }}</span>
|
<div v-for="contact in contacts" :key="contact.id" class="contact-item"
|
||||||
<span class="contact-time">{{ contact.lastMessageTime }}</span>
|
: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>
|
||||||
<div class="contact-preview">
|
|
||||||
<span class="last-message">{{ contact.lastMessage }}</span>
|
<div class="contact-info">
|
||||||
<n-badge v-if="contact.unreadCount > 0" :value="contact.unreadCount" :max="99" class="unread-badge" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -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,44 +91,68 @@
|
|||||||
<!-- 聊天消息区域 -->
|
<!-- 聊天消息区域 -->
|
||||||
<div class="chat-messages" ref="messagesContainer">
|
<div class="chat-messages" ref="messagesContainer">
|
||||||
<div class="messages-content">
|
<div class="messages-content">
|
||||||
<div v-for="message in currentMessages" :key="message.id" class="message-wrapper">
|
<!-- 消息空状态 -->
|
||||||
<!-- 日期分隔符 -->
|
<div v-if="currentMessages.length === 0 && !messagesLoading" class="messages-empty">
|
||||||
<div v-if="message.showDateDivider" class="date-divider">
|
<div class="empty-content">
|
||||||
<span class="date-text">{{ message.dateText }}</span>
|
<n-icon size="48" color="#d9d9d9">
|
||||||
|
<ChatbubbleEllipsesOutline />
|
||||||
|
</n-icon>
|
||||||
|
<p class="empty-text">暂无消息</p>
|
||||||
|
<p class="empty-desc">开始你们的对话吧</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 消息内容 -->
|
<!-- 消息列表 -->
|
||||||
<div class="message-item" :class="{ 'message-own': message.isOwn }">
|
<div v-else>
|
||||||
<div v-if="!message.isOwn" class="message-avatar">
|
<div v-for="message in currentMessages" :key="message.id" class="message-wrapper">
|
||||||
<img :src="message.avatar" :alt="message.senderName" />
|
<!-- 日期分隔符 -->
|
||||||
|
<div v-if="message.showDateDivider" class="date-divider">
|
||||||
|
<span class="date-text">{{ message.dateText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-content">
|
<!-- 消息内容 -->
|
||||||
<div v-if="!message.isOwn" class="message-sender">{{ message.senderName }}</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-if="message.type === 'text'" class="message-bubble">
|
<div v-else class="avatar-placeholder">
|
||||||
<p class="message-text">{{ message.content }}</p>
|
{{ message.senderName.charAt(0) }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- 图片消息 -->
|
|
||||||
<div v-else-if="message.type === 'image'" class="message-bubble image-bubble">
|
<div class="message-content">
|
||||||
<img :src="message.content" class="message-image" @click="previewImage(message.content)" />
|
<div v-if="!message.isOwn" class="message-sender">{{ message.senderName }}</div>
|
||||||
</div>
|
|
||||||
|
<!-- 文本消息 -->
|
||||||
<!-- 文件消息 -->
|
<div v-if="message.type === 'text'" class="message-bubble">
|
||||||
<div v-else-if="message.type === 'file'" class="message-bubble file-bubble">
|
<p class="message-text">{{ message.content }}</p>
|
||||||
<div class="file-info">
|
</div>
|
||||||
<n-icon size="20" color="#1890ff">
|
|
||||||
<DocumentOutline />
|
<!-- 图片消息 -->
|
||||||
</n-icon>
|
<div v-else-if="message.type === 'image'" class="message-bubble image-bubble">
|
||||||
<div class="file-details">
|
<img :src="message.content" class="message-image" @click="previewImage(message.content)" />
|
||||||
<span class="file-name">{{ message.fileName }}</span>
|
</div>
|
||||||
<span class="file-size">{{ message.fileSize }}</span>
|
|
||||||
</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>
|
</div>
|
||||||
<div class="message-time">{{ message.time }}</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: '我',
|
// 根据API返回的数字类型进行判断:0=私聊,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,
|
// 如果是群聊且没有memberCount,尝试获取群成员数量
|
||||||
contactId: 2,
|
for (const contact of contacts.value) {
|
||||||
type: 'file',
|
if (contact.type === 'group' && (!contact.memberCount || contact.memberCount === 0)) {
|
||||||
content: '',
|
loadGroupMemberCount(contact.id)
|
||||||
senderName: '李四',
|
}
|
||||||
avatar: 'https://picsum.photos/40/40?random=9',
|
}
|
||||||
time: '10:30',
|
}
|
||||||
isOwn: false,
|
} catch (error) {
|
||||||
fileName: '2025年全家爱词学习人工智能老师考级试卷-点击下载.pptx',
|
console.error('获取我的会话失败:', error)
|
||||||
fileSize: '2.5MB'
|
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)
|
||||||
|
|
||||||
|
// 根据messageType数字判断消息类型:0=文本,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%;
|
||||||
}
|
}
|
||||||
@ -777,4 +990,4 @@ onMounted(() => {
|
|||||||
max-width: 75%;
|
max-width: 75%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user