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()
|
export default new MessageApi()
|
@ -820,6 +820,31 @@ export interface CreatedStudentsRequest {
|
|||||||
classId: string;
|
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 {
|
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>
|
</div>
|
||||||
<n-divider v-if="props.type === 'student'" />
|
<n-divider v-if="props.type === 'student'" />
|
||||||
|
|
||||||
<n-data-table :columns="columns" :data="paginatedData" :pagination="pagination" :loading="loading"
|
<n-data-table :columns="columns" :data="filteredData" :pagination="pagination" :loading="loading"
|
||||||
:row-key="(row: StudentItem) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered
|
:row-key="(row) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered size="small">
|
||||||
size="small">
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div class="custom-empty">
|
<div class="custom-empty">
|
||||||
<n-empty v-if="!selectedDepartment && !props.classId" description="请先选择班级查看学员信息">
|
<n-empty v-if="!selectedDepartment && !props.classId" description="请先选择班级查看学员信息">
|
||||||
@ -323,9 +322,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-data-table :columns="libraryColumns" :data="filteredLibraryStudents" :loading="libraryLoading"
|
<n-data-table :columns="libraryColumns" :data="filteredLibraryStudents" :loading="libraryLoading"
|
||||||
:row-key="(row: LibraryStudentItem) => row.id"
|
:row-key="(row) => row.id" v-model:checked-row-keys="selectedLibraryStudents"
|
||||||
v-model:checked-row-keys="selectedLibraryStudents" :pagination="libraryPagination" striped
|
:pagination="libraryPagination" striped size="small" :max-height="400" />
|
||||||
size="small" :max-height="400" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -408,6 +406,7 @@ interface ClassItem {
|
|||||||
studentCount: number
|
studentCount: number
|
||||||
creator: string
|
creator: string
|
||||||
createTime: string
|
createTime: string
|
||||||
|
inviteCode?: string // 邀请码字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// 学员库数据类型定义
|
// 学员库数据类型定义
|
||||||
@ -556,12 +555,7 @@ const filteredData = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算属性:分页后的数据
|
// 移除 paginatedData,让 Naive UI 自动处理分页
|
||||||
const paginatedData = computed(() => {
|
|
||||||
const start = (pagination.value.page - 1) * pagination.value.pageSize
|
|
||||||
const end = start + pagination.value.pageSize
|
|
||||||
return filteredData.value.slice(start, end)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算属性:统一数据源生成的各种选项
|
// 计算属性:统一数据源生成的各种选项
|
||||||
|
|
||||||
@ -608,6 +602,9 @@ const filteredLibraryStudents = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 使用computed来动态更新学员库分页器的itemCount
|
||||||
|
const libraryPaginationItemCount = computed(() => filteredLibraryStudents.value.length)
|
||||||
|
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns: DataTableColumns<StudentItem> = [
|
const columns: DataTableColumns<StudentItem> = [
|
||||||
@ -802,39 +799,51 @@ const libraryColumns: DataTableColumns<LibraryStudentItem> = [
|
|||||||
const data = ref<StudentItem[]>([])
|
const data = ref<StudentItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 分页配置
|
// 分页状态
|
||||||
const pagination = ref({
|
const paginationPage = ref(1)
|
||||||
page: 1,
|
const paginationPageSize = ref(10)
|
||||||
pageSize: 10,
|
|
||||||
|
// 分页配置 - 让 Naive UI 自动计算 itemCount
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
page: paginationPage.value,
|
||||||
|
pageSize: paginationPageSize.value,
|
||||||
showSizePicker: true,
|
showSizePicker: true,
|
||||||
|
showQuickJumper: true,
|
||||||
pageSizes: [10, 20, 50],
|
pageSizes: [10, 20, 50],
|
||||||
itemCount: computed(() => filteredData.value.length), // 使用过滤后的数据长度
|
// 不设置 itemCount,让 Naive UI 自动计算
|
||||||
onChange: (page: number) => {
|
onChange: (page: number) => {
|
||||||
pagination.value.page = page
|
paginationPage.value = page
|
||||||
// 前端分页不需要重新加载数据
|
|
||||||
},
|
},
|
||||||
onUpdatePageSize: (pageSize: number) => {
|
onUpdatePageSize: (pageSize: number) => {
|
||||||
pagination.value.pageSize = pageSize
|
paginationPageSize.value = pageSize
|
||||||
pagination.value.page = 1
|
paginationPage.value = 1
|
||||||
// 前端分页不需要重新加载数据
|
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
// 学员库分页配置
|
// 学员库分页配置
|
||||||
const libraryPagination = ref({
|
const libraryPagination = computed(() => ({
|
||||||
page: 1,
|
page: libraryPaginationPage.value,
|
||||||
pageSize: 10,
|
pageSize: libraryPaginationPageSize.value,
|
||||||
showSizePicker: true,
|
showSizePicker: true,
|
||||||
|
showQuickJumper: true,
|
||||||
pageSizes: [10, 20, 50],
|
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) => {
|
onChange: (page: number) => {
|
||||||
libraryPagination.value.page = page
|
libraryPaginationPage.value = page
|
||||||
},
|
},
|
||||||
onUpdatePageSize: (pageSize: number) => {
|
onUpdatePageSize: (pageSize: number) => {
|
||||||
libraryPagination.value.pageSize = pageSize
|
libraryPaginationPageSize.value = pageSize
|
||||||
libraryPagination.value.page = 1
|
libraryPaginationPage.value = 1
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
// 学员库分页状态
|
||||||
|
const libraryPaginationPage = ref(1)
|
||||||
|
const libraryPaginationPageSize = ref(10)
|
||||||
|
|
||||||
// 操作处理函数
|
// 操作处理函数
|
||||||
const handleTransfer = (row: StudentItem) => {
|
const handleTransfer = (row: StudentItem) => {
|
||||||
@ -879,23 +888,62 @@ const handleBatchDelete = () => {
|
|||||||
negativeText: '取消',
|
negativeText: '取消',
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
try {
|
try {
|
||||||
// 这里模拟 API 调用
|
const currentClassId = props.classId || selectedDepartment.value
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
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({
|
||||||
selectedRowKeys.value = []
|
studentIds: selectedRowKeys.value,
|
||||||
|
classId: String(currentClassId)
|
||||||
|
})
|
||||||
|
|
||||||
message.success(`成功移除 ${removedCount} 名学员`)
|
// 关闭加载状态
|
||||||
|
loadingMessage.destroy()
|
||||||
|
|
||||||
// 重新加载数据
|
console.log('📊 批量移除响应:', response)
|
||||||
loadData(props.classId)
|
|
||||||
} catch (error) {
|
if (response.data) {
|
||||||
message.error('批量移除失败,请重试')
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentClassId = props.classId || selectedDepartment.value
|
||||||
|
if (!currentClassId) {
|
||||||
|
message.error('当前班级ID为空,无法执行调班操作')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否选择的是当前班级
|
||||||
|
if (selectedTargetClass.value === currentClassId) {
|
||||||
|
message.warning('不能调至当前班级')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 这里模拟 API 调用
|
console.log('🚀 开始批量调班:', {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
学生IDs: selectedRowKeys.value,
|
||||||
|
原班级ID: currentClassId,
|
||||||
|
目标班级ID: selectedTargetClass.value
|
||||||
|
})
|
||||||
|
|
||||||
const transferCount = selectedRowKeys.value.length
|
const response = await ClassApi.batchTransfer({
|
||||||
const targetClassName = masterClassList.value.find(item => item.id === selectedTargetClass.value)?.className
|
studentIds: selectedRowKeys.value,
|
||||||
|
originalClassId: String(currentClassId),
|
||||||
|
newClassId: selectedTargetClass.value
|
||||||
|
})
|
||||||
|
|
||||||
message.success(`已将 ${transferCount} 名学员调至 ${targetClassName}`)
|
console.log('📊 批量调班响应:', response)
|
||||||
|
|
||||||
// 关闭弹窗并重置状态
|
if (response.data) {
|
||||||
showBatchTransferModal.value = false
|
const result = response.data
|
||||||
selectedTargetClass.value = ''
|
const targetClassName = masterClassList.value.find((item: any) => item.id === selectedTargetClass.value)?.className
|
||||||
selectedRowKeys.value = []
|
|
||||||
|
|
||||||
// 重新加载数据
|
// 构建详细的结果消息
|
||||||
loadData(props.classId)
|
let resultMessage = `批量调班完成!\n`
|
||||||
} catch (error) {
|
resultMessage += `目标班级:${targetClassName}\n`
|
||||||
message.error('批量调班失败,请重试')
|
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 getClassNameById = (classId: string): string => {
|
||||||
|
|
||||||
const classItem = masterClassList.value.find(item => item.id === classId)
|
const classItem = masterClassList.value.find(item => item.id === classId)
|
||||||
console.log('格式化班级信息:', classItem);
|
// console.log('格式化班级信息:', classItem);
|
||||||
|
|
||||||
|
|
||||||
return classItem ? classItem.className : classId
|
return classItem ? classItem.className : classId
|
||||||
@ -1185,24 +1304,53 @@ const formatClassNames = (classInfo: string): string[] => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据班级ID生成邀请码
|
// 根据班级ID获取邀请码
|
||||||
const generateInviteCode = (classId: string) => {
|
const getInviteCode = async (classId: string) => {
|
||||||
// 模拟根据班级ID生成不同的邀请码
|
try {
|
||||||
const baseCode = 56685222
|
// 从班级列表中查找对应班级的邀请码
|
||||||
const numericClassId = parseInt(classId) || 1
|
const classInfo = classList.value.find(cls => cls.id === classId)
|
||||||
return (baseCode + numericClassId * 1000).toString()
|
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
|
currentInviteClassId.value = classId
|
||||||
inviteCode.value = generateInviteCode(classId)
|
|
||||||
showInviteModal.value = true
|
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 = () => {
|
const copyInviteCode = () => {
|
||||||
@ -1262,7 +1410,8 @@ const handleSubmit = async () => {
|
|||||||
resetForm()
|
resetForm()
|
||||||
|
|
||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
loadData(props.classId)
|
const currentClassId = props.classId || selectedDepartment.value
|
||||||
|
loadData(currentClassId)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ 添加学员失败:', error)
|
console.error('❌ 添加学员失败:', error)
|
||||||
message.error(error.message || '添加学员失败,请重试')
|
message.error(error.message || '添加学员失败,请重试')
|
||||||
@ -1412,7 +1561,8 @@ const loadClassList = async () => {
|
|||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).replace(/\//g, '.').replace(',', '')
|
}).replace(/\//g, '.').replace(',', ''),
|
||||||
|
inviteCode: classItem.inviteCode || '' // 添加邀请码字段映射
|
||||||
}))
|
}))
|
||||||
|
|
||||||
masterClassList.value = transformedClassData
|
masterClassList.value = transformedClassData
|
||||||
@ -1504,18 +1654,18 @@ const handleTemplateDownload = (type?: string) => {
|
|||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
// 搜索是实时的,通过计算属性filteredData自动过滤
|
// 搜索是实时的,通过计算属性filteredData自动过滤
|
||||||
// 重置到第一页
|
// 重置到第一页
|
||||||
pagination.value.page = 1
|
paginationPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听搜索关键词变化,重置分页
|
// 监听搜索关键词变化,重置分页
|
||||||
watch(searchKeyword, () => {
|
watch(searchKeyword, () => {
|
||||||
pagination.value.page = 1
|
paginationPage.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
// 清空搜索
|
// 清空搜索
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
searchKeyword.value = ''
|
searchKeyword.value = ''
|
||||||
pagination.value.page = 1
|
paginationPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 学员库相关方法
|
// 学员库相关方法
|
||||||
@ -1587,12 +1737,12 @@ const loadLibraryStudents = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLibrarySearch = () => {
|
const handleLibrarySearch = () => {
|
||||||
libraryPagination.value.page = 1
|
libraryPaginationPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearLibrarySearch = () => {
|
const clearLibrarySearch = () => {
|
||||||
librarySearchKeyword.value = ''
|
librarySearchKeyword.value = ''
|
||||||
libraryPagination.value.page = 1
|
libraryPaginationPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmLibrarySelection = async () => {
|
const handleConfirmLibrarySelection = async () => {
|
||||||
@ -1679,7 +1829,7 @@ watch(
|
|||||||
selectedDepartment.value = newClassId ? String(newClassId) : ''
|
selectedDepartment.value = newClassId ? String(newClassId) : ''
|
||||||
// 切换班级时清空搜索
|
// 切换班级时清空搜索
|
||||||
searchKeyword.value = ''
|
searchKeyword.value = ''
|
||||||
pagination.value.page = 1
|
paginationPage.value = 1
|
||||||
loadData(newClassId)
|
loadData(newClassId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1697,7 +1847,7 @@ watch(
|
|||||||
if (newDepartmentId !== oldDepartmentId && newDepartmentId !== currentPropsClassId) {
|
if (newDepartmentId !== oldDepartmentId && newDepartmentId !== currentPropsClassId) {
|
||||||
// 切换班级时清空搜索
|
// 切换班级时清空搜索
|
||||||
searchKeyword.value = ''
|
searchKeyword.value = ''
|
||||||
pagination.value.page = 1
|
paginationPage.value = 1
|
||||||
const targetClassId = newDepartmentId || null
|
const targetClassId = newDepartmentId || null
|
||||||
loadData(targetClassId)
|
loadData(targetClassId)
|
||||||
}
|
}
|
||||||
@ -1721,7 +1871,12 @@ onMounted(async () => {
|
|||||||
await loadClassList()
|
await loadClassList()
|
||||||
loadSchoolList()
|
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
|
const initialClassId = props.classId ? props.classId : selectedDepartment.value
|
||||||
|
|
||||||
// 只有当classId有效时才加载数据
|
// 只有当classId有效时才加载数据
|
||||||
|
@ -354,6 +354,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: MessageCenter,
|
component: MessageCenter,
|
||||||
meta: { title: '消息中心' }
|
meta: { title: '消息中心' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'message-detail/:id',
|
||||||
|
name: 'MessageDetail',
|
||||||
|
component: () => import('@/views/teacher/message/MessageDetail.vue'),
|
||||||
|
meta: { title: '消息详情' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'recycle-bin',
|
path: 'recycle-bin',
|
||||||
name: 'RecycleBin',
|
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>
|
</n-icon>
|
||||||
回复
|
回复
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="message-actions">
|
<div class="message-actions">
|
||||||
@ -159,6 +171,7 @@ interface Message {
|
|||||||
userId?: string
|
userId?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
likeCount?: number
|
likeCount?: number
|
||||||
|
readFlag: number // 是否已读
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理@标记的函数
|
// 处理@标记的函数
|
||||||
@ -200,7 +213,8 @@ const transformMessageData = (backendItem: BackendMessageItem): Message => {
|
|||||||
courseId: parsedContent?.entity?.id,
|
courseId: parsedContent?.entity?.id,
|
||||||
userId: parsedContent?.sender?.id,
|
userId: parsedContent?.sender?.id,
|
||||||
images: [],
|
images: [],
|
||||||
likeCount: 0
|
likeCount: 0,
|
||||||
|
readFlag: backendItem.readFlag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,6 +223,9 @@ const messages = ref<Message[]>([])
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 标记已读状态
|
||||||
|
const markingRead = ref<string | null>(null)
|
||||||
|
|
||||||
|
|
||||||
// 分页相关
|
// 分页相关
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
@ -284,7 +301,8 @@ const loadMessages = async () => {
|
|||||||
courseId: '1954463468539371522',
|
courseId: '1954463468539371522',
|
||||||
userId: '1966804797404344321',
|
userId: '1966804797404344321',
|
||||||
images: [],
|
images: [],
|
||||||
likeCount: 0
|
likeCount: 0,
|
||||||
|
readFlag: 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
totalPages.value = 1
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -510,6 +548,26 @@ const goToPage = (page: number) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
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 {
|
.action-btn {
|
||||||
|
@ -48,6 +48,15 @@
|
|||||||
<div class="message-text" v-if="messageItem.type === 0">课程:
|
<div class="message-text" v-if="messageItem.type === 0">课程:
|
||||||
<span class="course-info">{{ messageItem.courseInfo }}</span>
|
<span class="course-info">{{ messageItem.courseInfo }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,6 +131,9 @@ const pageSize = ref(20)
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 标记已读状态
|
||||||
|
const markingRead = ref<string | null>(null)
|
||||||
|
|
||||||
// 计算显示的页码
|
// 计算显示的页码
|
||||||
const visiblePages = computed(() => {
|
const visiblePages = computed(() => {
|
||||||
const pages = []
|
const pages = []
|
||||||
@ -224,6 +236,26 @@ const goToPage = (page: number) => {
|
|||||||
loadMessages()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -364,8 +396,28 @@ const goToPage = (page: number) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
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 {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
<!-- 消息内容 -->
|
<!-- 消息内容 -->
|
||||||
<div class="message-text">
|
<div class="message-text">
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
<n-button type="info" text class="detail-btn">
|
<n-button type="info" text class="detail-btn" @click="goToMessageDetail(message)">
|
||||||
查看详情>
|
查看详情>
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
@ -64,9 +64,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { NIcon } from 'naive-ui'
|
import { NIcon, NButton } from 'naive-ui'
|
||||||
import { NotificationsOffOutline } from '@vicons/ionicons5'
|
import { NotificationsOffOutline } from '@vicons/ionicons5'
|
||||||
import { MessageApi, type SystemMessage, type BackendMessageItem } from '@/api'
|
import { MessageApi, type SystemMessage, type BackendMessageItem } from '@/api'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const messages = ref<SystemMessage[]>([])
|
const messages = ref<SystemMessage[]>([])
|
||||||
@ -78,6 +79,9 @@ const pageSize = ref(10)
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 计算显示的页码
|
// 计算显示的页码
|
||||||
const visiblePages = computed(() => {
|
const visiblePages = computed(() => {
|
||||||
const pages = []
|
const pages = []
|
||||||
@ -243,6 +247,18 @@ const goToPage = (page: number) => {
|
|||||||
loadMessages()
|
loadMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跳转到消息详情页面
|
||||||
|
const goToMessageDetail = (message: SystemMessage) => {
|
||||||
|
console.log('🔍 跳转到消息详情页面:', message)
|
||||||
|
// 跳转到消息详情页面,传递消息ID作为参数
|
||||||
|
router.push({
|
||||||
|
name: 'MessageDetail',
|
||||||
|
params: {
|
||||||
|
id: message.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user