1594 lines
43 KiB
Vue
1594 lines
43 KiB
Vue
<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> |