Files
gym-manage/gym-manage-uniapp/pages/message/index.vue
T

1594 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="message-page">
<!-- 顶部导航栏 - 使用自定义导航栏适配胶囊 -->
<view class="nav-header" :style="navStyle">
<view class="nav-left">
<view class="back-btn" @tap="goBack">
<text class="back-icon"></text>
</view>
<text class="nav-title">消息中心</text>
<view v-if="unreadCount > 0" class="unread-tip">
<text>{{ unreadCount }}</text>
</view>
</view>
<view class="nav-right">
<view
v-if="unreadCount > 0"
class="action-btn"
@tap="markAllAsRead"
hover-class="action-btn-hover"
>
<text class="action-icon"></text>
<text class="action-text">全部已读</text>
</view>
</view>
</view>
<!-- Tab 切换 -->
<view class="tabs-wrapper" :style="tabsWrapperStyle">
<scroll-view scroll-x class="tabs-scroll" :show-scrollbar="false">
<view class="tabs-container">
<view
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: currentTab === tab.value }]"
@tap="switchTab(tab.value)"
>
<text class="tab-label">{{ tab.label }}</text>
<view v-if="tab.value === 'unread' && unreadCount > 0" class="tab-badge">
<text>{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
<view v-if="currentTab === tab.value" class="tab-indicator"></view>
</view>
</view>
</scroll-view>
</view>
<!-- 消息列表 -->
<scroll-view
scroll-y
class="message-list"
:style="listStyle"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onScrollToLower"
refresher-background="#F5F7FA"
refresher-default-style="black"
lower-threshold="100"
>
<!-- 消息列表区域 -->
<view class="message-list-content">
<!-- 真实消息数据 -->
<template v-if="groupedMessages.length > 0">
<view v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
<view class="group-date">
<view class="group-date-line"></view>
<text>{{ group.date }}</text>
<view class="group-date-line"></view>
</view>
<view
v-for="message in group.items"
:key="message.id"
:class="['message-item', { read: message.read }]"
@tap="openMessage(message)"
hover-class="message-item-hover"
>
<view :class="['message-icon', message.type]">
<image :src="getMessageIcon(message.type)" mode="aspectFit" class="icon-img" />
<view v-if="!message.read" class="icon-dot">
<view class="dot-inner"></view>
</view>
</view>
<view class="message-content">
<view class="message-header">
<view class="title-wrapper">
<text class="message-title">{{ message.title }}</text>
<view v-if="!message.read" class="unread-tag">NEW</view>
</view>
<text class="message-time">{{ message.time }}</text>
</view>
<text class="message-desc">{{ message.content }}</text>
<view class="message-footer">
<text class="message-type">{{ getTypeLabel(message.type) }}</text>
<view class="message-actions" v-if="!message.read">
<text class="action-mark" @tap.stop="markSingleAsRead(message)">标记已读</text>
</view>
</view>
</view>
<view class="message-arrow">
<text></text>
</view>
</view>
</view>
</template>
<!-- 骨架屏项混在列表中 -->
<view v-for="i in skeletonCount" :key="'skeleton-' + i" class="message-item skeleton-item">
<view class="message-icon skeleton-icon"></view>
<view class="message-content">
<view class="skeleton-title"></view>
<view class="skeleton-desc"></view>
<view class="skeleton-footer"></view>
</view>
</view>
</view>
<!-- 加载更多指示器 -->
<view class="loading-more-state" v-if="loadingMore">
<view class="loading-spinner small">
<view class="spinner"></view>
</view>
<text class="loading-text">加载更多...</text>
</view>
<!-- 空状态 -->
<view class="message-empty" v-if="filteredMessages.length === 0 && skeletonCount === 0 && !loading && !loadingMore">
<view class="empty-icon-wrapper">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png" class="empty-icon" />
</view>
<text class="empty-text">{{ emptyText }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
<view v-if="currentTab === 'unread'" class="empty-action" @tap="switchTab('all')">
<text>查看全部消息 </text>
</view>
</view>
<!-- 已经到底啦 -->
<view class="no-more" v-if="!hasMore && filteredMessages.length > 0 && !loadingMore">
<text>已经到底啦~</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
getUserMessages,
getUnreadMessages,
getUnreadMessageCount,
markMessageAsRead,
markAllMessagesAsRead
} from '@/api/main.js'
const messages = ref([])
const currentTab = ref('all')
const refreshing = ref(false)
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(0)
const pageSize = ref(10)
const skeletonCount = ref(10) // 骨架屏数量
const allRawMessages = ref([]) // 存储后端返回的所有原始数据
const TEST_MODE = false
// ============ 小程序胶囊适配 ============
const statusBarHeight = ref(20)
const navBarHeight = ref(44)
const menuButtonInfo = ref(null)
// 获取胶囊按钮信息
const getMenuButtonInfo = () => {
// #ifdef MP-WEIXIN
try {
const info = uni.getMenuButtonBoundingClientRect()
if (info) {
menuButtonInfo.value = info
return info
}
} catch (e) {
console.warn('获取胶囊信息失败', e)
}
// #endif
return null
}
// 获取系统信息
const getSystemInfo = () => {
try {
const info = uni.getSystemInfoSync()
statusBarHeight.value = info.statusBarHeight || 20
return info
} catch (e) {
return { statusBarHeight: 20 }
}
}
// 初始化导航栏高度
const initNavBar = () => {
const systemInfo = getSystemInfo()
const menuButton = getMenuButtonInfo()
if (menuButton) {
const capsuleBottom = menuButton.top + menuButton.height
navBarHeight.value = capsuleBottom + 8 - statusBarHeight.value
} else {
navBarHeight.value = 44
}
}
// 导航栏样式
const navStyle = computed(() => {
return {
paddingTop: statusBarHeight.value + 'px',
paddingRight: menuButtonInfo.value ? (menuButtonInfo.value.width + 12) + 'px' : '32rpx',
minHeight: (statusBarHeight.value + navBarHeight.value) + 'px',
height: (statusBarHeight.value + navBarHeight.value) + 'px'
}
})
// Tab栏外层样式
const tabsWrapperStyle = computed(() => {
const top = statusBarHeight.value + navBarHeight.value
return {
top: top + 'px'
}
})
// 列表高度样式
const listStyle = computed(() => {
const top = statusBarHeight.value + navBarHeight.value + 70
return {
height: `calc(100vh - ${top}px)`,
paddingTop: '8rpx'
}
})
// ============ 小程序胶囊适配结束 ============
const tabs = [
{ label: '全部', value: 'all' },
{ label: '未读', value: 'unread' },
{ label: '已读', value: 'read' }
]
// ============ 工具函数:时间戳字符串转数字 ============
/**
* 将后端返回的时间戳字符串转换为数字
* @param {string|number} timestamp - 时间戳字符串或数字
* @returns {number} 数字类型的时间戳
*/
const parseTimestamp = (timestamp) => {
if (!timestamp) return Date.now()
// 如果是数字,直接返回
if (typeof timestamp === 'number') {
return timestamp
}
// 如果是字符串
if (typeof timestamp === 'string') {
// 尝试解析日期字符串格式,如 "2026-06-20 20:14:07"
const dateStr = timestamp.replace(/-/g, '/').replace('T', ' ')
const parsed = Date.parse(dateStr)
if (!isNaN(parsed)) {
return parsed
}
// 尝试转换为数字
const num = Number(timestamp)
if (isNaN(num)) {
return Date.now()
}
// 如果是秒级时间戳(10位),转换为毫秒
if (timestamp.length === 10) {
return num * 1000
}
// 如果是毫秒级时间戳(13位),直接返回
return num
}
return Date.now()
}
// ============ 工具函数结束 ============
// ============ 日期格式化 ============
/**
* 格式化日期为 "6月17日 星期三" 或 "2025年6月17日 星期三" 格式
* @param {string|number} timestamp - 时间戳字符串或数字
* @returns {string} 格式化后的日期字符串
*/
const formatDate = (timestamp) => {
// 解析时间戳
const ts = parseTimestamp(timestamp)
const date = new Date(ts)
const now = new Date()
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '未知日期'
}
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((today - messageDate) / (1000 * 60 * 60 * 24))
// 月份、日期、星期
const month = date.getMonth() + 1
const day = date.getDate()
const year = date.getFullYear()
const currentYear = now.getFullYear()
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const weekday = weekdays[date.getDay()]
// 判断是否为今年
const isThisYear = year === currentYear
if (diffDays === 0) {
return '今天'
} else if (diffDays === 1) {
return '昨天'
} else {
// 2天及以上显示日期
if (isThisYear) {
// 今年:显示 "6月17日 星期三"
return `${month}${day}${weekday}`
} else {
// 不是今年:显示 "2025年6月17日 星期三"
return `${year}${month}${day}${weekday}`
}
}
}
/**
* 格式化时间为 "HH:MM" 格式
* @param {string|number} timestamp - 时间戳字符串或数字
* @returns {string} 格式化后的时间字符串
*/
const formatTime = (timestamp) => {
// 解析时间戳
const ts = parseTimestamp(timestamp)
const date = new Date(ts)
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '--:--'
}
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
// ============ 日期格式化结束 ============
// ============ 消息类型映射 ============
/**
* 将后端消息类型映射为前端显示类型
* @param {string} type - 后端消息类型
* @returns {string} 前端显示类型
*/
const mapMessageType = (type) => {
const typeMap = {
'COURSE': 'course',
'SYSTEM': 'system',
'ACTIVITY': 'activity',
'POINTS': 'points',
'course': 'course',
'system': 'system',
'activity': 'activity',
'points': 'points'
}
return typeMap[type] || 'system'
}
/**
* 将后端消息数据转换为前端格式
* @param {object} msg - 后端消息对象
* @returns {object} 前端消息对象
*/
const transformMessage = (msg) => {
console.log('[transformMessage] 原始消息:', msg.id, 'createTime:', msg.createTime, 'createdAt:', msg.createdAt)
return {
id: msg.id,
type: mapMessageType(msg.type),
title: msg.title,
content: msg.content,
timestamp: msg.createTime || msg.createdAt || Date.now(),
read: msg.isRead === true || msg.isRead === 1 || msg.isRead === '1'
}
}
// ============ 消息类型映射结束 ============
// ============ 测试模式假数据 ============
const mockTemplates = [
{ type: 'course', title: '课程开始提醒', content: '您预约的「{course}」课程将在10分钟后开始,请提前到达训练场地。' },
{ type: 'course', title: '课程取消通知', content: '抱歉,您预约的「{course}」课程因教练临时有事已取消。' },
{ type: 'course', title: '课程预约成功', content: '您已成功预约「{course}」课程,请准时参加。' },
{ type: 'activity', title: '新活动上线', content: '「{activity}」火热开启,参与即有机会赢取健身礼包!' },
{ type: 'activity', title: '活动报名成功', content: '恭喜您成功报名「{activity}」,期待您的参与!' },
{ type: 'activity', title: '活动即将开始', content: '您报名的「{activity}」将在明天开始,请做好准备。' },
{ type: 'points', title: '积分到账通知', content: '您的运动积分已到账 +{points},累计积分可兑换精美礼品。' },
{ type: 'points', title: '积分兑换成功', content: '您已成功兑换「{gift}」,请在有效期内使用。' },
{ type: 'points', title: '积分即将过期', content: '您有 {points} 积分将于月底过期,请尽快使用。' },
{ type: 'system', title: '系统维护通知', content: '系统将于今晚22:00-24:00进行维护升级,期间可能影响部分功能使用。' },
{ type: 'system', title: '账户安全提醒', content: '检测到您的账户在新设备上登录,如非本人操作请及时修改密码。' },
{ type: 'system', title: '密码修改成功', content: '您的账户密码已成功修改,如非本人操作请联系客服。' }
]
const mockCourses = ['晨间活力唤醒跑', '全身力量塑形', '瑜伽放松课', '动感单车', '拳击有氧', '普拉提核心', 'HIIT燃脂', '游泳训练']
const mockActivities = ['夏日减脂挑战赛', '端午健身派对', '会员感恩周', '新人打卡活动', '周末运动嘉年华', '健身达人挑战赛']
const mockGifts = ['周卡体验券', '月卡优惠券', '私教体验课', '运动水壶', '健身手套', '蛋白粉试用装']
const generateMockMessages = (page, size) => {
// 模拟3页数据,每页10条,共30条
const maxPages = 3
if (page >= maxPages) {
return [] // 模拟到底
}
const messages = []
for (let i = 0; i < size; i++) {
const template = mockTemplates[Math.floor(Math.random() * mockTemplates.length)]
let content = template.content
// 替换占位符
content = content.replace('{course}', mockCourses[Math.floor(Math.random() * mockCourses.length)])
content = content.replace('{activity}', mockActivities[Math.floor(Math.random() * mockActivities.length)])
content = content.replace('{points}', Math.floor(Math.random() * 200 + 50))
content = content.replace('{gift}', mockGifts[Math.floor(Math.random() * mockGifts.length)])
// 生成时间戳:当前时间往前推随机天数
const daysAgo = page * size + i + Math.floor(Math.random() * 3)
const timestamp = Date.now() - daysAgo * 86400000 - Math.floor(Math.random() * 3600000)
messages.push({
id: `mock_${page}_${i}`,
type: template.type,
title: template.title,
content: content,
createdAt: timestamp,
isRead: Math.random() > 0.4 // 60%概率未读
})
}
return messages
}
// ============ 测试模式假数据结束 ============
const unreadCount = computed(() => {
if (propsUnreadCount.value !== null && propsUnreadCount.value !== undefined) {
return propsUnreadCount.value
}
return messages.value.filter(msg => !msg.read).length
})
const filteredMessages = computed(() => {
if (currentTab.value === 'all') {
return messages.value
} else if (currentTab.value === 'unread') {
return messages.value.filter(msg => !msg.read)
} else if (currentTab.value === 'read') {
return messages.value.filter(msg => msg.read)
}
return messages.value
})
const emptyText = computed(() => {
if (currentTab.value === 'unread') return '🎉 暂无未读消息'
if (currentTab.value === 'read') return '📭 暂无已读消息'
return '📬 暂无消息'
})
const emptyDesc = computed(() => {
if (currentTab.value === 'unread') return '您已读完所有消息,继续保持哦!'
if (currentTab.value === 'read') return '还没有已读的消息,快去查看吧!'
return '您还没有收到任何通知'
})
const groupedMessages = computed(() => {
const processedMessages = filteredMessages.value.map(msg => ({
...msg,
date: formatDate(msg.timestamp),
time: formatTime(msg.timestamp)
}))
const groups = {}
processedMessages.forEach(msg => {
if (!groups[msg.date]) {
groups[msg.date] = []
}
groups[msg.date].push(msg)
})
// 自定义排序:今天 > 昨天 > 其他日期(按时间倒序)
const dateOrder = ['今天', '昨天']
const sortedDates = Object.keys(groups).sort((a, b) => {
const indexA = dateOrder.indexOf(a)
const indexB = dateOrder.indexOf(b)
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB
}
if (indexA !== -1) return -1
if (indexB !== -1) return 1
// 按日期倒序排列
// 从日期字符串中提取日期进行比较
const dateA = new Date(a.replace(/[年月日]/g, '/'))
const dateB = new Date(b.replace(/[年月日]/g, '/'))
return dateB - dateA
})
return sortedDates.map(date => ({
date,
items: groups[date]
}))
})
const getMessageIcon = (type) => {
const icons = {
course: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png',
system: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png',
activity: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png',
points: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png'
}
return icons[type] || icons.system
}
const getTypeLabel = (type) => {
const labels = {
course: '课程通知',
system: '系统消息',
activity: '活动推送',
points: '积分动态'
}
return labels[type] || '通知'
}
const goBack = () => {
uni.reLaunch({
url: '/pages/index/index'
})
}
const switchTab = (tab) => {
if (currentTab.value === tab) return
currentTab.value = tab
if (uni.vibrateShort) {
uni.vibrateShort({
type: 'light'
})
}
}
const openMessage = async (message) => {
uni.showLoading({
title: '加载中...',
mask: true
})
const startTime = Date.now()
try {
if (!message.read && !TEST_MODE) {
await markMessageAsRead(message.id)
message.read = true
updateUnreadCount()
} else if (!message.read && TEST_MODE) {
message.read = true
updateUnreadCount()
}
} catch (e) {
console.error('标记已读失败:', e)
} finally {
const elapsed = Date.now() - startTime
const minDelay = 300
if (elapsed < minDelay) {
await new Promise(resolve => setTimeout(resolve, minDelay - elapsed))
}
uni.hideLoading()
}
if (uni.vibrateShort) {
uni.vibrateShort({
type: 'light'
})
}
const messageData = encodeURIComponent(JSON.stringify(message))
uni.navigateTo({
url: `/pages/message/detail?message=${messageData}`
})
}
const markSingleAsRead = async (message) => {
if (TEST_MODE) {
message.read = true
updateUnreadCount()
uni.showToast({
title: '已标记为已读',
icon: 'success',
duration: 1200
})
return
}
try {
await markMessageAsRead(message.id)
message.read = true
updateUnreadCount()
uni.showToast({
title: '已标记为已读',
icon: 'success',
duration: 1200
})
} catch (e) {
console.error('标记已读失败:', e)
uni.showToast({
title: '操作失败',
icon: 'none',
duration: 1200
})
}
}
const markAllAsRead = () => {
uni.showModal({
title: '确认操作',
content: '确定要将所有未读消息标记为已读吗?',
confirmColor: '#FF6B35',
success: async (res) => {
if (res.confirm) {
if (TEST_MODE) {
messages.value.forEach(msg => {
if (!msg.read) {
msg.read = true
}
})
updateUnreadCount()
uni.showToast({
title: '已全部标为已读',
icon: 'success',
duration: 1500
})
return
}
try {
const userId = uni.getStorageSync('userId') || 1
console.log('请求标记全部已读, userId:', userId)
const res = await markAllMessagesAsRead(userId)
console.log('标记全部已读响应数据:', res)
messages.value.forEach(msg => {
if (!msg.read) {
msg.read = true
}
})
updateUnreadCount()
uni.showToast({
title: '已全部标为已读',
icon: 'success',
duration: 1500
})
} catch (e) {
console.error('全部标记已读失败:', e)
uni.showToast({
title: '操作失败',
icon: 'none',
duration: 1500
})
}
}
}
})
}
const onRefresh = async () => {
refreshing.value = true
currentPage.value = 0
hasMore.value = true
try {
await loadMessages(true)
uni.showToast({
title: '刷新成功',
icon: 'success',
duration: 1000
})
} catch (e) {
console.error('刷新失败:', e)
uni.showToast({
title: '刷新失败',
icon: 'none',
duration: 1000
})
} finally {
refreshing.value = false
}
}
const onScrollToLower = () => {
if (loadingMore.value || !hasMore.value || loading.value) return
loadMore()
}
const loadMore = async () => {
// 检查是否可以加载更多
if (!hasMore.value) {
console.log('没有更多数据了')
return
}
if (loading.value) {
console.log('正在加载中,跳过')
return
}
if (loadingMore.value) {
console.log('正在加载更多中,跳过')
return
}
console.log('触发加载更多,当前页:', currentPage.value, '已有消息数:', messages.value.length)
loadingMore.value = true
// 加载更多时添加骨架屏(用户滑多少就显示多少个)
skeletonCount.value += pageSize.value
try {
await loadMessages(false)
} catch (e) {
console.error('加载更多失败:', e)
} finally {
loadingMore.value = false
}
}
const updateUnreadCount = () => {
const count = messages.value.filter(msg => !msg.read).length
try {
uni.setStorageSync('unreadMessageCount', count)
} catch (e) {
console.error('保存未读消息数失败:', e)
}
}
// ============ 加载消息数据 ============
/**
* 加载消息列表
* @param {boolean} isRefresh - 是否刷新(true=重置数据,false=追加数据)
*/
const loadMessages = async (isRefresh = true) => {
// 防止重复请求
if (loading.value) return
loading.value = true
// 处理骨架屏:初始加载时显示骨架屏
if (isRefresh) {
skeletonCount.value = pageSize.value
messages.value = []
allRawMessages.value = []
currentPage.value = 0
hasMore.value = true
}
// 加载更多时,skeletonCount 已在 loadMore 中累加,这里不需要重置
// 记录开始时间,用于计算最小延迟
const startTime = Date.now()
const minDelay = 1500 // 最小延迟 1500ms,确保骨架屏可见
try {
if (TEST_MODE) {
// 模拟网络延迟,确保骨架屏可见
await new Promise(resolve => setTimeout(resolve, 2000))
const mockData = generateMockMessages(currentPage.value, pageSize.value)
const processedMessages = mockData.map(transformMessage)
// 模拟后端分页结构
const mockRes = {
content: mockData,
totalPages: 3,
totalElements: 30,
currentPage: currentPage.value,
pageSize: pageSize.value,
first: currentPage.value === 0,
last: currentPage.value >= 2 || mockData.length < pageSize.value
}
console.log('[测试模式] 分页响应:', mockRes)
// 等待最小延迟后再添加数据(确保骨架屏显示)
const elapsed = Date.now() - startTime
if (elapsed < minDelay) {
await new Promise(resolve => setTimeout(resolve, minDelay - elapsed))
}
if (processedMessages.length > 0) {
// 一次性添加所有数据
messages.value.push(...processedMessages)
// 清空骨架屏
skeletonCount.value = 0
// 使用 last 判断是否有更多数据
hasMore.value = !mockRes.last
currentPage.value++
updateUnreadCount()
} else {
// 没有数据时,清空剩余骨架屏
skeletonCount.value = 0
hasMore.value = false
}
return
}
const userId = uni.getStorageSync('userId') || 1
const params = { page: currentPage.value, size: pageSize.value }
console.log('请求消息列表, userId:', userId, 'params:', params)
const res = await getUserMessages(userId, params)
console.log('消息列表响应数据:', res)
// 解析后端响应
let content = []
let last = true
let isPaged = false // 标记后端是否返回分页结构
if (res && Array.isArray(res)) {
// 直接返回数组(兼容旧接口)
content = res
} else if (res && res.content && Array.isArray(res.content)) {
// 标准分页结构
content = res.content
last = res.last === true
isPaged = true
} else if (res && res.data && res.data.content && Array.isArray(res.data.content)) {
// 嵌套在 data 中的分页结构
content = res.data.content
last = res.data.last === true
isPaged = true
} else if (res && res.data && Array.isArray(res.data)) {
// 直接返回数组在 data 中
content = res.data
}
// 等待最小延迟后再添加数据(确保骨架屏显示)
const elapsed = Date.now() - startTime
if (elapsed < minDelay) {
await new Promise(resolve => setTimeout(resolve, minDelay - elapsed))
}
// 判断后端是否真的分页:如果返回的数据量大于 pageSize,说明后端返回了所有数据
const backendReturnedAll = content.length > pageSize.value
if ((!isPaged || backendReturnedAll) && content.length > 0) {
// 后端返回了所有数据(或没有分页结构),前端自己做分页
if (allRawMessages.value.length === 0) {
allRawMessages.value = content
console.log('后端返回所有数据,共', content.length, '条,前端将做分页切片')
}
// 计算当前页应该显示的数据
const start = currentPage.value * pageSize.value
const end = start + pageSize.value
const pageData = allRawMessages.value.slice(start, end)
console.log('前端分页:', { start, end, pageDataLength: pageData.length, total: allRawMessages.value.length })
const newMessages = pageData.map(transformMessage)
if (newMessages.length > 0) {
// 一次性添加所有数据
messages.value.push(...newMessages)
// 清空骨架屏
skeletonCount.value = 0
// 判断是否还有更多(前端分页)
hasMore.value = end < allRawMessages.value.length
currentPage.value++
updateUnreadCount()
} else {
// 当前页没有数据,清空骨架屏
skeletonCount.value = 0
hasMore.value = false
}
} else {
// 后端支持分页,直接使用返回的数据
const newMessages = content.map(transformMessage)
if (newMessages.length > 0) {
// 一次性添加所有数据
messages.value.push(...newMessages)
// 清空骨架屏
skeletonCount.value = 0
// 使用 last 判断是否有更多数据
hasMore.value = !last
currentPage.value++
updateUnreadCount()
} else {
// 没有数据时,清空剩余骨架屏
skeletonCount.value = 0
hasMore.value = false
}
}
} catch (e) {
console.error('获取消息列表失败:', e)
skeletonCount.value = 0
if (isRefresh) {
try {
const cachedMessages = uni.getStorageSync('cachedMessages')
if (cachedMessages && Array.isArray(cachedMessages)) {
messages.value = cachedMessages
}
} catch (e2) {
console.error('读取缓存消息失败:', e2)
}
}
} finally {
loading.value = false
loadingMore.value = false
}
}
/**
* 加载未读消息数量
*/
const loadUnreadCount = async () => {
// 测试模式下不请求后端
if (TEST_MODE) {
return
}
try {
const userId = uni.getStorageSync('userId') || 1
console.log('请求未读消息数量, userId:', userId)
const res = await getUnreadMessageCount(userId)
console.log('未读消息数量响应数据:', res)
if (res && typeof res === 'number') {
uni.setStorageSync('unreadMessageCount', res)
} else if (res && typeof res.count === 'number') {
uni.setStorageSync('unreadMessageCount', res.count)
} else if (res && res.data && typeof res.data === 'number') {
uni.setStorageSync('unreadMessageCount', res.data)
} else if (res && res.data && typeof res.data.count === 'number') {
uni.setStorageSync('unreadMessageCount', res.data.count)
}
} catch (e) {
console.error('获取未读消息数失败:', e)
}
}
// ============ 加载消息数据结束 ============
const propsUnreadCount = ref(null)
onLoad((options) => {
if (options?.unreadCount) {
propsUnreadCount.value = parseInt(options.unreadCount, 10) || null
console.log('[Message] 从URL接收未读数量:', propsUnreadCount.value)
}
loadMessages()
})
onMounted(() => {
initNavBar()
})
onUnmounted(() => {
uni.$emit('pageShow')
})
</script>
<style lang="scss" scoped>
.message-page {
min-height: 100vh;
background: #F5F7FA;
}
// ============ 导航栏 ============
.nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 32rpx;
padding-right: 32rpx;
background: var(--bg-white);
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
position: sticky;
top: 0;
z-index: 20;
box-sizing: border-box;
.nav-left {
display: flex;
align-items: center;
gap: 12rpx;
.back-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.03);
transition: all 0.2s ease;
flex-shrink: 0;
.back-icon {
font-size: 48rpx;
color: var(--text-dark);
font-weight: 300;
line-height: 1;
margin-top: -4rpx;
}
&:active {
background: rgba(0, 0, 0, 0.06);
transform: scale(0.92);
}
}
.nav-title {
font-size: 38rpx;
font-weight: 700;
color: var(--text-dark);
letter-spacing: 0.5rpx;
white-space: nowrap;
}
.unread-tip {
display: flex;
align-items: center;
justify-content: center;
min-width: 36rpx;
height: 36rpx;
padding: 0 12rpx;
background: rgba(255, 71, 87, 0.12);
border-radius: 18rpx;
border: 1rpx solid rgba(255, 71, 87, 0.1);
flex-shrink: 0;
text {
font-size: 22rpx;
color: #FF4757;
font-weight: 600;
line-height: 1;
text-align: center;
}
}
}
.nav-right {
flex-shrink: 0;
display: flex;
align-items: center;
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
padding: 10rpx 24rpx;
height: 56rpx;
background: linear-gradient(135deg, rgba(255, 107, 53, 0.12), rgba(255, 107, 53, 0.06));
border-radius: 28rpx;
transition: all 0.2s ease;
border: 1rpx solid rgba(255, 107, 53, 0.1);
box-sizing: border-box;
.action-icon {
font-size: 22rpx;
color: var(--accent-orange);
font-weight: 700;
}
.action-text {
font-size: 24rpx;
color: var(--accent-orange);
font-weight: 500;
white-space: nowrap;
}
}
.action-btn-hover {
opacity: 0.7;
transform: scale(0.95);
}
}
}
// ============ Tab 栏 ============
.tabs-wrapper {
position: sticky;
z-index: 10;
background: var(--bg-white);
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
}
.tabs-scroll {
white-space: nowrap;
.tabs-container {
display: inline-flex;
padding: 0 32rpx;
height: 70rpx;
align-items: center;
gap: 8rpx;
}
}
.tab-item {
display: inline-flex;
align-items: center;
padding: 0 28rpx;
height: 56rpx;
position: relative;
border-radius: 28rpx;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.tab-label {
font-size: 26rpx;
color: var(--text-muted);
transition: all 0.3s ease;
font-weight: 500;
}
.tab-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 28rpx;
height: 28rpx;
padding: 0 8rpx;
margin-left: 6rpx;
background: linear-gradient(135deg, #FF4757, #FF2D55);
border-radius: 14rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 71, 87, 0.3);
animation: badgePop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
text {
font-size: 18rpx;
color: white;
font-weight: 700;
line-height: 1;
text-align: center;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%) scaleX(0);
width: 28rpx;
height: 4rpx;
border-radius: 2rpx;
background: linear-gradient(90deg, #FF6B35, #FF8A5C);
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
&.active {
.tab-label {
color: var(--accent-orange);
font-weight: 600;
font-size: 28rpx;
}
.tab-indicator {
transform: translateX(-50%) scaleX(1);
width: 36rpx;
}
}
&:active {
opacity: 0.7;
}
}
@keyframes badgePop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.3); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
// ============ 消息列表 ============
.message-list {
background: #F5F7FA;
}
.message-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 48rpx;
.empty-icon-wrapper {
width: 200rpx;
height: 200rpx;
background: linear-gradient(135deg, rgba(120, 185, 215, 0.06), rgba(120, 185, 215, 0.12));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
transition: all 0.3s ease;
animation: float 3s ease-in-out infinite;
.empty-icon {
width: 120rpx;
height: 120rpx;
opacity: 0.5;
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12rpx); }
}
.empty-text {
font-size: 32rpx;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: var(--text-light);
}
.empty-action {
margin-top: 32rpx;
padding: 12rpx 32rpx;
background: rgba(255, 107, 53, 0.08);
border-radius: 30rpx;
text {
font-size: 26rpx;
color: var(--accent-orange);
font-weight: 500;
}
&:active {
opacity: 0.7;
}
}
}
.group-date {
padding: 24rpx 32rpx 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
.group-date-line {
flex: 0 0 60rpx;
height: 1rpx;
background: linear-gradient(to right, transparent, rgba(0,0,0,0.06), transparent);
}
text {
font-size: 24rpx;
color: var(--text-light);
background: rgba(0, 0, 0, 0.04);
padding: 6rpx 24rpx;
border-radius: 20rpx;
letter-spacing: 1rpx;
font-weight: 500;
}
}
.message-item {
display: flex;
align-items: center;
padding: 24rpx 28rpx;
margin: 0 24rpx 12rpx;
background: var(--bg-white);
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
border: 1rpx solid rgba(0, 0, 0, 0.03);
&.message-item-hover {
transform: scale(0.98);
background: #F8FAFB;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
&.read {
.message-title {
color: var(--text-muted);
font-weight: 400;
}
.message-desc {
color: var(--text-light);
}
.message-icon {
filter: grayscale(0.3);
opacity: 0.7;
}
}
.message-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
position: relative;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
&.course {
background: linear-gradient(135deg, #4A90D9 0%, #2E6BB5 100%);
}
&.system {
background: linear-gradient(135deg, #52C41A 0%, #389E0D 100%);
}
&.activity {
background: linear-gradient(135deg, #FF6B35 0%, #E85D2C 100%);
}
&.points {
background: linear-gradient(135deg, #667EEA 0%, #5A4BD1 100%);
}
.icon-img {
width: 40rpx;
height: 40rpx;
}
.icon-dot {
position: absolute;
top: -4rpx;
right: -4rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FF4757, #FF2D55);
border: 2.5rpx solid white;
display: flex;
align-items: center;
justify-content: center;
animation: pulse 2s ease-in-out infinite;
box-shadow: 0 0 0 3rpx rgba(255, 71, 87, 0.15);
.dot-inner {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: white;
opacity: 0.9;
}
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 3rpx rgba(255, 71, 87, 0.15);
}
50% {
transform: scale(1.1);
box-shadow: 0 0 0 6rpx rgba(255, 71, 87, 0.08);
}
}
.message-content {
flex: 1;
min-width: 0;
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6rpx;
.title-wrapper {
display: flex;
align-items: center;
gap: 8rpx;
flex: 1;
min-width: 0;
.message-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.unread-tag {
flex-shrink: 0;
font-size: 16rpx;
color: #FF4757;
background: rgba(255, 71, 87, 0.08);
padding: 2rpx 10rpx;
border-radius: 8rpx;
font-weight: 700;
animation: tagPop 0.3s ease;
border: 1rpx solid rgba(255, 71, 87, 0.08);
letter-spacing: 0.5rpx;
line-height: 1.4;
}
}
.message-time {
font-size: 22rpx;
color: var(--text-light);
flex-shrink: 0;
margin-left: 12rpx;
font-weight: 400;
}
}
.message-desc {
font-size: 26rpx;
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
margin-bottom: 8rpx;
}
.message-footer {
display: flex;
align-items: center;
justify-content: space-between;
.message-type {
font-size: 20rpx;
color: var(--text-light);
opacity: 0.6;
background: rgba(0, 0, 0, 0.03);
padding: 2rpx 12rpx;
border-radius: 8rpx;
}
.message-actions {
.action-mark {
font-size: 20rpx;
color: var(--accent-orange);
opacity: 0.7;
padding: 2rpx 12rpx;
background: rgba(255, 107, 53, 0.06);
border-radius: 8rpx;
&:active {
opacity: 0.4;
}
}
}
}
}
.message-arrow {
margin-left: 12rpx;
flex-shrink: 0;
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.02);
transition: all 0.2s ease;
text {
color: var(--text-light);
font-size: 28rpx;
opacity: 0.25;
font-weight: 300;
}
}
}
@keyframes tagPop {
0% { transform: scale(0.5); opacity: 0; }
70% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
/* 骨架屏项样式 */
.skeleton-item {
.skeleton-icon {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-title {
height: 32rpx;
width: 60%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-desc {
height: 28rpx;
width: 85%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-footer {
height: 24rpx;
width: 40%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 加载更多状态 */
.loading-more-state {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx 0;
gap: 16rpx;
.loading-spinner {
width: 36rpx;
height: 36rpx;
.spinner {
width: 100%;
height: 100%;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
&.small {
width: 32rpx;
height: 32rpx;
}
}
.loading-text {
font-size: 26rpx;
color: #94a3b8;
}
}
/* 已经到底啦 */
.no-more {
padding: 32rpx 0;
text-align: center;
text {
font-size: 24rpx;
color: #94a3b8;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>