feat: 完善消息中心和班级管理功能

This commit is contained in:
QDKF 2025-09-25 20:01:32 +08:00
parent 8a8fd09137
commit 97d8e99689
8 changed files with 1152 additions and 83 deletions

View File

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

View File

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

View File

@ -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(() => {
)
})
// 使computeditemCount
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,23 +888,62 @@ 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 })
//
selectedRowKeys.value = []
const response = await ClassApi.batchRemoveStudents({
studentIds: selectedRowKeys.value,
classId: String(currentClassId)
})
message.success(`成功移除 ${removedCount} 名学员`)
//
loadingMessage.destroy()
//
loadData(props.classId)
} catch (error) {
message.error('批量移除失败,请重试')
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 = []
//
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,24 +1047,95 @@ 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)
//
showBatchTransferModal.value = false
selectedTargetClass.value = ''
selectedRowKeys.value = []
if (response.data) {
const result = response.data
const targetClassName = masterClassList.value.find((item: any) => item.id === selectedTargetClass.value)?.className
//
loadData(props.classId)
} catch (error) {
message.error('批量调班失败,请重试')
//
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)
}
} 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('批量调班失败,请重试')
}
}
}
@ -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
console.log('打开邀请码弹窗:', {
班级ID: classId,
邀请码: inviteCode.value
})
//
inviteCode.value = '加载中...'
try {
//
const realInviteCode = await getInviteCode(classId)
inviteCode.value = realInviteCode
console.log('打开邀请码弹窗:', {
班级ID: classId,
邀请码: 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

View File

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

View 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)
// 使messageIdsendId
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>

View File

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

View File

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

View File

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