浏览代码

feat(message): 实现消息弹窗页面活跃状态检查机制

- 新增页面活跃状态标志 isPageActive
- 在 onShow 和 onHide 生命周期中管理页面状态
- 弹窗显示前检查页面是否活跃,避免在隐藏页面显示弹窗
- 添加未读消息数量显示功能
- 优化消息弹窗逻辑,提升用户体验
- 新增病人消息记录页面,支持查看和发送消息
- 完善消息类型和状态展示
- 实现消息发送功能,支持多种通知方式
- 添加页面间消息已读事件通信
- 更新导航栏和页面样式,增强视觉效果
mcbaiyun 3 周之前
父节点
当前提交
61a88ea8f3

+ 95 - 0
docs/patient-home-popup-message-page-active-check.md

@@ -0,0 +1,95 @@
+# 病人首页弹窗消息页面活跃状态检查优化
+
+## 问题描述
+
+在病人首页实现弹窗消息功能时,我们遇到了一个用户体验问题:当服务器响应速度较慢时,用户已经导航到其他页面,但弹窗消息仍然会弹出。这是因为异步请求在页面隐藏后仍然会执行其回调函数,导致在不合适的时机显示弹窗。
+
+具体表现为:
+1. 用户进入首页,触发获取未读消息的请求
+2. 服务器响应较慢,用户在此期间导航到其他页面
+3. 当请求响应到达时,页面已经隐藏,但弹窗仍然会显示
+4. 这给用户造成困惑,因为弹窗出现在错误的上下文中
+
+## 问题分析
+
+问题的根本原因在于异步请求的生命周期管理:
+
+1. `checkAndShowPopupMessages` 函数在 `onShow` 生命周期中被调用
+2. 该函数发起异步请求获取消息列表
+3. 使用 `setTimeout` 延迟1秒显示弹窗,确保页面加载完成
+4. 如果在这1秒延迟期间用户离开页面,弹窗仍然会显示
+
+这种行为在网络条件较差或服务器负载较高的情况下尤为明显,因为请求响应时间变长,用户更容易在等待期间离开页面。
+
+## 解决方案
+
+### 核心思路
+
+引入页面活跃状态跟踪机制,在显示弹窗前检查页面是否仍然可见。只有当页面处于活跃状态时才显示弹窗。
+
+### 实现步骤
+
+1. 添加页面活跃状态标志 `isPageActive`
+2. 在页面显示时(`onShow`)设置状态为活跃
+3. 在页面隐藏时(`onHide`)设置状态为非活跃
+4. 在弹窗显示前检查页面活跃状态
+5. 只有页面活跃时才执行弹窗显示逻辑
+
+### 代码示例
+
+```javascript
+// 添加页面活跃状态标志
+const isPageActive = ref(false)
+
+// 在页面显示时设置为活跃
+onShow(() => {
+  isPageActive.value = true
+  // ... 其他逻辑
+  checkAndShowPopupMessages()
+})
+
+// 在页面隐藏时设置为非活跃
+onHide(() => {
+  isPageActive.value = false
+})
+
+// 修改弹窗检查函数
+const checkAndShowPopupMessages = async () => {
+  try {
+    // 获取消息列表...
+    
+    if (popupMessages.length > 0) {
+      const latestMessage = popupMessages[0]
+      
+      setTimeout(() => {
+        // 关键检查:页面是否仍然活跃
+        if (!isPageActive.value) {
+          return // 页面已隐藏,不显示弹窗
+        }
+        
+        uni.showModal({
+          // 显示弹窗...
+        })
+      }, 1000)
+    }
+  } catch (error) {
+    console.error('检查弹窗消息失败:', error)
+  }
+}
+```
+
+## 最佳实践
+
+1. **生命周期管理**:正确使用页面生命周期钩子来跟踪页面状态
+2. **状态检查**:在执行任何可能影响用户界面的异步操作前,检查页面是否仍然活跃
+3. **延迟操作保护**:对于包含延迟的异步操作,确保在执行前验证上下文的有效性
+4. **资源清理**:在页面卸载时清理可能仍在进行的异步操作
+5. **用户体验优先**:避免在不恰当的时机显示用户界面元素
+
+## 总结
+
+通过引入页面活跃状态检查机制,我们成功解决了弹窗消息在页面隐藏后仍然显示的问题。这种解决方案的核心在于理解异步操作的生命周期,并通过状态管理来控制UI更新的时机。
+
+该方案不仅适用于弹窗消息场景,也适用于其他需要在页面特定状态下执行的异步操作,如数据加载、动画播放等。关键在于建立页面状态与异步操作之间的关联,确保UI行为始终符合用户的当前上下文。
+
+这种设计提高了应用的健壮性和用户体验,尤其在网络条件不稳定的环境下能够提供更一致的交互体验。

+ 232 - 0
src/api/message.ts

@@ -0,0 +1,232 @@
+import request from './request'
+import { safeJsonParse } from '@/utils/jsonBig'
+
+// 消息实体
+export interface Message {
+  id: string
+  senderId: string
+  senderName?: string
+  receiverId: string
+  content: string
+  contentFormat: string
+  type: string
+  notifyPopup: boolean
+  notifySubscribe: boolean
+  status: number
+  readTime?: string
+  createTime: string
+}
+
+// 分页查询参数
+export interface MessageQueryParams {
+  page?: number
+  size?: number
+  status?: number
+}
+
+export interface SendMessageRequest {
+  receiverIds: string[]
+  content: string
+  contentFormat?: string
+  type: string
+  notifyFamily?: boolean
+  notifyPopup?: boolean
+  notifySubscribe?: boolean
+}
+
+export interface SystemDailySendRequest {
+  receiverIds: string[]
+  content: string
+  type: string
+}
+
+export interface SystemAnomalySendRequest {
+  patientId: string
+  anomalyData?: any
+  type: string
+}
+
+export interface MessagePageResponse {
+  records: Message[]
+  total: number
+  size: number
+  current: number
+  pages: number
+}
+
+// 医生患者消息响应
+export interface DoctorPatientMessagesResponse {
+  patientMessages: Message[]
+  familyMessages: Message[]
+}
+
+/**
+ * 医生发送消息
+ */
+export async function sendMessage(payload: SendMessageRequest) {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/messages/send',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json' },
+    data: payload
+  })
+  return res
+}
+
+/**
+ * 系统发送日常消息
+ */
+export async function sendSystemDailyMessage(payload: SystemDailySendRequest) {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/messages/system-daily-send',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json' },
+    data: payload
+  })
+  return res
+}
+
+/**
+ * 系统发送异常通知
+ */
+export async function sendSystemAnomalyMessage(payload: SystemAnomalySendRequest) {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/messages/system-anomaly-send',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json' },
+    data: payload
+  })
+  return res
+}
+
+/**
+ * 获取消息列表(分页)
+ */
+export async function getMessageList(query: MessageQueryParams) {
+  const params = [] as string[]
+  if (query.page != null) params.push(`page=${encodeURIComponent(String(query.page))}`)
+  if (query.size != null) params.push(`size=${encodeURIComponent(String(query.size))}`)
+  if (query.status != null) params.push(`status=${encodeURIComponent(String(query.status))}`)
+  const q = params.length ? `?${params.join('&')}` : ''
+
+  const res: any = await request({
+    url: `https://wx.baiyun.work/messages/list${q}`,
+    method: 'GET'
+  })
+  // ensure ids are strings
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data && Array.isArray(parsed.data.records)) {
+      parsed.data.records = parsed.data.records.map((r: any) => ({
+        ...r,
+        id: String(r.id),
+        senderId: r.senderId ? String(r.senderId) : '',
+        receiverId: r.receiverId ? String(r.receiverId) : ''
+      }))
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}
+
+/**
+ * 获取消息详情
+ */
+export async function getMessageById(id: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/messages/${encodeURIComponent(String(id))}`,
+    method: 'GET'
+  })
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data) {
+      parsed.data = {
+        ...parsed.data,
+        id: String(parsed.data.id),
+        senderId: parsed.data.senderId ? String(parsed.data.senderId) : '',
+        receiverId: parsed.data.receiverId ? String(parsed.data.receiverId) : ''
+      }
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}
+
+/**
+ * 标记消息已读
+ */
+export async function markMessageAsRead(id: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/messages/${encodeURIComponent(String(id))}/read`,
+    method: 'PUT'
+  })
+  return res
+}
+
+/**
+ * 删除消息
+ */
+export async function deleteMessage(id: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/messages/${encodeURIComponent(String(id))}`,
+    method: 'DELETE'
+  })
+  return res
+}
+
+/**
+ * 获取未读消息数量
+ */
+export async function getUnreadMessageCount() {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/messages/unread-count',
+    method: 'GET'
+  })
+  return res
+}
+
+/**
+ * 获取医生患者和家属的消息
+ */
+export async function getDoctorPatientMessages(patientId: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/messages/doctor/patient/${encodeURIComponent(String(patientId))}`,
+    method: 'GET'
+  })
+  // ensure ids are strings in both patient and family messages
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data) {
+      const data = parsed.data as DoctorPatientMessagesResponse
+      
+      // Process patient messages
+      if (data.patientMessages && Array.isArray(data.patientMessages)) {
+        data.patientMessages = data.patientMessages.map((msg: any) => ({
+          ...msg,
+          id: String(msg.id),
+          senderId: msg.senderId ? String(msg.senderId) : '',
+          receiverId: msg.receiverId ? String(msg.receiverId) : ''
+        }))
+      }
+      
+      // Process family messages
+      if (data.familyMessages && Array.isArray(data.familyMessages)) {
+        data.familyMessages = data.familyMessages.map((msg: any) => ({
+          ...msg,
+          id: String(msg.id),
+          senderId: msg.senderId ? String(msg.senderId) : '',
+          receiverId: msg.receiverId ? String(msg.receiverId) : ''
+        }))
+      }
+      
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  return res
+}

+ 8 - 0
src/pages.json

@@ -192,6 +192,14 @@
 				"navigationBarTitleText": "我的病人"
 			}
 		},
+		{
+			"path": "pages/doctor/index/patient-messages",
+			"style": {
+				"navigationBarTitleText": "消息记录",
+				"enablePullDownRefresh": false,
+				"navigationBarBackgroundColor": "#F8F8F8"
+			}
+		},
 		{
 			"path": "pages/doctor/manage/medicine",
 			"style": {

+ 113 - 3
src/pages/doctor/index/index.vue

@@ -15,7 +15,7 @@
           </view>
           <view class="message-button" @click="onMessageClick">
             <image src="/static/icons/remixicon/message-3-line.svg" class="message-icon" />
-            <view class="badge">3</view>
+            <view class="badge" v-if="unreadMessageCount > 0">{{ unreadMessageCount > 99 ? '99+' : unreadMessageCount }}</view>
           </view>
           <view class="qr-button" @click="onQrClick">
             <image src="/static/icons/remixicon/qr-code-line.svg" class="qr-icon" />
@@ -99,7 +99,7 @@
 
 <script setup lang="ts">
 import { ref, computed } from 'vue'
-import { onShow } from '@dcloudio/uni-app'
+import { onShow, onHide } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import { fetchUserInfo as fetchUserInfoApi } from '@/api/user'
@@ -107,9 +107,16 @@ import request from '@/api/request'
 import { handleQrScanResult } from '@/utils/qr'
 import { queryBoundPatientsActivities } from '@/api/userActivity'
 import { avatarCache } from '@/utils/avatarCache'
+import { getUnreadMessageCount, getMessageList, markMessageAsRead, type MessageQueryParams } from '@/api/message'
 
 const user = ref<{ avatar?: string; nickname?: string; title?: string }>({})
 
+// 未读消息数量
+const unreadMessageCount = ref(0)
+
+// 页面活跃状态标志
+const isPageActive = ref(false)
+
 const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
 
 const avatarSrc = computed(() => {
@@ -263,6 +270,93 @@ const fetchPatientActivities = async () => {
   }
 }
 
+// 获取未读消息数量
+const fetchUnreadMessageCount = async () => {
+  try {
+    const response = await getUnreadMessageCount()
+    const resp = response.data as any
+    if (resp && resp.code === 200) {
+      unreadMessageCount.value = resp.data || 0
+    }
+  } catch (error) {
+    console.error('获取未读消息数量失败:', error)
+  }
+}
+
+// 检查并显示需要弹窗的消息
+const checkAndShowPopupMessages = async () => {
+  try {
+    // 获取最近的消息,检查是否有需要弹窗的未读消息
+    const params: MessageQueryParams = {
+      page: 1,
+      size: 10,
+      status: 0 // 未读消息
+    }
+
+    const response = await getMessageList(params)
+    const resp = response.data as any
+
+    if (resp && resp.code === 200 && resp.data) {
+      const messages = resp.data.records || []
+
+      // 查找需要弹窗的消息(notifyPopup为true且未读)
+      const popupMessages = messages.filter((msg: any) => msg.notifyPopup && msg.status === 0)
+
+      if (popupMessages.length > 0) {
+        // 显示最新的弹窗消息
+        const latestMessage = popupMessages[0]
+
+        // 延迟一点时间显示弹窗,确保页面加载完成
+        setTimeout(() => {
+          // 检查页面是否仍然活跃
+          if (!isPageActive.value) {
+            return
+          }
+          uni.showModal({
+            title: getMessageTitle(latestMessage.type),
+            content: latestMessage.content,
+            showCancel: false,
+            confirmText: '我知道了',
+            success: () => {
+              // 用户确认后,自动标记为已读
+              markMessageAsReadLocal(latestMessage.id)
+            }
+          })
+        }, 1000)
+      }
+    }
+  } catch (error) {
+    console.error('检查弹窗消息失败:', error)
+  }
+}
+
+// 标记消息已读
+const markMessageAsReadLocal = async (messageId: string) => {
+  try {
+    await markMessageAsRead(messageId)
+    // 更新未读消息数量
+    if (unreadMessageCount.value > 0) {
+      unreadMessageCount.value--
+    }
+  } catch (error) {
+    console.error('标记消息已读失败:', error)
+  }
+}
+
+// 获取消息标题
+const getMessageTitle = (type: string) => {
+  switch (type) {
+    case 'SYSTEM_DAILY':
+      return '系统通知'
+    case 'DOCTOR':
+      return '医生消息'
+    case 'SYSTEM_ANOMALY':
+      return '异常通知'
+    default:
+      return '消息提醒'
+  }
+}
+
 // 格式化时间显示
 const formatTime = (createTime: string) => {
   try {
@@ -286,7 +380,15 @@ const formatTime = (createTime: string) => {
 }
 
 function onMessageClick() {
-  uni.showToast({ title: '功能正在开发中', icon: 'none' })
+  uni.navigateTo({ 
+    url: '/pages/public/message-detail',
+    events: {
+      // 监听消息详情页的消息已读事件
+      messageRead: () => {
+        fetchUnreadMessageCount()
+      }
+    }
+  })
 }
 
 // 获取患者头像
@@ -323,6 +425,7 @@ const getPatientAvatar = async (userId: string): Promise<string> => {
 
 // 如果在微信小程序端且未登录,自动跳转到登录页
 onShow(() => {
+  isPageActive.value = true
   const token = uni.getStorageSync('token')
   if (!token) {
     // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
@@ -331,9 +434,16 @@ onShow(() => {
     fetchUserInfo()
     fetchTodayReminders()
     fetchPatientActivities()
+    fetchUnreadMessageCount()
+    checkAndShowPopupMessages()
   }
 })
 
+// 页面隐藏时设置不活跃
+onHide(() => {
+  isPageActive.value = false
+})
+
 function handleScan(res: any) {
   return handleQrScanResult(res)
 }

+ 27 - 1
src/pages/doctor/index/my-patients.vue

@@ -17,6 +17,7 @@
       
       <view class="action-buttons">
         <button class="action-btn primary" @click="viewHealthData(patient)">健康数据</button>
+        <button class="action-btn info" @click="viewMessageHistory(patient)">消息记录</button>
         <button class="action-btn danger" @click="unbindPatient(patient)">解除绑定</button>
       </view>
     </view>
@@ -48,7 +49,7 @@ const pageData = ref({
   pages: 0
 })
 
-const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/'
 
 // 获取病人列表
 const fetchPatients = async () => {
@@ -148,6 +149,21 @@ const viewHealthData = (patient: PatientInfo) => {
   })
 }
 
+const viewMessageHistory = (patient: PatientInfo) => {
+  // 跳转到消息记录页面
+  uni.navigateTo({
+    url: `/pages/doctor/index/patient-messages?patientId=${patient.patientUserId}`
+  })
+}
+
+// 复选框变化
+// const onCheckboxChange = (e: any) => {
+//   const values = e.detail.value
+//   sendForm.value.notifyFamily = values.includes('notifyFamily')
+//   sendForm.value.notifyPopup = values.includes('notifyPopup')
+//   sendForm.value.notifySubscribe = values.includes('notifySubscribe')
+// }
+
 // 解除绑定
 const unbindPatient = (patient: PatientInfo) => {
   uni.showModal({
@@ -286,6 +302,16 @@ onShow(() => {
   color: #fff;
 }
 
+.info {
+  background-color: #17a2b8;
+  color: #fff;
+}
+
+.secondary {
+  background-color: #ffa726;
+  color: #fff;
+}
+
 .danger {
   background-color: #ff4757;
   color: #fff;

+ 644 - 0
src/pages/doctor/index/patient-messages.vue

@@ -0,0 +1,644 @@
+<template>
+  <CustomNav title="消息记录" leftType="back" />
+  <view class="page-container">
+    <!-- 切换按钮 -->
+    <view class="tab-container">
+      <view class="tab-buttons">
+        <button
+          class="tab-btn"
+          :class="{ active: activeTab === 'patient' }"
+          @click="switchTab('patient')"
+        >
+          病人消息
+        </button>
+        <button
+          class="tab-btn"
+          :class="{ active: activeTab === 'family' }"
+          @click="switchTab('family')"
+        >
+          家属消息
+        </button>
+      </view>
+    </view>
+
+    <!-- 消息列表 -->
+    <view class="messages-container">
+      <view class="messages-list" v-if="activeTab === 'patient'">
+        <view class="message-item" v-for="message in patientMessages" :key="message.id">
+          <view class="message-header">
+            <view class="message-info">
+              <text class="message-time">{{ formatTime(message.createTime) }}</text>
+              <view class="read-status">
+                <text class="status-text" :class="{ unread: message.status === 0 }">
+                  {{ message.status === 0 ? '未读' : '已读' }}
+                </text>
+              </view>
+              <view class="popup-status" v-if="message.notifyPopup">
+                <text class="popup-text">弹窗通知</text>
+              </view>
+              <view class="subscribe-status" v-if="message.notifySubscribe">
+                <text class="subscribe-text">推送订阅</text>
+              </view>
+            </view>
+          </view>
+          <view class="message-content">
+            <text class="content-text">{{ message.content }}</text>
+          </view>
+          <view class="message-footer">
+            <text class="message-type">{{ getMessageTypeText(message.type) }}</text>
+          </view>
+        </view>
+
+        <view class="empty-state" v-if="patientMessages.length === 0">
+          <image class="empty-icon" src="/static/icons/remixicon/chat-1-line.svg" />
+          <text class="empty-text">暂无病人消息记录</text>
+        </view>
+      </view>
+
+      <view class="messages-list" v-if="activeTab === 'family'">
+        <view class="message-item" v-for="message in familyMessages" :key="message.id">
+          <view class="message-header">
+            <view class="message-info">
+              <text class="message-time">{{ formatTime(message.createTime) }}</text>
+              <view class="read-status">
+                <text class="status-text" :class="{ unread: message.status === 0 }">
+                  {{ message.status === 0 ? '未读' : '已读' }}
+                </text>
+              </view>
+              <view class="popup-status" v-if="message.notifyPopup">
+                <text class="popup-text">弹窗通知</text>
+              </view>
+              <view class="subscribe-status" v-if="message.notifySubscribe">
+                <text class="subscribe-text">推送订阅</text>
+              </view>
+            </view>
+          </view>
+          <view class="message-content">
+            <text class="content-text">{{ message.content }}</text>
+          </view>
+          <view class="message-footer">
+            <text class="message-type">{{ getMessageTypeText(message.type) }}</text>
+          </view>
+        </view>
+
+        <view class="empty-state" v-if="familyMessages.length === 0">
+          <image class="empty-icon" src="/static/icons/remixicon/chat-1-line.svg" />
+          <text class="empty-text">暂无家属消息记录</text>
+        </view>
+      </view>
+    </view>
+  </view>
+  <!-- 悬浮发送消息按钮 -->
+  <view class="fab" @click="showSendModal" role="button" aria-label="发送消息">
+    <view class="fab-inner">
+      <uni-icons type="chat" size="28" color="#fff" />
+    </view>
+  </view>
+
+  <!-- 发送消息模态框 -->
+  <view class="modal" v-if="showSendModalFlag">
+    <view class="modal-backdrop" @click="cancelSend"></view>
+    <view class="modal-panel">
+      <view class="drag-handle"></view>
+      <view class="modal-header">
+        <text class="modal-title">发送消息</text>
+      </view>
+      <view class="modal-inner">
+        <view class="form-row">
+          <text class="label">消息内容</text>
+          <textarea 
+            v-model="sendForm.content" 
+            placeholder="请输入消息内容" 
+            class="message-input"
+            :maxlength="500"
+          />
+        </view>
+        <view class="form-row checkbox-row">
+          <view class="switch-list">
+            <view class="switch-item">
+              <text class="switch-label">通知家属</text>
+              <switch :checked="sendForm.notifyFamily" @change="(e: any) => sendForm.notifyFamily = e.detail.value" />
+            </view>
+            <view class="switch-item">
+              <text class="switch-label">弹窗通知</text>
+              <switch :checked="sendForm.notifyPopup" @change="(e: any) => sendForm.notifyPopup = e.detail.value" />
+            </view>
+            <view class="switch-item">
+              <text class="switch-label">推送订阅</text>
+              <switch :checked="sendForm.notifySubscribe" @change="(e: any) => sendForm.notifySubscribe = e.detail.value" />
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="fixed-footer">
+        <button class="btn-cancel" @click="cancelSend">取消</button>
+        <button class="btn-primary" @click="confirmSend">发送</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import CustomNav from '@/components/custom-nav.vue'
+import { getDoctorPatientMessages, type DoctorPatientMessagesResponse, type Message } from '@/api/message'
+import { sendMessage as sendMessageApi, type SendMessageRequest } from '@/api/message'
+
+const activeTab = ref<'patient' | 'family'>('patient')
+const patientMessages = ref<Message[]>([])
+const familyMessages = ref<Message[]>([])
+const patientId = ref<string>('')
+
+// 发送消息相关
+const showSendModalFlag = ref(false)
+const sendForm = ref({
+  content: '',
+  notifyFamily: false,
+  notifyPopup: false,
+  notifySubscribe: false
+})
+
+// 切换标签
+const switchTab = (tab: 'patient' | 'family') => {
+  activeTab.value = tab
+}
+
+// 格式化时间
+const formatTime = (timeStr: string) => {
+  if (!timeStr) return ''
+  const date = new Date(timeStr)
+  return date.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+// 获取消息类型文本
+const getMessageTypeText = (type: string) => {
+  switch (type) {
+    case 'DOCTOR':
+      return '医生消息'
+    case 'SYSTEM_DAILY':
+      return '系统日常'
+    case 'SYSTEM_ANOMALY':
+      return '异常提醒'
+    default:
+      return '未知类型'
+  }
+}
+
+// 获取消息记录
+const fetchMessages = async () => {
+  if (!patientId.value) return
+
+  try {
+    uni.showLoading({ title: '加载中...' })
+
+    const response = await getDoctorPatientMessages(patientId.value)
+    uni.hideLoading()
+
+    const resp = response.data as any
+    if (resp && resp.code === 200 && resp.data) {
+      const data = resp.data as DoctorPatientMessagesResponse
+      patientMessages.value = data.patientMessages || []
+      familyMessages.value = data.familyMessages || []
+    } else {
+      uni.showToast({
+        title: '获取消息记录失败',
+        icon: 'none'
+      })
+    }
+  } catch (error) {
+    uni.hideLoading()
+    console.error('获取消息记录失败:', error)
+    uni.showToast({
+      title: '获取消息记录失败',
+      icon: 'none'
+    })
+  }
+}
+
+onLoad((options: any) => {
+  if (options.patientId) {
+    patientId.value = options.patientId
+    fetchMessages()
+  } else {
+    uni.showToast({
+      title: '缺少患者ID参数',
+      icon: 'none'
+    })
+    uni.navigateBack()
+  }
+})
+
+// 发送消息相关方法
+const showSendModal = () => {
+  sendForm.value.content = '医生提醒:请按时服药,保持健康生活习惯。'
+  sendForm.value.notifyFamily = false
+  sendForm.value.notifyPopup = false
+  sendForm.value.notifySubscribe = false
+  showSendModalFlag.value = true
+}
+
+const cancelSend = () => {
+  showSendModalFlag.value = false
+}
+
+const confirmSend = async () => {
+  if (!sendForm.value.content.trim()) {
+    uni.showToast({ title: '请输入消息内容', icon: 'none' })
+    return
+  }
+
+  try {
+    uni.showLoading({ title: '发送中...' })
+    
+    const receiverIds = [patientId.value]
+    // TODO: 如果 notifyFamily,添加家属ID
+    
+    const request: SendMessageRequest = {
+      receiverIds,
+      content: sendForm.value.content,
+      contentFormat: 'PLAIN',
+      type: 'DOCTOR',
+      notifyFamily: sendForm.value.notifyFamily,
+      notifyPopup: sendForm.value.notifyPopup,
+      notifySubscribe: sendForm.value.notifySubscribe
+    }
+    
+    const response = await sendMessageApi(request)
+    
+    uni.hideLoading()
+    
+    const resp = response.data as any
+    if (resp && resp.code === 200) {
+      uni.showToast({ title: '消息发送成功', icon: 'success' })
+      showSendModalFlag.value = false
+      
+      // 重新获取消息记录
+      fetchMessages()
+    } else {
+      uni.showToast({ title: resp?.message || '发送失败', icon: 'none' })
+    }
+  } catch (error) {
+    uni.hideLoading()
+    console.error('发送消息失败:', error)
+    uni.showToast({ title: '发送失败', icon: 'none' })
+  }
+}
+</script>
+
+<style scoped>
+.page-container {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding-top: calc(var(--status-bar-height) + 44px);
+}
+
+/* 切换按钮样式 */
+.tab-container {
+  background-color: #fff;
+  padding: 20rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
+}
+
+.tab-buttons {
+  display: flex;
+  border-radius: 12rpx;
+  overflow: hidden;
+  background-color: #f0f0f0;
+}
+
+.tab-btn {
+  flex: 1;
+  height: 80rpx;
+  border: none;
+  background-color: transparent;
+  color: #666;
+  font-size: 32rpx;
+  position: relative;
+  transition: all 0.3s ease;
+}
+
+.tab-btn.active {
+  background-color: #3742fa;
+  color: #fff;
+  font-weight: bold;
+}
+
+.tab-btn::after {
+  content: '';
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 1rpx;
+  height: 40rpx;
+  background-color: #ddd;
+}
+
+.tab-btn:last-child::after {
+  display: none;
+}
+
+/* 消息列表样式 */
+.messages-container {
+  padding: 0 20rpx;
+}
+
+.messages-list {
+  background-color: #fff;
+  border-radius: 20rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
+}
+
+.message-item {
+  padding: 30rpx 40rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.message-item:last-child {
+  border-bottom: none;
+}
+
+.message-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+
+.message-info {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+
+.message-time {
+  font-size: 28rpx;
+  color: #999;
+}
+
+.read-status {
+  padding: 8rpx 16rpx;
+  border-radius: 20rpx;
+  background-color: #e8f5e8;
+}
+
+.status-text {
+  font-size: 24rpx;
+  color: #52c41a;
+  font-weight: 500;
+}
+
+.status-text.unread {
+  color: #ff4d4f;
+  background-color: #ffebe9;
+}
+
+.popup-status {
+  padding: 8rpx 16rpx;
+  border-radius: 20rpx;
+  background-color: #fff2e8;
+  border: 1rpx solid #ffbb96;
+}
+
+.popup-text {
+  font-size: 24rpx;
+  color: #fa8c16;
+  font-weight: 500;
+}
+
+.subscribe-status {
+  padding: 8rpx 16rpx;
+  border-radius: 20rpx;
+  background-color: #e6f7ff;
+  border: 1rpx solid #91d5ff;
+}
+
+.subscribe-text {
+  font-size: 24rpx;
+  color: #1890ff;
+  font-weight: 500;
+}
+
+.message-content {
+  margin-bottom: 20rpx;
+}
+
+.content-text {
+  font-size: 32rpx;
+  color: #333;
+  line-height: 1.6;
+}
+
+.message-footer {
+  text-align: right;
+}
+
+.message-type {
+  font-size: 26rpx;
+  color: #999;
+  background-color: #f5f5f5;
+  padding: 6rpx 12rpx;
+  border-radius: 12rpx;
+}
+
+/* 空状态样式 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 100rpx 40rpx;
+}
+
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  margin-bottom: 30rpx;
+  opacity: 0.5;
+}
+
+.empty-text {
+  font-size: 32rpx;
+  color: #666;
+}
+
+/* 悬浮按钮 */
+.fab {
+  position: fixed;
+  right: 28rpx;
+  bottom: 160rpx;
+  width: 110rpx;
+  height: 110rpx;
+  border-radius: 999px;
+  background: linear-gradient(180deg, #4a90e2, #2d8cf0);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2);
+  z-index: 1200;
+  border: none;
+}
+.fab:active { transform: translateY(2rpx); }
+.fab:focus { outline: none; }
+.fab-inner { 
+  color: #fff; 
+  font-size: 28rpx; 
+  line-height: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* 发送消息模态框样式 */
+.modal {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  z-index: 1300;
+}
+
+.modal-backdrop {
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.4);
+}
+
+.modal-panel {
+  position: relative;
+  width: 100%;
+  background: #fff;
+  border-top-left-radius: 18rpx;
+  border-top-right-radius: 18rpx;
+  padding: 28rpx 24rpx 140rpx 24rpx;
+  box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12);
+}
+
+.drag-handle {
+  width: 64rpx;
+  height: 6rpx;
+  background: rgba(0,0,0,0.08);
+  border-radius: 999px;
+  margin: 10rpx auto 14rpx auto;
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+  margin-bottom: 6rpx;
+}
+
+.modal-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  letter-spacing: 1rpx;
+}
+
+.modal-inner {
+  max-width: 90%;
+  margin: 0 auto;
+}
+
+.form-row {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 34rpx;
+  padding: 14rpx 0;
+}
+
+.checkbox-row {
+  margin-top: 20rpx;
+}
+
+.label {
+  color: #666;
+  font-size: 32rpx;
+  margin-bottom: 16rpx;
+  font-weight: 500;
+}
+
+.message-input {
+  width: 100%;
+  min-height: 200rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 14rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  line-height: 1.5;
+  box-sizing: border-box;
+  background: #fff;
+}
+
+.switch-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.switch-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx;
+  background-color: #f9f9f9;
+  border-radius: 8rpx;
+}
+
+.switch-label {
+  font-size: 32rpx;
+  color: #333;
+}
+
+.fixed-footer {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 40rpx;
+  padding: 0 24rpx;
+  display: flex;
+  gap: 20rpx;
+  justify-content: center;
+}
+
+.btn-cancel {
+  width: 220rpx;
+  height: 100rpx;
+  padding: 0;
+  border-radius: 12rpx;
+  background: #f5f5f5;
+  color: #666;
+  border: none;
+  font-size: 32rpx;
+}
+
+.btn-primary {
+  width: 220rpx;
+  height: 100rpx;
+  padding: 0;
+  border-radius: 12rpx;
+  background: #3742fa;
+  color: #fff;
+  border: none;
+  font-size: 32rpx;
+  box-shadow: 0 10rpx 28rpx rgba(55, 66, 250, 0.18);
+}
+</style>

+ 113 - 3
src/pages/patient-family/index/index.vue

@@ -15,7 +15,7 @@
           </view>
           <view class="message-button" @click="onMessageClick">
             <image src="/static/icons/remixicon/message-3-line.svg" class="message-icon" />
-            <view class="badge">3</view>
+            <view class="badge" v-if="unreadMessageCount > 0">{{ unreadMessageCount > 99 ? '99+' : unreadMessageCount }}</view>
           </view>
           <view class="qr-button" @click="onQrClick">
             <image src="/static/icons/remixicon/qr-code-line.svg" class="qr-icon" />
@@ -74,7 +74,7 @@
 
 <script setup lang="ts">
 import { ref, computed, watch, onUnmounted } from 'vue'
-import { onShow } from '@dcloudio/uni-app'
+import { onShow, onHide } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import { fetchUserInfo as fetchUserInfoApi } from '@/api/user'
 import TabBar from '@/components/tab-bar.vue'
@@ -84,9 +84,16 @@ import { avatarCache } from '@/utils/avatarCache'
 import { listUserBindingsByBoundUser, type UserBindingResponse, type UserBindingPageResponse } from '@/api/userBinding'
 import { downloadWithAuth } from '@/utils/downloadWithAuth'
 import { getNewsList, News } from '@/api/news'
+import { getUnreadMessageCount, getMessageList, markMessageAsRead, type MessageQueryParams } from '@/api/message'
 
 const user = ref<{ avatar?: string; nickname?: string }>({})
 
+// 未读消息数量
+const unreadMessageCount = ref(0)
+
+// 页面活跃状态标志
+const isPageActive = ref(false)
+
 const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
 
 const avatarSrc = computed(() => {
@@ -223,8 +230,96 @@ const fetchTodayReminders = async () => {
   }
 }
 
+// 获取未读消息数量
+const fetchUnreadMessageCount = async () => {
+  try {
+    const response = await getUnreadMessageCount()
+    const resp = response.data as any
+    if (resp && resp.code === 200) {
+      unreadMessageCount.value = resp.data || 0
+    }
+  } catch (error) {
+    console.error('获取未读消息数量失败:', error)
+  }
+}
+
+// 检查并显示需要弹窗的消息
+const checkAndShowPopupMessages = async () => {
+  try {
+    // 获取最近的消息,检查是否有需要弹窗的未读消息
+    const params: MessageQueryParams = {
+      page: 1,
+      size: 10,
+      status: 0 // 未读消息
+    }
+
+    const response = await getMessageList(params)
+    const resp = response.data as any
+
+    if (resp && resp.code === 200 && resp.data) {
+      const messages = resp.data.records || []
+
+      // 查找需要弹窗的消息(notifyPopup为true且未读)
+      const popupMessages = messages.filter((msg: any) => msg.notifyPopup && msg.status === 0)
+
+      if (popupMessages.length > 0) {
+        // 显示最新的弹窗消息
+        const latestMessage = popupMessages[0]
+
+        // 延迟一点时间显示弹窗,确保页面加载完成
+        setTimeout(() => {
+          // 检查页面是否仍然活跃
+          if (!isPageActive.value) {
+            return
+          }
+          uni.showModal({
+            title: getMessageTitle(latestMessage.type),
+            content: latestMessage.content,
+            showCancel: false,
+            confirmText: '我知道了',
+            success: () => {
+              // 用户确认后,自动标记为已读
+              markMessageAsReadLocal(latestMessage.id)
+            }
+          })
+        }, 1000)
+      }
+    }
+  } catch (error) {
+    console.error('检查弹窗消息失败:', error)
+  }
+}
+
+// 标记消息已读
+const markMessageAsReadLocal = async (messageId: string) => {
+  try {
+    await markMessageAsRead(messageId)
+    // 更新未读消息数量
+    if (unreadMessageCount.value > 0) {
+      unreadMessageCount.value--
+    }
+  } catch (error) {
+    console.error('标记消息已读失败:', error)
+  }
+}
+
+// 获取消息标题
+const getMessageTitle = (type: string) => {
+  switch (type) {
+    case 'SYSTEM_DAILY':
+      return '系统通知'
+    case 'DOCTOR':
+      return '医生消息'
+    case 'SYSTEM_ANOMALY':
+      return '异常通知'
+    default:
+      return '消息提醒'
+  }
+}
+
 // 如果在微信小程序端且未登录,自动跳转到登录页
 onShow(() => {
+  isPageActive.value = true
   const token = uni.getStorageSync('token')
   if (!token) {
     // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
@@ -233,9 +328,16 @@ onShow(() => {
     fetchUserInfo()
     fetchTodayReminders()
     fetchNews()
+    fetchUnreadMessageCount()
+    checkAndShowPopupMessages()
   }
 })
 
+// 页面隐藏时设置不活跃
+onHide(() => {
+  isPageActive.value = false
+})
+
 function handleScan(res: any) {
   return handleQrScanResult(res)
 }
@@ -332,7 +434,15 @@ async function checkAndNavigateToHealthData() {
 }
 
 function onMessageClick() {
-  uni.showToast({ title: '功能正在开发中', icon: 'none' })
+  uni.navigateTo({ 
+    url: '/pages/public/message-detail',
+    events: {
+      // 监听消息详情页的消息已读事件
+      messageRead: () => {
+        fetchUnreadMessageCount()
+      }
+    }
+  })
 }
 
 function onQrClick() {

+ 113 - 3
src/pages/patient/index/index.vue

@@ -15,7 +15,7 @@
           </view>
           <view class="message-button" @click="onMessageClick">
             <image src="/static/icons/remixicon/message-3-line.svg" class="message-icon" />
-            <view class="badge">3</view>
+            <view class="badge" v-if="unreadMessageCount > 0">{{ unreadMessageCount > 99 ? '99+' : unreadMessageCount }}</view>
           </view>
           <view class="qr-button" @click="onQrClick">
             <image src="/static/icons/remixicon/qr-code-line.svg" class="qr-icon" />
@@ -115,7 +115,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
-import { onShow } from '@dcloudio/uni-app'
+import { onShow, onHide } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import { handleQrScanResult } from '@/utils/qr'
@@ -123,9 +123,16 @@ import { fetchUserInfo as fetchUserInfoApi, downloadAvatar as downloadAvatarApi
 import { downloadWithAuth } from '@/utils/downloadWithAuth'
 import { avatarCache } from '@/utils/avatarCache'
 import { getNewsList, News } from '@/api/news'
+import { getUnreadMessageCount, getMessageList, markMessageAsRead, type MessageQueryParams } from '@/api/message'
 
 const user = ref<{ avatar?: string; nickname?: string; age?: number }>({})
 
+// 未读消息数量
+const unreadMessageCount = ref(0)
+
+// 页面活跃状态标志
+const isPageActive = ref(false)
+
 const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
 
 const avatarSrc = computed(() => {
@@ -229,6 +236,93 @@ const fetchNews = async () => {
   }
 }
 
+// 获取未读消息数量
+const fetchUnreadMessageCount = async () => {
+  try {
+    const response = await getUnreadMessageCount()
+    const resp = response.data as any
+    if (resp && resp.code === 200) {
+      unreadMessageCount.value = resp.data || 0
+    }
+  } catch (error) {
+    console.error('获取未读消息数量失败:', error)
+  }
+}
+
+// 检查并显示需要弹窗的消息
+const checkAndShowPopupMessages = async () => {
+  try {
+    // 获取最近的消息,检查是否有需要弹窗的未读消息
+    const params: MessageQueryParams = {
+      page: 1,
+      size: 10,
+      status: 0 // 未读消息
+    }
+
+    const response = await getMessageList(params)
+    const resp = response.data as any
+
+    if (resp && resp.code === 200 && resp.data) {
+      const messages = resp.data.records || []
+
+      // 查找需要弹窗的消息(notifyPopup为true且未读)
+      const popupMessages = messages.filter((msg: any) => msg.notifyPopup && msg.status === 0)
+
+      if (popupMessages.length > 0) {
+        // 显示最新的弹窗消息
+        const latestMessage = popupMessages[0]
+
+        // 延迟一点时间显示弹窗,确保页面加载完成
+        setTimeout(() => {
+          // 检查页面是否仍然活跃
+          if (!isPageActive.value) {
+            return
+          }
+          uni.showModal({
+            title: getMessageTitle(latestMessage.type),
+            content: latestMessage.content,
+            showCancel: false,
+            confirmText: '我知道了',
+            success: () => {
+              // 用户确认后,自动标记为已读
+              markMessageAsReadLocal(latestMessage.id)
+            }
+          })
+        }, 1000)
+      }
+    }
+  } catch (error) {
+    console.error('检查弹窗消息失败:', error)
+  }
+}
+
+// 标记消息已读
+const markMessageAsReadLocal = async (messageId: string) => {
+  try {
+    await markMessageAsRead(messageId)
+    // 更新未读消息数量
+    if (unreadMessageCount.value > 0) {
+      unreadMessageCount.value--
+    }
+  } catch (error) {
+    console.error('标记消息已读失败:', error)
+  }
+}
+
+// 获取消息标题
+const getMessageTitle = (type: string) => {
+  switch (type) {
+    case 'SYSTEM_DAILY':
+      return '系统通知'
+    case 'DOCTOR':
+      return '医生消息'
+    case 'SYSTEM_ANOMALY':
+      return '异常通知'
+    default:
+      return '消息提醒'
+  }
+}
+
 const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
@@ -413,6 +507,7 @@ const fetchUserInfo = async () => {
 
 // 如果在微信小程序端且未登录,自动跳转到登录页
 onShow(() => {
+  isPageActive.value = true
   const token = uni.getStorageSync('token')
   if (!token) {
     // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
@@ -420,6 +515,8 @@ onShow(() => {
   } else {
     fetchUserInfo()
     fetchNews()
+    fetchUnreadMessageCount()
+    checkAndShowPopupMessages()
     // 生成打卡任务
     // generateCheckinTasks()
     // 清理过期记录
@@ -437,6 +534,11 @@ onMounted(() => {
   })
 })
 
+// 页面隐藏时设置不活跃
+onHide(() => {
+  isPageActive.value = false
+})
+
 // 在组件卸载前移除事件监听
 // 注意:在实际的uni-app项目中,可能需要根据具体生命周期进行调整
 // 如果存在onUnload钩子,应该在那里移除事件监听器
@@ -460,7 +562,15 @@ function onItemClick(type: string) {
 }
 
 function onMessageClick() {
-  uni.navigateTo({ url: '/pages/public/message-detail?id=1' })
+  uni.navigateTo({ 
+    url: '/pages/public/message-detail',
+    events: {
+      // 监听消息详情页的消息已读事件
+      messageRead: () => {
+        fetchUnreadMessageCount()
+      }
+    }
+  })
 }
 
 function onQrClick() {

+ 240 - 55
src/pages/public/message-detail.vue

@@ -1,33 +1,56 @@
 <template>
   <CustomNav title="消息详情" leftType="back" />
   <view class="page-container">
-    <view class="message-card" v-for="msg in messages" :key="msg.id">
-      <view class="message-header">
-        <view class="header-top">
-          <view class="header-left">
-            <text class="message-title">{{ getMessageTitle(msg.type) }}</text>
-          </view>
-          <view class="header-right">
-            <view class="action-buttons">
-              <button class="action-btn primary" v-if="!msg.isRead" @click="markAsRead(msg.id)">标记已读</button>
-              <view v-else class="read-indicator">
-                <uni-icons type="checkmarkempty" size="24" color="#07C160" />
-                <text class="read-text">已读</text>
+    <!-- 骨架屏 -->
+    <view v-if="loading" class="skeleton-container">
+      <view class="skeleton-card" v-for="i in 3" :key="i">
+        <view class="skeleton-header">
+          <view class="skeleton-line skeleton-title"></view>
+          <view class="skeleton-line skeleton-small"></view>
+        </view>
+        <view class="skeleton-content">
+          <view class="skeleton-line skeleton-text"></view>
+          <view class="skeleton-line skeleton-text"></view>
+          <view class="skeleton-line skeleton-text skeleton-short"></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 消息列表 -->
+    <view v-else class="message-list">
+      <view v-if="messages.length === 0" class="empty-state">
+        <image class="empty-icon" src="/static/icons/remixicon/notification-off-line.svg" />
+        <text class="empty-text">暂无消息</text>
+      </view>
+
+      <view v-else class="message-card" v-for="msg in messages" :key="msg.id">
+        <view class="message-header">
+          <view class="header-top">
+            <view class="header-left">
+              <text class="message-title">{{ getMessageTitle(msg.type) }}</text>
+            </view>
+            <view class="header-right">
+              <view class="action-buttons">
+                <button class="action-btn primary" v-if="msg.status !== 1" @click="markAsRead(msg.id)">标记已读</button>
+                <view v-else class="read-indicator">
+                  <uni-icons type="checkmarkempty" size="24" color="#07C160" />
+                  <text class="read-text">已读</text>
+                </view>
               </view>
             </view>
           </view>
-        </view>
-        <view class="header-bottom">
-          <view class="header-left">
-            <text class="sender">发送者: {{ msg.senderName || '系统' }}</text>
-          </view>
-          <view class="header-right">
-            <text class="message-time">发送时间: {{ msg.createTime }}</text>
+          <view class="header-bottom">
+            <view class="header-left">
+              <text class="sender">发送者: {{ msg.senderName || '系统' }}</text>
+            </view>
+            <view class="header-right">
+              <text class="message-time">发送时间: {{ formatTime(msg.createTime) }}</text>
+            </view>
           </view>
         </view>
-      </view>
-      <view class="message-content">
-        <text class="content-text">{{ msg.content }}</text>
+        <view class="message-content">
+          <text class="content-text">{{ msg.content }}</text>
+        </view>
       </view>
     </view>
   </view>
@@ -35,36 +58,18 @@
 
 <script setup lang="ts">
 import { ref, computed } from 'vue'
-import { onLoad } from '@dcloudio/uni-app'
+import { onLoad, onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
+import { getMessageList, markMessageAsRead, type Message, type MessageQueryParams } from '@/api/message'
 
-interface Message {
-  id: string
-  type: string
-  content: string
-  createTime: string
-  senderName?: string
-  isRead: boolean
-}
-
-const messages = ref<Message[]>([
-  {
-    id: '1',
-    type: 'DOCTOR',
-    content: '这是未读的医生消息内容详情。',
-    createTime: '2023-12-05 10:00:00',
-    senderName: '医生',
-    isRead: false
-  },
-  {
-    id: '2',
-    type: 'SYSTEM_ANOMALY',
-    content: '这是已读的异常通知内容详情。',
-    createTime: '2023-12-04 15:30:00',
-    senderName: '系统',
-    isRead: true
-  }
-])
+const messages = ref<Message[]>([])
+const loading = ref(true)
+const pageData = ref({
+  pageNum: 1,
+  pageSize: 20,
+  total: 0,
+  pages: 0
+})
 
 const getMessageTitle = (type: string) => {
   switch (type) {
@@ -79,13 +84,112 @@ const getMessageTitle = (type: string) => {
   }
 }
 
-const markAsRead = (id: string) => {
-  const msg = messages.value.find(m => m.id === id)
-  if (msg) {
-    msg.isRead = true
-    uni.showToast({ title: '已标记为已读', icon: 'success' })
+const formatTime = (timeStr: string) => {
+  if (!timeStr) return ''
+  try {
+    const date = new Date(timeStr)
+    const now = new Date()
+    const diff = now.getTime() - date.getTime()
+    const oneDay = 24 * 60 * 60 * 1000
+    const oneWeek = 7 * oneDay
+
+    if (diff < oneDay) {
+      // 今天
+      return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
+    } else if (diff < oneWeek) {
+      // 本周
+      const days = ['日', '一', '二', '三', '四', '五', '六']
+      return `周${days[date.getDay()]} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
+    } else {
+      // 更早
+      return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
+    }
+  } catch (e) {
+    return timeStr
+  }
+}
+
+// 获取消息列表
+const fetchMessages = async () => {
+  loading.value = true
+  try {
+    const params: MessageQueryParams = {
+      page: pageData.value.pageNum,
+      size: pageData.value.pageSize
+    }
+
+    const response = await getMessageList(params)
+    const resp = response.data as any
+
+    if (resp && resp.code === 200 && resp.data) {
+      const pageResult = resp.data
+      messages.value = pageResult.records.map((msg: Message) => ({
+        ...msg,
+        isRead: msg.status === 1 // 假设 status 为 1 表示已读
+      }))
+      pageData.value.total = pageResult.total
+      pageData.value.pages = pageResult.pages
+    } else {
+      uni.showToast({
+        title: resp?.message || '获取消息失败',
+        icon: 'none'
+      })
+    }
+  } catch (error) {
+    console.error('获取消息失败:', error)
+    uni.showToast({
+      title: '获取消息失败',
+      icon: 'none'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const markAsRead = async (id: string) => {
+  try {
+    const response = await markMessageAsRead(id)
+    const resp = response.data as any
+
+    if (resp && resp.code === 200) {
+      // 更新本地状态
+      const msg = messages.value.find(m => m.id === id)
+      if (msg) {
+        msg.status = 1
+      }
+      uni.showToast({ title: '已标记为已读', icon: 'success' })
+
+      // 通知首页更新未读消息数量
+      const pages = getCurrentPages()
+      const prevPage = pages[pages.length - 2]
+      if (prevPage && prevPage.$vm && typeof prevPage.$vm.fetchUnreadMessageCount === 'function') {
+        prevPage.$vm.fetchUnreadMessageCount()
+      }
+    } else {
+      uni.showToast({
+        title: resp?.message || '标记失败',
+        icon: 'none'
+      })
+    }
+  } catch (error) {
+    console.error('标记已读失败:', error)
+    uni.showToast({
+      title: '标记失败',
+      icon: 'none'
+    })
   }
 }
+
+onLoad(() => {
+  fetchMessages()
+})
+
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  }
+})
 </script>
 
 <style scoped>
@@ -224,4 +328,85 @@ const markAsRead = (id: string) => {
 .read-text {
   color: #07C160;
 }
+
+/* 骨架屏样式 */
+.skeleton-container {
+  padding: 32rpx 20rpx;
+}
+
+.skeleton-card {
+  background-color: #fff;
+  margin-bottom: 32rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.348);
+}
+
+.skeleton-header {
+  padding: 20rpx;
+  border-bottom: 1rpx solid #f5f5f5;
+  background-color: rgba(0, 0, 0, 0.03);
+}
+
+.skeleton-content {
+  padding: 20rpx;
+}
+
+.skeleton-line {
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  border-radius: 4rpx;
+  margin-bottom: 12rpx;
+}
+
+.skeleton-title {
+  height: 38rpx;
+  width: 60%;
+}
+
+.skeleton-small {
+  height: 28rpx;
+  width: 40%;
+}
+
+.skeleton-text {
+  height: 34rpx;
+  width: 100%;
+}
+
+.skeleton-short {
+  width: 70%;
+  margin-bottom: 0;
+}
+
+@keyframes skeleton-loading {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
+/* 空状态样式 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 40rpx;
+}
+
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  margin-bottom: 30rpx;
+  opacity: 0.5;
+}
+
+.empty-text {
+  font-size: 32rpx;
+  color: #666;
+}
 </style>