feat: 完善消息中心和班级管理功能
This commit is contained in:
parent
8a8fd09137
commit
97d8e99689
@ -423,6 +423,34 @@ class MessageApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息详情
|
||||
* @param sendId 消息发送ID
|
||||
* @returns Promise<ApiResponse<any>>
|
||||
*/
|
||||
async getMessageDetail(sendId: string): Promise<ApiResponse<any>> {
|
||||
return request({
|
||||
url: '/sys/sysAnnouncementSend/getOne',
|
||||
method: 'GET',
|
||||
params: { sendId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记系统消息为已读
|
||||
* @param sendId 消息发送ID
|
||||
* @returns Promise<ApiResponse<any>>
|
||||
*/
|
||||
async markSystemMessageAsRead(sendId: string): Promise<ApiResponse<any>> {
|
||||
return request({
|
||||
url: '/aiol/message/readOne',
|
||||
method: 'GET',
|
||||
params: {
|
||||
sendId
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageApi()
|
@ -820,6 +820,31 @@ export interface CreatedStudentsRequest {
|
||||
classId: string;
|
||||
}
|
||||
|
||||
export interface BatchTransferResponse {
|
||||
notFoundStudentIds: string[];
|
||||
originalClassId: string;
|
||||
alreadyInNewClassStudentIds: string[];
|
||||
failCount: number;
|
||||
notFoundCount: number;
|
||||
newClassId: string;
|
||||
successCount: number;
|
||||
successStudentIds: string[];
|
||||
totalCount: number;
|
||||
alreadyInNewClassCount: number;
|
||||
failStudentIds: string[];
|
||||
}
|
||||
|
||||
export interface BatchRemoveResponse {
|
||||
notFoundStudentIds: string[];
|
||||
classId: string;
|
||||
failCount: number;
|
||||
notFoundCount: number;
|
||||
successCount: number;
|
||||
successStudentIds: string[];
|
||||
totalCount: number;
|
||||
failStudentIds: string[];
|
||||
}
|
||||
|
||||
export class ClassApi {
|
||||
/**
|
||||
* 创建班级
|
||||
@ -896,6 +921,66 @@ export class ClassApi {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量调班
|
||||
*/
|
||||
static async batchTransfer(data: {
|
||||
studentIds: string[];
|
||||
originalClassId: string;
|
||||
newClassId: string;
|
||||
}): Promise<ApiResponse<BatchTransferResponse>> {
|
||||
return ApiRequest.post('/aiol/aiolClassStudent/batchTransfer', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移除学生(使用单个移除接口循环调用)
|
||||
*/
|
||||
static async batchRemoveStudents(data: {
|
||||
studentIds: string[];
|
||||
classId: string;
|
||||
}): Promise<ApiResponse<BatchRemoveResponse>> {
|
||||
const { studentIds, classId } = data;
|
||||
const results = {
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
notFoundCount: 0,
|
||||
successStudentIds: [] as string[],
|
||||
failStudentIds: [] as string[],
|
||||
notFoundStudentIds: [] as string[],
|
||||
totalCount: studentIds.length,
|
||||
classId
|
||||
};
|
||||
|
||||
// 循环调用单个移除接口
|
||||
for (const studentId of studentIds) {
|
||||
try {
|
||||
const response = await this.removeStudent(classId, studentId);
|
||||
if (response.data && (response.data.success || response.data.code === 200)) {
|
||||
results.successCount++;
|
||||
results.successStudentIds.push(studentId);
|
||||
} else {
|
||||
results.failCount++;
|
||||
results.failStudentIds.push(studentId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`移除学生 ${studentId} 失败:`, error);
|
||||
if (error.response && error.response.status === 404) {
|
||||
results.notFoundCount++;
|
||||
results.notFoundStudentIds.push(studentId);
|
||||
} else {
|
||||
results.failCount++;
|
||||
results.failStudentIds.push(studentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '批量移除完成',
|
||||
data: results as any
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载链接
|
||||
*/
|
||||
|
@ -79,9 +79,8 @@
|
||||
</div>
|
||||
<n-divider v-if="props.type === 'student'" />
|
||||
|
||||
<n-data-table :columns="columns" :data="paginatedData" :pagination="pagination" :loading="loading"
|
||||
:row-key="(row: StudentItem) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered
|
||||
size="small">
|
||||
<n-data-table :columns="columns" :data="filteredData" :pagination="pagination" :loading="loading"
|
||||
:row-key="(row) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered size="small">
|
||||
<template #empty>
|
||||
<div class="custom-empty">
|
||||
<n-empty v-if="!selectedDepartment && !props.classId" description="请先选择班级查看学员信息">
|
||||
@ -323,9 +322,8 @@
|
||||
</div>
|
||||
|
||||
<n-data-table :columns="libraryColumns" :data="filteredLibraryStudents" :loading="libraryLoading"
|
||||
:row-key="(row: LibraryStudentItem) => row.id"
|
||||
v-model:checked-row-keys="selectedLibraryStudents" :pagination="libraryPagination" striped
|
||||
size="small" :max-height="400" />
|
||||
:row-key="(row) => row.id" v-model:checked-row-keys="selectedLibraryStudents"
|
||||
:pagination="libraryPagination" striped size="small" :max-height="400" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@ -408,6 +406,7 @@ interface ClassItem {
|
||||
studentCount: number
|
||||
creator: string
|
||||
createTime: string
|
||||
inviteCode?: string // 邀请码字段
|
||||
}
|
||||
|
||||
// 学员库数据类型定义
|
||||
@ -556,12 +555,7 @@ const filteredData = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 计算属性:分页后的数据
|
||||
const paginatedData = computed(() => {
|
||||
const start = (pagination.value.page - 1) * pagination.value.pageSize
|
||||
const end = start + pagination.value.pageSize
|
||||
return filteredData.value.slice(start, end)
|
||||
})
|
||||
// 移除 paginatedData,让 Naive UI 自动处理分页
|
||||
|
||||
// 计算属性:统一数据源生成的各种选项
|
||||
|
||||
@ -608,6 +602,9 @@ const filteredLibraryStudents = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 使用computed来动态更新学员库分页器的itemCount
|
||||
const libraryPaginationItemCount = computed(() => filteredLibraryStudents.value.length)
|
||||
|
||||
|
||||
// 表格列定义
|
||||
const columns: DataTableColumns<StudentItem> = [
|
||||
@ -802,39 +799,51 @@ const libraryColumns: DataTableColumns<LibraryStudentItem> = [
|
||||
const data = ref<StudentItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 分页配置
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
// 分页状态
|
||||
const paginationPage = ref(1)
|
||||
const paginationPageSize = ref(10)
|
||||
|
||||
// 分页配置 - 让 Naive UI 自动计算 itemCount
|
||||
const pagination = computed(() => ({
|
||||
page: paginationPage.value,
|
||||
pageSize: paginationPageSize.value,
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
itemCount: computed(() => filteredData.value.length), // 使用过滤后的数据长度
|
||||
// 不设置 itemCount,让 Naive UI 自动计算
|
||||
onChange: (page: number) => {
|
||||
pagination.value.page = page
|
||||
// 前端分页不需要重新加载数据
|
||||
paginationPage.value = page
|
||||
},
|
||||
onUpdatePageSize: (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
// 前端分页不需要重新加载数据
|
||||
paginationPageSize.value = pageSize
|
||||
paginationPage.value = 1
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// 学员库分页配置
|
||||
const libraryPagination = ref({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
const libraryPagination = computed(() => ({
|
||||
page: libraryPaginationPage.value,
|
||||
pageSize: libraryPaginationPageSize.value,
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
itemCount: computed(() => filteredLibraryStudents.value.length),
|
||||
itemCount: libraryPaginationItemCount.value,
|
||||
prefix: (info: { itemCount?: number }) => {
|
||||
const itemCount = info.itemCount || 0;
|
||||
return `共 ${itemCount} 条`;
|
||||
},
|
||||
onChange: (page: number) => {
|
||||
libraryPagination.value.page = page
|
||||
libraryPaginationPage.value = page
|
||||
},
|
||||
onUpdatePageSize: (pageSize: number) => {
|
||||
libraryPagination.value.pageSize = pageSize
|
||||
libraryPagination.value.page = 1
|
||||
libraryPaginationPageSize.value = pageSize
|
||||
libraryPaginationPage.value = 1
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// 学员库分页状态
|
||||
const libraryPaginationPage = ref(1)
|
||||
const libraryPaginationPageSize = ref(10)
|
||||
|
||||
// 操作处理函数
|
||||
const handleTransfer = (row: StudentItem) => {
|
||||
@ -879,25 +888,64 @@ const handleBatchDelete = () => {
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
// 这里模拟 API 调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
const currentClassId = props.classId || selectedDepartment.value
|
||||
if (!currentClassId) {
|
||||
message.error('班级ID为空,无法执行移除操作')
|
||||
return
|
||||
}
|
||||
|
||||
const removedCount = selectedRowKeys.value.length
|
||||
console.log('🚀 开始批量移除学生:', {
|
||||
学生IDs: selectedRowKeys.value,
|
||||
班级ID: currentClassId
|
||||
})
|
||||
|
||||
// 从数据中移除选中的学员
|
||||
data.value = data.value.filter(student => !selectedRowKeys.value.includes(student.id))
|
||||
// 显示加载状态
|
||||
const loadingMessage = message.loading('正在批量移除学生...', { duration: 0 })
|
||||
|
||||
const response = await ClassApi.batchRemoveStudents({
|
||||
studentIds: selectedRowKeys.value,
|
||||
classId: String(currentClassId)
|
||||
})
|
||||
|
||||
// 关闭加载状态
|
||||
loadingMessage.destroy()
|
||||
|
||||
console.log('📊 批量移除响应:', response)
|
||||
|
||||
if (response.data) {
|
||||
const result = response.data
|
||||
|
||||
// 构建详细的结果消息
|
||||
let resultMessage = `批量移除完成!\n`
|
||||
resultMessage += `总数量:${result.totalCount} 人\n`
|
||||
resultMessage += `成功:${result.successCount} 人\n`
|
||||
|
||||
if (result.failCount > 0) {
|
||||
resultMessage += `失败:${result.failCount} 人\n`
|
||||
}
|
||||
if (result.notFoundCount > 0) {
|
||||
resultMessage += `未找到:${result.notFoundCount} 人\n`
|
||||
}
|
||||
|
||||
message.success(resultMessage)
|
||||
|
||||
// 清空选中状态
|
||||
selectedRowKeys.value = []
|
||||
|
||||
message.success(`成功移除 ${removedCount} 名学员`)
|
||||
|
||||
// 重新加载数据
|
||||
loadData(props.classId)
|
||||
} catch (error) {
|
||||
loadData(currentClassId)
|
||||
} else {
|
||||
message.error('批量移除失败:响应数据为空')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ 批量移除失败:', error)
|
||||
if (error.response && error.response.data) {
|
||||
message.error(`批量移除失败: ${error.response.data.message || error.response.statusText}`)
|
||||
} else {
|
||||
message.error('批量移除失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -999,25 +1047,96 @@ const confirmBatchTransfer = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const currentClassId = props.classId || selectedDepartment.value
|
||||
if (!currentClassId) {
|
||||
message.error('当前班级ID为空,无法执行调班操作')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否选择的是当前班级
|
||||
if (selectedTargetClass.value === currentClassId) {
|
||||
message.warning('不能调至当前班级')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 这里模拟 API 调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log('🚀 开始批量调班:', {
|
||||
学生IDs: selectedRowKeys.value,
|
||||
原班级ID: currentClassId,
|
||||
目标班级ID: selectedTargetClass.value
|
||||
})
|
||||
|
||||
const transferCount = selectedRowKeys.value.length
|
||||
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
|
||||
const response = await ClassApi.batchTransfer({
|
||||
studentIds: selectedRowKeys.value,
|
||||
originalClassId: String(currentClassId),
|
||||
newClassId: selectedTargetClass.value
|
||||
})
|
||||
|
||||
message.success(`已将 ${transferCount} 名学员调至 ${targetClassName}`)
|
||||
console.log('📊 批量调班响应:', response)
|
||||
|
||||
if (response.data) {
|
||||
const result = response.data
|
||||
const targetClassName = masterClassList.value.find((item: any) => item.id === selectedTargetClass.value)?.className
|
||||
|
||||
// 构建详细的结果消息
|
||||
let resultMessage = `批量调班完成!\n`
|
||||
resultMessage += `目标班级:${targetClassName}\n`
|
||||
resultMessage += `总数量:${selectedRowKeys.value.length} 人\n`
|
||||
resultMessage += `成功:${selectedRowKeys.value.length - result.failCount} 人\n`
|
||||
|
||||
if (result.failCount > 0) {
|
||||
resultMessage += `失败:${result.failCount} 人\n`
|
||||
}
|
||||
if (result.alreadyInNewClassCount > 0) {
|
||||
resultMessage += `已在目标班级:${result.alreadyInNewClassCount} 人\n`
|
||||
}
|
||||
if (result.notFoundCount > 0) {
|
||||
resultMessage += `未找到:${result.notFoundCount} 人\n`
|
||||
}
|
||||
|
||||
message.success(resultMessage)
|
||||
|
||||
// 如果有成功调班的学生,自动切换到目标班级
|
||||
if (result.successCount > 0) {
|
||||
const targetClassId = selectedTargetClass.value
|
||||
console.log('🔄 自动切换到目标班级:', targetClassId)
|
||||
|
||||
// 关闭弹窗并重置状态
|
||||
showBatchTransferModal.value = false
|
||||
selectedTargetClass.value = ''
|
||||
selectedRowKeys.value = []
|
||||
|
||||
// 重新加载数据
|
||||
// 切换到目标班级
|
||||
selectedDepartment.value = targetClassId
|
||||
// 清空搜索关键词
|
||||
searchKeyword.value = ''
|
||||
// 重新加载目标班级的数据
|
||||
loadData(targetClassId)
|
||||
|
||||
// 延迟显示切换提示,让用户看到调班结果
|
||||
setTimeout(() => {
|
||||
message.info(`已自动切换到目标班级:${targetClassName}`)
|
||||
}, 1000)
|
||||
} else {
|
||||
// 关闭弹窗并重置状态
|
||||
showBatchTransferModal.value = false
|
||||
selectedTargetClass.value = ''
|
||||
selectedRowKeys.value = []
|
||||
|
||||
// 如果没有成功调班的学生,重新加载当前班级数据
|
||||
loadData(props.classId)
|
||||
} catch (error) {
|
||||
}
|
||||
} else {
|
||||
message.error('批量调班失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ 批量调班失败:', error)
|
||||
if (error.response && error.response.data) {
|
||||
message.error(`批量调班失败: ${error.response.data.message || error.response.statusText}`)
|
||||
} else {
|
||||
message.error('批量调班失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (row: StudentItem) => {
|
||||
@ -1165,7 +1284,7 @@ const isCurrentClass = (classValue: string) => {
|
||||
const getClassNameById = (classId: string): string => {
|
||||
|
||||
const classItem = masterClassList.value.find(item => item.id === classId)
|
||||
console.log('格式化班级信息:', classItem);
|
||||
// console.log('格式化班级信息:', classItem);
|
||||
|
||||
|
||||
return classItem ? classItem.className : classId
|
||||
@ -1185,24 +1304,53 @@ const formatClassNames = (classInfo: string): string[] => {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据班级ID生成邀请码
|
||||
const generateInviteCode = (classId: string) => {
|
||||
// 模拟根据班级ID生成不同的邀请码
|
||||
const baseCode = 56685222
|
||||
const numericClassId = parseInt(classId) || 1
|
||||
return (baseCode + numericClassId * 1000).toString()
|
||||
// 根据班级ID获取邀请码
|
||||
const getInviteCode = async (classId: string) => {
|
||||
try {
|
||||
// 从班级列表中查找对应班级的邀请码
|
||||
const classInfo = classList.value.find(cls => cls.id === classId)
|
||||
if (classInfo && classInfo.inviteCode) {
|
||||
return classInfo.inviteCode
|
||||
}
|
||||
|
||||
// 如果班级列表中没有,尝试重新查询
|
||||
const response = await ClassApi.queryClassList({ course_id: null })
|
||||
if (response.data && response.data.result) {
|
||||
const targetClass = response.data.result.find((cls: any) => cls.id === classId)
|
||||
if (targetClass && targetClass.inviteCode) {
|
||||
return targetClass.inviteCode
|
||||
}
|
||||
}
|
||||
|
||||
return '未找到邀请码'
|
||||
} catch (error) {
|
||||
console.error('获取邀请码失败:', error)
|
||||
return '获取失败'
|
||||
}
|
||||
}
|
||||
|
||||
// 打开邀请码弹窗
|
||||
const openInviteModal = (classId: string) => {
|
||||
const openInviteModal = async (classId: string) => {
|
||||
currentInviteClassId.value = classId
|
||||
inviteCode.value = generateInviteCode(classId)
|
||||
showInviteModal.value = true
|
||||
|
||||
// 显示加载状态
|
||||
inviteCode.value = '加载中...'
|
||||
|
||||
try {
|
||||
// 获取真实的邀请码
|
||||
const realInviteCode = await getInviteCode(classId)
|
||||
inviteCode.value = realInviteCode
|
||||
|
||||
console.log('打开邀请码弹窗:', {
|
||||
班级ID: classId,
|
||||
邀请码: inviteCode.value
|
||||
邀请码: realInviteCode
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取邀请码失败:', error)
|
||||
inviteCode.value = '获取失败'
|
||||
message.error('获取邀请码失败')
|
||||
}
|
||||
}
|
||||
|
||||
const copyInviteCode = () => {
|
||||
@ -1262,7 +1410,8 @@ const handleSubmit = async () => {
|
||||
resetForm()
|
||||
|
||||
// 重新加载数据
|
||||
loadData(props.classId)
|
||||
const currentClassId = props.classId || selectedDepartment.value
|
||||
loadData(currentClassId)
|
||||
} catch (error: any) {
|
||||
console.error('❌ 添加学员失败:', error)
|
||||
message.error(error.message || '添加学员失败,请重试')
|
||||
@ -1412,7 +1561,8 @@ const loadClassList = async () => {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).replace(/\//g, '.').replace(',', '')
|
||||
}).replace(/\//g, '.').replace(',', ''),
|
||||
inviteCode: classItem.inviteCode || '' // 添加邀请码字段映射
|
||||
}))
|
||||
|
||||
masterClassList.value = transformedClassData
|
||||
@ -1504,18 +1654,18 @@ const handleTemplateDownload = (type?: string) => {
|
||||
const handleSearch = () => {
|
||||
// 搜索是实时的,通过计算属性filteredData自动过滤
|
||||
// 重置到第一页
|
||||
pagination.value.page = 1
|
||||
paginationPage.value = 1
|
||||
}
|
||||
|
||||
// 监听搜索关键词变化,重置分页
|
||||
watch(searchKeyword, () => {
|
||||
pagination.value.page = 1
|
||||
paginationPage.value = 1
|
||||
})
|
||||
|
||||
// 清空搜索
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
pagination.value.page = 1
|
||||
paginationPage.value = 1
|
||||
}
|
||||
|
||||
// 学员库相关方法
|
||||
@ -1587,12 +1737,12 @@ const loadLibraryStudents = async () => {
|
||||
}
|
||||
|
||||
const handleLibrarySearch = () => {
|
||||
libraryPagination.value.page = 1
|
||||
libraryPaginationPage.value = 1
|
||||
}
|
||||
|
||||
const clearLibrarySearch = () => {
|
||||
librarySearchKeyword.value = ''
|
||||
libraryPagination.value.page = 1
|
||||
libraryPaginationPage.value = 1
|
||||
}
|
||||
|
||||
const handleConfirmLibrarySelection = async () => {
|
||||
@ -1679,7 +1829,7 @@ watch(
|
||||
selectedDepartment.value = newClassId ? String(newClassId) : ''
|
||||
// 切换班级时清空搜索
|
||||
searchKeyword.value = ''
|
||||
pagination.value.page = 1
|
||||
paginationPage.value = 1
|
||||
loadData(newClassId)
|
||||
}
|
||||
},
|
||||
@ -1697,7 +1847,7 @@ watch(
|
||||
if (newDepartmentId !== oldDepartmentId && newDepartmentId !== currentPropsClassId) {
|
||||
// 切换班级时清空搜索
|
||||
searchKeyword.value = ''
|
||||
pagination.value.page = 1
|
||||
paginationPage.value = 1
|
||||
const targetClassId = newDepartmentId || null
|
||||
loadData(targetClassId)
|
||||
}
|
||||
@ -1721,7 +1871,12 @@ onMounted(async () => {
|
||||
await loadClassList()
|
||||
loadSchoolList()
|
||||
|
||||
// 初始加载时,优先使用使用传入的classId,其次使用选择器的值
|
||||
// 如果没有传入classId且没有选择班级,自动选择第一个班级
|
||||
if (!props.classId && !selectedDepartment.value && masterClassList.value.length > 0) {
|
||||
selectedDepartment.value = masterClassList.value[0].id
|
||||
}
|
||||
|
||||
// 初始加载时,优先使用传入的classId,其次使用选择器的值
|
||||
const initialClassId = props.classId ? props.classId : selectedDepartment.value
|
||||
|
||||
// 只有当classId有效时才加载数据
|
||||
|
@ -354,6 +354,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: MessageCenter,
|
||||
meta: { title: '消息中心' }
|
||||
},
|
||||
{
|
||||
path: 'message-detail/:id',
|
||||
name: 'MessageDetail',
|
||||
component: () => import('@/views/teacher/message/MessageDetail.vue'),
|
||||
meta: { title: '消息详情' }
|
||||
},
|
||||
{
|
||||
path: 'recycle-bin',
|
||||
name: 'RecycleBin',
|
||||
|
669
src/views/teacher/message/MessageDetail.vue
Normal file
669
src/views/teacher/message/MessageDetail.vue
Normal file
@ -0,0 +1,669 @@
|
||||
<template>
|
||||
<div class="message-detail-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<button class="back-btn" @click="goBack">
|
||||
<n-icon size="18">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
返回消息列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div class="page-content">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<n-spin size="large">
|
||||
<div class="loading-text">加载中...</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<n-result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
:description="error"
|
||||
>
|
||||
<template #footer>
|
||||
<n-button @click="loadMessageDetail">重试</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
|
||||
<!-- 消息详情内容 -->
|
||||
<div v-else-if="messageDetail" class="message-detail">
|
||||
<!-- 消息卡片头部 -->
|
||||
<div class="message-card-header">
|
||||
<div class="message-title-section">
|
||||
<h1 class="message-title">{{ messageDetail.titile || '无标题' }}</h1>
|
||||
<div class="message-meta">
|
||||
<div class="meta-item">
|
||||
<n-icon size="16" color="#666">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
<span>{{ messageDetail.sender || '系统' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<n-icon size="16" color="#666">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4.2 14.2L11 13V7h1.5v5.2l4.5 2.7-.8 1.3z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
<span>{{ formatTime(messageDetail.sendTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态标签 -->
|
||||
<div class="status-badges">
|
||||
<n-tag :type="getMessageTypeTag(messageDetail.msgCategory)" size="small">
|
||||
{{ getMessageTypeText(messageDetail.msgCategory) }}
|
||||
</n-tag>
|
||||
<n-tag :type="getPriorityTag(messageDetail.priority)" size="small">
|
||||
{{ getPriorityText(messageDetail.priority) }}
|
||||
</n-tag>
|
||||
<n-tag :type="messageDetail.readFlag === 1 ? 'success' : 'warning'" size="small">
|
||||
{{ messageDetail.readFlag === 1 ? '已读' : '未读' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容区域 -->
|
||||
<div class="message-content-section">
|
||||
<div class="content-header">
|
||||
<n-icon size="20" color="#1890ff">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
<span class="content-title">消息内容</span>
|
||||
</div>
|
||||
<div class="content-body" v-html="formatMessageContent(messageDetail.msgContent)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 附加信息 -->
|
||||
<div class="additional-info">
|
||||
<!-- 附件信息 -->
|
||||
<div v-if="messageDetail.files" class="files-section">
|
||||
<div class="section-header">
|
||||
<n-icon size="18" color="#52c41a">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
<span>附件</span>
|
||||
</div>
|
||||
<div class="files-list">
|
||||
<n-tag
|
||||
v-for="(file, index) in parseFiles(messageDetail.files)"
|
||||
:key="index"
|
||||
type="info"
|
||||
class="file-tag"
|
||||
>
|
||||
{{ file.name }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div v-if="messageDetail.visitsNum" class="stats-section">
|
||||
<div class="section-header">
|
||||
<n-icon size="18" color="#fa8c16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16,6L18.29,8.29L13.41,13.17L9.41,9.17L2,16.59L3.41,18L9.41,12L13.41,16L19.71,9.71L22,12V6H16Z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
<span>统计信息</span>
|
||||
</div>
|
||||
<div class="stats-content">
|
||||
<span class="stat-item">浏览次数:{{ messageDetail.visitsNum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="message-actions">
|
||||
<n-button
|
||||
v-if="messageDetail.readFlag === 0"
|
||||
type="success"
|
||||
@click="markAsRead"
|
||||
:loading="markingRead"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
</template>
|
||||
标记为已读
|
||||
</n-button>
|
||||
|
||||
<div v-else style="color: #52c41a; font-size: 14px; text-align: center;">
|
||||
✓ 消息已读
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NIcon, NButton, NSpin, NResult, NTag, useMessage } from 'naive-ui'
|
||||
import { MessageApi } from '@/api'
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const messageDetail = ref<any>(null)
|
||||
const markingRead = ref(false)
|
||||
|
||||
// 获取消息ID
|
||||
const messageId = route.params.id as string
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
if (messageId) {
|
||||
loadMessageDetail()
|
||||
} else {
|
||||
error.value = '消息ID不存在'
|
||||
}
|
||||
})
|
||||
|
||||
// 加载消息详情
|
||||
const loadMessageDetail = async () => {
|
||||
if (!messageId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
console.log('🔍 开始加载消息详情,messageId:', messageId)
|
||||
const response = await MessageApi.getMessageDetail(messageId)
|
||||
console.log('🔍 消息详情API响应:', response)
|
||||
|
||||
if (response.data && response.data.code === 200 && response.data.result) {
|
||||
messageDetail.value = response.data.result
|
||||
console.log('✅ 消息详情加载成功:', messageDetail.value)
|
||||
} else {
|
||||
error.value = response.data?.message || response.message || '获取消息详情失败'
|
||||
console.error('❌ 获取消息详情失败:', response)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '网络错误,请稍后重试'
|
||||
console.error('❌ 获取消息详情异常:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为已读
|
||||
const markAsRead = async () => {
|
||||
if (!messageId) return
|
||||
|
||||
markingRead.value = true
|
||||
try {
|
||||
console.log('📝 标记消息为已读,messageId:', messageId)
|
||||
console.log('📝 消息详情数据:', messageDetail.value)
|
||||
|
||||
// 使用messageId作为sendId参数
|
||||
const response = await MessageApi.markSystemMessageAsRead(messageId)
|
||||
console.log('📝 标记已读API响应:', response)
|
||||
|
||||
console.log('📝 API响应详情:', response)
|
||||
|
||||
// 检查API返回的成功状态
|
||||
if (response.data && (response.data.success === true || response.data.code === 0)) {
|
||||
// 更新本地状态
|
||||
if (messageDetail.value) {
|
||||
messageDetail.value.readFlag = 1
|
||||
}
|
||||
message.success(response.data.message || '已标记为已读')
|
||||
console.log('✅ 标记已读成功')
|
||||
} else {
|
||||
console.log('❌ API返回错误:', response.data)
|
||||
message.error(response.data?.message || '标记已读失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('❌ 标记已读失败:', err)
|
||||
// 检查是否是网络错误或其他具体错误
|
||||
if (err.response && err.response.status) {
|
||||
message.error(`标记已读失败: ${err.response.status} - ${err.response.statusText}`)
|
||||
} else if (err.message) {
|
||||
message.error(`标记已读失败: ${err.message}`)
|
||||
} else {
|
||||
message.error('标记已读失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
markingRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回消息列表
|
||||
const goBack = () => {
|
||||
router.push('/teacher/message-center')
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr) return '未知时间'
|
||||
try {
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return timeStr
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化消息内容
|
||||
const formatMessageContent = (content: string) => {
|
||||
if (!content) return '无内容'
|
||||
|
||||
try {
|
||||
// 尝试解析JSON格式的内容
|
||||
const parsed = JSON.parse(content)
|
||||
if (typeof parsed === 'object') {
|
||||
// 格式化显示消息内容
|
||||
let formattedContent = ''
|
||||
|
||||
// 显示发送者信息
|
||||
if (parsed.sender) {
|
||||
formattedContent += `<div class="message-sender">
|
||||
<strong>发送者:</strong>${parsed.sender.username || '未知用户'}
|
||||
</div>`
|
||||
}
|
||||
|
||||
// 显示评论内容
|
||||
if (parsed.comment) {
|
||||
formattedContent += `<div class="message-comment">
|
||||
<strong>评论内容:</strong>${parsed.comment.content || '无内容'}
|
||||
</div>`
|
||||
}
|
||||
|
||||
// 显示相关实体信息
|
||||
if (parsed.entity) {
|
||||
formattedContent += `<div class="message-entity">
|
||||
<strong>相关${parsed.entity.type === 'course' ? '课程' : '内容'}:</strong>${parsed.entity.title || '无标题'}
|
||||
</div>`
|
||||
}
|
||||
|
||||
// 显示动作时间
|
||||
if (parsed.actionTime) {
|
||||
const actionTime = new Date(parsed.actionTime).toLocaleString('zh-CN')
|
||||
formattedContent += `<div class="message-action-time">
|
||||
<strong>动作时间:</strong>${actionTime}
|
||||
</div>`
|
||||
}
|
||||
|
||||
return formattedContent || JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
// 如果不是JSON,直接返回原内容
|
||||
return content.replace(/\n/g, '<br>')
|
||||
}
|
||||
}
|
||||
|
||||
// 解析附件
|
||||
const parseFiles = (files: any) => {
|
||||
if (!files) return []
|
||||
try {
|
||||
if (typeof files === 'string') {
|
||||
return JSON.parse(files)
|
||||
}
|
||||
return Array.isArray(files) ? files : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取消息类型标签
|
||||
const getMessageTypeTag = (category: string): "success" | "error" | "warning" | "info" | "default" | "primary" => {
|
||||
const typeMap: Record<string, "success" | "error" | "warning" | "info" | "default" | "primary"> = {
|
||||
'system': 'info',
|
||||
'announcement': 'warning',
|
||||
'notification': 'success',
|
||||
'urgent': 'error'
|
||||
}
|
||||
return typeMap[category] || 'default'
|
||||
}
|
||||
|
||||
// 获取消息类型文本
|
||||
const getMessageTypeText = (category: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'1': '系统消息',
|
||||
'2': '评论和@',
|
||||
'3': '赞和收藏',
|
||||
'system': '系统消息',
|
||||
'announcement': '公告',
|
||||
'notification': '通知',
|
||||
'urgent': '紧急'
|
||||
}
|
||||
return textMap[category] || category || '未知'
|
||||
}
|
||||
|
||||
// 获取优先级标签
|
||||
const getPriorityTag = (priority: string): "success" | "error" | "warning" | "info" | "default" | "primary" => {
|
||||
const priorityMap: Record<string, "success" | "error" | "warning" | "info" | "default" | "primary"> = {
|
||||
'high': 'error',
|
||||
'medium': 'warning',
|
||||
'low': 'info'
|
||||
}
|
||||
return priorityMap[priority] || 'default'
|
||||
}
|
||||
|
||||
// 获取优先级文本
|
||||
const getPriorityText = (priority: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'H': '高',
|
||||
'M': '中',
|
||||
'L': '低',
|
||||
'high': '高',
|
||||
'medium': '中',
|
||||
'low': '低'
|
||||
}
|
||||
return textMap[priority] || priority || '未知'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-detail-page {
|
||||
background-color: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
min-height: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-detail {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 消息卡片头部 */
|
||||
.message-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.message-title-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 消息内容区域 */
|
||||
.message-content-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
background: #fafafa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e8e8e8;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 格式化消息内容的样式 */
|
||||
.content-body .message-sender,
|
||||
.content-body .message-comment,
|
||||
.content-body .message-entity,
|
||||
.content-body .message-action-time {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.content-body .message-sender strong,
|
||||
.content-body .message-comment strong,
|
||||
.content-body .message-entity strong,
|
||||
.content-body .message-action-time strong {
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-body .message-comment {
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
.content-body .message-comment strong {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.content-body .message-entity {
|
||||
border-left-color: #fa8c16;
|
||||
}
|
||||
|
||||
.content-body .message-entity strong {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.content-body .message-action-time {
|
||||
border-left-color: #722ed1;
|
||||
}
|
||||
|
||||
.content-body .message-action-time strong {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
/* 附加信息 */
|
||||
.additional-info {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.files-section,
|
||||
.stats-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-tag {
|
||||
cursor: pointer;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #91d5ff;
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
background: #fff7e6;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffd591;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
color: #fa8c16;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.message-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.message-detail {
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.message-card-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.content-body .message-sender,
|
||||
.content-body .message-comment,
|
||||
.content-body .message-entity,
|
||||
.content-body .message-action-time {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -85,6 +85,18 @@
|
||||
</n-icon>
|
||||
回复
|
||||
</button>
|
||||
|
||||
<!-- 标记已读按钮 -->
|
||||
<button v-if="message.readFlag === 0" class="action-btn mark-read-btn" @click="markAsRead(message.id)"
|
||||
:disabled="markingRead === message.id">
|
||||
<n-icon size="16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z" />
|
||||
</svg>
|
||||
</n-icon>
|
||||
{{ markingRead === message.id ? '标记中...' : '标记已读' }}
|
||||
</button>
|
||||
<span v-else class="read-status">✓ 已读</span>
|
||||
</div>
|
||||
|
||||
<div class="message-actions">
|
||||
@ -159,6 +171,7 @@ interface Message {
|
||||
userId?: string
|
||||
images?: string[]
|
||||
likeCount?: number
|
||||
readFlag: number // 是否已读
|
||||
}
|
||||
|
||||
// 处理@标记的函数
|
||||
@ -200,7 +213,8 @@ const transformMessageData = (backendItem: BackendMessageItem): Message => {
|
||||
courseId: parsedContent?.entity?.id,
|
||||
userId: parsedContent?.sender?.id,
|
||||
images: [],
|
||||
likeCount: 0
|
||||
likeCount: 0,
|
||||
readFlag: backendItem.readFlag
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,6 +223,9 @@ const messages = ref<Message[]>([])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
|
||||
// 标记已读状态
|
||||
const markingRead = ref<string | null>(null)
|
||||
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
@ -284,7 +301,8 @@ const loadMessages = async () => {
|
||||
courseId: '1954463468539371522',
|
||||
userId: '1966804797404344321',
|
||||
images: [],
|
||||
likeCount: 0
|
||||
likeCount: 0,
|
||||
readFlag: 0
|
||||
}
|
||||
]
|
||||
totalPages.value = 1
|
||||
@ -404,6 +422,26 @@ const goToPage = (page: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 标记消息为已读
|
||||
const markAsRead = async (messageId: string) => {
|
||||
markingRead.value = messageId
|
||||
try {
|
||||
const response = await MessageApi.markSystemMessageAsRead(messageId)
|
||||
|
||||
if (response.data && (response.data.success === true || response.data.code === 0)) {
|
||||
// 更新本地状态
|
||||
const messageIndex = messages.value.findIndex(msg => msg.id === messageId)
|
||||
if (messageIndex !== -1) {
|
||||
messages.value[messageIndex].readFlag = 1
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('标记已读失败:', err)
|
||||
} finally {
|
||||
markingRead.value = null
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -510,6 +548,26 @@ const goToPage = (page: number) => {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn.mark-read-btn {
|
||||
color: #0288D1;
|
||||
}
|
||||
|
||||
.action-btn.mark-read-btn:hover {
|
||||
color: #0288D1;
|
||||
}
|
||||
|
||||
.action-btn.mark-read-btn:disabled {
|
||||
color: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.read-status {
|
||||
color: #0288D1;
|
||||
font-size: 12px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
@ -48,6 +48,15 @@
|
||||
<div class="message-text" v-if="messageItem.type === 0">课程:
|
||||
<span class="course-info">{{ messageItem.courseInfo }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 标记已读按钮 -->
|
||||
<div class="message-actions">
|
||||
<button v-if="messageItem.readFlag === 0" class="mark-read-btn" @click="markAsRead(messageItem.id)"
|
||||
:disabled="markingRead === messageItem.id">
|
||||
{{ markingRead === messageItem.id ? '标记中...' : '标记已读' }}
|
||||
</button>
|
||||
<span v-else class="read-status">✓ 已读</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,6 +131,9 @@ const pageSize = ref(20)
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 标记已读状态
|
||||
const markingRead = ref<string | null>(null)
|
||||
|
||||
// 计算显示的页码
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
@ -224,6 +236,26 @@ const goToPage = (page: number) => {
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// 标记消息为已读
|
||||
const markAsRead = async (messageId: string) => {
|
||||
markingRead.value = messageId
|
||||
try {
|
||||
const response = await MessageApi.markSystemMessageAsRead(messageId)
|
||||
|
||||
if (response.data && (response.data.success === true || response.data.code === 0)) {
|
||||
// 更新本地状态
|
||||
const messageIndex = messages.value.findIndex(msg => msg.id === messageId)
|
||||
if (messageIndex !== -1) {
|
||||
messages.value[messageIndex].readFlag = 1
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('标记已读失败:', err)
|
||||
} finally {
|
||||
markingRead.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -364,8 +396,28 @@ const goToPage = (page: number) => {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mark-read-btn {
|
||||
padding: 4px 12px;
|
||||
background: #0288D1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark-read-btn:disabled {
|
||||
background: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.read-status {
|
||||
color: #0288D1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -26,7 +26,7 @@
|
||||
<!-- 消息内容 -->
|
||||
<div class="message-text">
|
||||
{{ message.content }}
|
||||
<n-button type="info" text class="detail-btn">
|
||||
<n-button type="info" text class="detail-btn" @click="goToMessageDetail(message)">
|
||||
查看详情>
|
||||
</n-button>
|
||||
</div>
|
||||
@ -64,9 +64,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { NIcon, NButton } from 'naive-ui'
|
||||
import { NotificationsOffOutline } from '@vicons/ionicons5'
|
||||
import { MessageApi, type SystemMessage, type BackendMessageItem } from '@/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 响应式数据
|
||||
const messages = ref<SystemMessage[]>([])
|
||||
@ -78,6 +79,9 @@ const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
|
||||
// 计算显示的页码
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
@ -243,6 +247,18 @@ const goToPage = (page: number) => {
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到消息详情页面
|
||||
const goToMessageDetail = (message: SystemMessage) => {
|
||||
console.log('🔍 跳转到消息详情页面:', message)
|
||||
// 跳转到消息详情页面,传递消息ID作为参数
|
||||
router.push({
|
||||
name: 'MessageDetail',
|
||||
params: {
|
||||
id: message.id
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
Loading…
x
Reference in New Issue
Block a user