소스 검색

feat(patient): 实现患者提醒设置功能

- 新增患者提醒设置实体及API接口定义
- 实现获取、保存和删除患者提醒设置功能
- 重构提醒页面UI,支持血压、血糖、心率和用药提醒独立设置
- 添加时间点选择和删除功能
- 实现提醒设置的本地存储和服务器同步
- 优化时间选择器样式和交互逻辑
- 添加全局消息开关和一次性订阅开关控制
- 支持每日用药提醒时间点自动加载和展示
mcbaiyun 1 개월 전
부모
커밋
932058e871
2개의 변경된 파일405개의 추가작업 그리고 133개의 파일을 삭제
  1. 90 0
      src/api/patientReminder.ts
  2. 315 133
      src/pages/patient/health/reminder.vue

+ 90 - 0
src/api/patientReminder.ts

@@ -0,0 +1,90 @@
+import request from './request'
+
+// 患者提醒设置实体
+export interface PatientReminder {
+  id: string // Snowflake 64位ID 使用字符串保存以避免 JS Number 精度丢失
+  patientUserId: string // 患者用户ID
+  notificationEnabled: boolean // 是否启用消息通知总开关
+  subscriptionAvailable: boolean // 一次性订阅开关
+  bloodPressureEnabled: boolean // 测量血压提醒开关
+  bloodPressureTimes: string[] // 测量血压的时间点列表
+  bloodSugarEnabled: boolean // 测量血糖提醒开关
+  bloodSugarTimes: string[] // 测量血糖的时间点列表
+  heartRateEnabled: boolean // 测量心率提醒开关
+  heartRateTimes: string[] // 测量心率的时间点列表
+  medicationEnabled: boolean // 用药提醒开关
+  createTime: string // 创建时间
+  updateTime: string // 更新时间
+}
+
+// 患者提醒概览响应
+export interface PatientReminderOverviewResponse {
+  reminder: PatientReminder | null
+}
+
+// 保存患者提醒设置请求参数
+export interface SavePatientReminderRequest {
+  notificationEnabled?: boolean // 是否启用消息通知总开关
+  subscriptionAvailable?: boolean // 一次性订阅开关
+  bloodPressureEnabled?: boolean // 测量血压提醒开关
+  bloodPressureTimes?: string[] // 测量血压的时间点列表
+  bloodSugarEnabled?: boolean // 测量血糖提醒开关
+  bloodSugarTimes?: string[] // 测量血糖的时间点列表
+  heartRateEnabled?: boolean // 测量心率提醒开关
+  heartRateTimes?: string[] // 测量心率的时间点列表
+  medicationEnabled?: boolean // 用药提醒开关
+}
+
+/**
+ * 获取患者提醒概览
+ */
+export async function getPatientReminderOverview() {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/patient-reminder/overview',
+    method: 'GET'
+  })
+  
+  // 强制把 Snowflake ID 字段转换为字符串,避免后续使用 Number 导致精度丢失
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data && parsed.data.reminder) {
+      parsed.data.reminder = {
+        ...parsed.data.reminder,
+        id: String(parsed.data.reminder.id)
+      }
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
+  
+  return res
+}
+
+/**
+ * 保存患者提醒设置(创建或更新)
+ * @param payload 保存患者提醒设置请求参数
+ */
+export async function savePatientReminder(payload: SavePatientReminderRequest) {
+  const res: any = await request({
+    url: 'https://wx.baiyun.work/patient-reminder/save',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json' },
+    data: payload
+  })
+  
+  return res
+}
+
+/**
+ * 删除患者提醒设置
+ * @param id 患者提醒设置ID
+ */
+export async function deletePatientReminder(id: string | number) {
+  const res: any = await request({
+    url: `https://wx.baiyun.work/patient-reminder/${encodeURIComponent(String(id))}`,
+    method: 'DELETE'
+  })
+  
+  return res
+}

+ 315 - 133
src/pages/patient/health/reminder.vue

@@ -11,26 +11,80 @@
     </view>
 
     <view class="reminder-list">
-      <view class="reminder-item" v-for="(reminder, index) in reminders" :key="index">
+      <!-- 测量血压数据 -->
+      <view class="reminder-item">
         <view class="reminder-header">
-          <text class="reminder-title">{{ reminder.title }}</text>
-          <switch :checked="reminder.enabled" @change="toggleReminder(index)" />
+          <text class="reminder-title">测量血压数据</text>
+          <switch :checked="bloodPressureReminder.enabled" @change="toggleBloodPressureReminder" />
         </view>
         <view class="time-list">
-          <view class="time-item" v-for="(time, timeIndex) in reminder.times" :key="timeIndex">
+          <view class="time-item" v-for="(time, timeIndex) in bloodPressureReminder.times" :key="timeIndex">
             <text class="time-text">{{ time }}</text>
-            <!-- 每日用药提醒不允许删除时间点 -->
-            <view class="delete-time" v-if="reminder.title !== '每日用药提醒'" @click="deleteTime(index, timeIndex)">
+            <view class="delete-time" @click="deleteBloodPressureTime(timeIndex)">
               <uni-icons type="closeempty" size="16" color="#ff4757"></uni-icons>
             </view>
           </view>
-          <!-- 每日用药提醒不显示添加按钮 -->
-          <view class="add-time-btn" v-if="reminder.title !== '每日用药提醒'" @click="openTimePicker(index)">
+          <view class="add-time-btn" @click="openTimePicker('bloodPressure')">
+            <uni-icons type="plusempty" size="20" color="#3742fa"></uni-icons>
+            <text class="add-time-text">添加时间点</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 测量血糖数据 -->
+      <view class="reminder-item">
+        <view class="reminder-header">
+          <text class="reminder-title">测量血糖数据</text>
+          <switch :checked="bloodSugarReminder.enabled" @change="toggleBloodSugarReminder" />
+        </view>
+        <view class="time-list">
+          <view class="time-item" v-for="(time, timeIndex) in bloodSugarReminder.times" :key="timeIndex">
+            <text class="time-text">{{ time }}</text>
+            <view class="delete-time" @click="deleteBloodSugarTime(timeIndex)">
+              <uni-icons type="closeempty" size="16" color="#ff4757"></uni-icons>
+            </view>
+          </view>
+          <view class="add-time-btn" @click="openTimePicker('bloodSugar')">
+            <uni-icons type="plusempty" size="20" color="#3742fa"></uni-icons>
+            <text class="add-time-text">添加时间点</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 测量心率数据 -->
+      <view class="reminder-item">
+        <view class="reminder-header">
+          <text class="reminder-title">测量心率数据</text>
+          <switch :checked="heartRateReminder.enabled" @change="toggleHeartRateReminder" />
+        </view>
+        <view class="time-list">
+          <view class="time-item" v-for="(time, timeIndex) in heartRateReminder.times" :key="timeIndex">
+            <text class="time-text">{{ time }}</text>
+            <view class="delete-time" @click="deleteHeartRateTime(timeIndex)">
+              <uni-icons type="closeempty" size="16" color="#ff4757"></uni-icons>
+            </view>
+          </view>
+          <view class="add-time-btn" @click="openTimePicker('heartRate')">
             <uni-icons type="plusempty" size="20" color="#3742fa"></uni-icons>
             <text class="add-time-text">添加时间点</text>
           </view>
         </view>
       </view>
+
+      <!-- 每日用药提醒 -->
+      <view class="reminder-item">
+        <view class="reminder-header">
+          <text class="reminder-title">每日用药提醒</text>
+          <switch :checked="medicationReminder.enabled" @change="toggleMedicationReminder" />
+        </view>
+        <view class="time-list">
+          <view class="time-item" v-for="(time, timeIndex) in medicationReminder.times" :key="timeIndex">
+            <text class="time-text">{{ time }}</text>
+            <!-- 每日用药提醒不允许删除时间点 -->
+          </view>
+          <!-- 每日用药提醒不显示添加按钮 -->
+        </view>
+      </view>
     </view>
   </view>
   
@@ -82,20 +136,28 @@ import { onShow, onHide } from '@dcloudio/uni-app'
 
 import CustomNav from '@/components/custom-nav.vue'
 import { getMedicationListByPatientId } from '@/api/patientMedication'
+import { getPatientReminderOverview, savePatientReminder } from '@/api/patientReminder'
 
-interface Reminder {
-  title: string
-  enabled: boolean
-  times: string[]
-}
+// 定义各个提醒项的状态
+const bloodPressureReminder = ref({
+  enabled: false,
+  times: [] as string[]
+})
 
-const reminders = ref<Reminder[]>([
-  { title: '测量血压数据', enabled: true, times: ['07:00'] },
-  { title: '测量血糖数据', enabled: false, times: ['12:00'] },
-  { title: '测量心率数据', enabled: true, times: ['18:00'] },
-  // 每日用药提醒的时间点将通过API获取,初始值为空数组
-  { title: '每日用药提醒', enabled: true, times: [] }
-])
+const bloodSugarReminder = ref({
+  enabled: false,
+  times: [] as string[]
+})
+
+const heartRateReminder = ref({
+  enabled: false,
+  times: [] as string[]
+})
+
+const medicationReminder = ref({
+  enabled: false,
+  times: [] as string[] // 初始为空数组,通过API获取实际数据
+})
 
 // 模板 ID & 其他展示信息
 const TEMPLATE_ID = 'ACS7cwcbx0F0Y_YaB4GZr7rWP7BO2-7wQOtYsnUjmFI'
@@ -104,14 +166,17 @@ const TEMPLATE_NO = '7536'
 // 全局消息开关
 const notificationsEnabled = ref<boolean>(false)
 
+// 一次性订阅开关
+const subscriptionAvailable = ref<boolean>(true)
+
 // 是否显示权限引导
 const showPermissionGuide = ref<boolean>(false)
 
 // 是否显示时间选择器
 const showTimePicker = ref<boolean>(false)
 
-// 当前编辑的提醒索引
-const editingReminderIndex = ref<number>(-1)
+// 当前编辑的提醒类型
+const editingReminderType = ref<'bloodPressure' | 'bloodSugar' | 'heartRate' | null>(null)
 
 // 时间选择器相关
 const timeOptions = ref<string[]>(['07:00', '08:00', '09:00', '12:00', '13:00', '18:00', '19:00', '20:00', '21:00'])
@@ -150,34 +215,92 @@ const entrySourceText = computed(() => {
   }
 })
 
+// 各个提醒项的开关控制函数
+const toggleBloodPressureReminder = () => {
+  bloodPressureReminder.value.enabled = !bloodPressureReminder.value.enabled
+  saveReminders()
+}
+
+const toggleBloodSugarReminder = () => {
+  bloodSugarReminder.value.enabled = !bloodSugarReminder.value.enabled
+  saveReminders()
+}
+
+const toggleHeartRateReminder = () => {
+  heartRateReminder.value.enabled = !heartRateReminder.value.enabled
+  saveReminders()
+}
+
+const toggleMedicationReminder = () => {
+  medicationReminder.value.enabled = !medicationReminder.value.enabled
+  saveReminders()
+}
+
+// 删除时间点的函数
+const deleteBloodPressureTime = (timeIndex: number) => {
+  bloodPressureReminder.value.times.splice(timeIndex, 1)
+  saveReminders()
+}
+
+const deleteBloodSugarTime = (timeIndex: number) => {
+  bloodSugarReminder.value.times.splice(timeIndex, 1)
+  saveReminders()
+}
+
+const deleteHeartRateTime = (timeIndex: number) => {
+  heartRateReminder.value.times.splice(timeIndex, 1)
+  saveReminders()
+}
+
+// 打开时间选择器
+const openTimePicker = (type: 'bloodPressure' | 'bloodSugar' | 'heartRate') => {
+  editingReminderType.value = type
+  selectedTimeIndex.value = 0
+  showTimePicker.value = true
+}
+
+// 关闭时间选择器
+const closeTimePicker = () => {
+  showTimePicker.value = false
+  editingReminderType.value = null
+}
+
+// 时间选择器变更
+const onTimeChange = (e: any) => {
+  selectedTimeIndex.value = e.detail.value
+}
+
+// 确认时间选择
+const confirmTime = () => {
+  if (editingReminderType.value) {
+    const time = timeOptions.value[selectedTimeIndex.value]
+    let times: string[] = []
+    
+    switch (editingReminderType.value) {
+      case 'bloodPressure':
+        times = bloodPressureReminder.value.times
+        break
+      case 'bloodSugar':
+        times = bloodSugarReminder.value.times
+        break
+      case 'heartRate':
+        times = heartRateReminder.value.times
+        break
+    }
+    
+    if (!times.includes(time)) {
+      times.push(time)
+      saveReminders()
+    }
+  }
+  closeTimePicker()
+}
+
 onMounted(() => {
   loadUser()
-  try {
-    const val = (uni as any).getStorageSync('notificationsEnabled')
-    console.log('已获取全局消息开关状态:', val)
-    if (typeof val === 'boolean') notificationsEnabled.value = val
-  } catch (e) {
-    // 忽略错误
-  }
   
-  // 加载提醒设置(除了每日用药提醒)
-  try {
-    const savedReminders = (uni as any).getStorageSync('reminders')
-    if (savedReminders && Array.isArray(savedReminders)) {
-      // 只更新非每日用药提醒的设置
-      reminders.value = reminders.value.map(reminder => {
-        if (reminder.title === '每日用药提醒') {
-          // 保留每日用药提醒项,使用空数组作为初始时间点
-          return { ...reminder, times: [] };
-        }
-        // 查找本地存储中对应的提醒设置
-        const savedReminder = savedReminders.find((r: Reminder) => r.title === reminder.title);
-        return savedReminder ? savedReminder : reminder;
-      });
-    }
-  } catch (e) {
-    // 忽略错误
-  }
+  // 加载提醒设置
+  loadReminders()
   
   // 加载用药时间点
   loadMedicationTimes()
@@ -357,15 +480,97 @@ onUnmounted(() => {
 })
 
 /**
- * 保存提醒设置到本地存储(不包括每日用药提醒)
+ * 加载提醒设置
+ */
+const loadReminders = async () => {
+  if (!currentUserId.value) return
+  
+  try {
+    const res = await getPatientReminderOverview()
+    if (res && res.data && res.data.code === 200) {
+      const reminderData = res.data.data?.reminder
+      
+      if (reminderData) {
+        // 更新各个提醒项的状态
+        bloodPressureReminder.value = {
+          enabled: reminderData.bloodPressureEnabled,
+          times: reminderData.bloodPressureTimes || []
+        }
+        
+        bloodSugarReminder.value = {
+          enabled: reminderData.bloodSugarEnabled,
+          times: reminderData.bloodSugarTimes || []
+        }
+        
+        heartRateReminder.value = {
+          enabled: reminderData.heartRateEnabled,
+          times: reminderData.heartRateTimes || []
+        }
+        
+        medicationReminder.value = {
+          enabled: reminderData.medicationEnabled,
+          times: medicationReminder.value.times // 保留已加载的用药时间点
+        }
+        
+        // 更新全局消息开关和一次性订阅开关
+        notificationsEnabled.value = reminderData.notificationEnabled
+        subscriptionAvailable.value = reminderData.subscriptionAvailable
+        
+        try {
+          (uni as any).setStorageSync('notificationsEnabled', reminderData.notificationEnabled)
+        } catch (e) {
+          console.error('保存通知开关状态到本地存储失败:', e)
+        }
+      }
+    } else {
+      console.log('未找到提醒设置,使用默认值')
+    }
+  } catch (e) {
+    console.error('加载提醒设置失败:', e)
+  }
+}
+
+/**
+ * 保存提醒设置到服务器
  */
-const saveReminders = () => {
+const saveReminders = async () => {
+  if (!currentUserId.value) {
+    console.warn('用户未登录,无法保存提醒设置')
+    return
+  }
+  
   try {
-    // 过滤掉每日用药提醒,不保存到本地存储
-    const remindersToSave = reminders.value.filter(reminder => reminder.title !== '每日用药提醒');
-    (uni as any).setStorageSync('reminders', remindersToSave);
+    // 构造要保存的提醒设置对象
+    const payload = {
+      notificationEnabled: notificationsEnabled.value,
+      subscriptionAvailable: subscriptionAvailable.value,
+      bloodPressureEnabled: bloodPressureReminder.value.enabled,
+      bloodPressureTimes: bloodPressureReminder.value.times,
+      bloodSugarEnabled: bloodSugarReminder.value.enabled,
+      bloodSugarTimes: bloodSugarReminder.value.times,
+      heartRateEnabled: heartRateReminder.value.enabled,
+      heartRateTimes: heartRateReminder.value.times,
+      medicationEnabled: medicationReminder.value.enabled
+    }
+    
+    await savePatientReminder(payload)
+    console.log('提醒设置保存成功')
+    
+    // 同时也保存到本地存储以防万一
+    try {
+      const remindersToSave = [
+        { title: '测量血压数据', enabled: bloodPressureReminder.value.enabled, times: bloodPressureReminder.value.times },
+        { title: '测量血糖数据', enabled: bloodSugarReminder.value.enabled, times: bloodSugarReminder.value.times },
+        { title: '测量心率数据', enabled: heartRateReminder.value.enabled, times: heartRateReminder.value.times }
+      ]
+      ;(uni as any).setStorageSync('reminders', remindersToSave)
+      ;(uni as any).setStorageSync('notificationsEnabled', notificationsEnabled.value)
+    } catch (e) {
+      console.error('保存提醒设置到本地存储失败:', e)
+    }
   } catch (e) {
-    console.error('保存提醒设置失败:', e)
+    console.error('保存提醒设置到服务器失败:', e)
+    uni.showToast({ title: '保存失败', icon: 'none' })
   }
 }
 
@@ -379,12 +584,8 @@ const loadMedicationTimes = async () => {
     if (res && res.data && res.data.code === 200 && Array.isArray(res.data.data)) {
       const allTimes = res.data.data.flatMap((med: any) => med.times || []).filter((t: any) => typeof t === 'string') as string[]
       const uniqueTimes = [...new Set(allTimes)].sort()
-      // 找到每日用药提醒的索引(假设是第四个)
-      const medicationReminderIndex = reminders.value.findIndex(r => r.title === '每日用药提醒')
-      if (medicationReminderIndex >= 0) {
-        reminders.value[medicationReminderIndex].times = uniqueTimes
-        // 不再保存到本地存储,每次都从API获取最新数据
-      }
+      // 更新每日用药提醒的时间点
+      medicationReminder.value.times = uniqueTimes
     }
   } catch (e) {
     console.error('加载用药时间点失败:', e)
@@ -502,11 +703,9 @@ const onNotificationChange = (e: any) => {
         const result = res && res[TEMPLATE_ID]
         if (result === 'accept') {
           console.log('用户接受了订阅')
-          try {
-            ;(uni as any).setStorageSync('notificationsEnabled', true)
-          } catch (err) {
-            // 忽略存储错误
-          }
+          // 设置一次性订阅开关为可用
+          subscriptionAvailable.value = true
+          saveReminders()
           uni.showToast({ title: '订阅成功', icon: 'success' })
           // 隐藏权限引导
           showPermissionGuide.value = false
@@ -514,9 +713,7 @@ const onNotificationChange = (e: any) => {
           console.log('用户未接受订阅,结果:', result)
           // 用户拒绝或关闭了弹窗
           notificationsEnabled.value = false
-          try {
-            ;(uni as any).setStorageSync('notificationsEnabled', false)
-          } catch (err) {}
+          saveReminders()
           uni.showToast({ title: '订阅被拒绝', icon: 'none' })
           // 显示权限引导
           showPermissionGuide.value = true
@@ -525,9 +722,7 @@ const onNotificationChange = (e: any) => {
       fail(err: any) {
         console.log('订阅消息请求失败:', err)
         notificationsEnabled.value = false
-        try {
-          ;(uni as any).setStorageSync('notificationsEnabled', false)
-        } catch (e) {}
+        saveReminders()
         // 根据错误类型显示不同的提示信息
         if (err.errCode === 20004) {
           uni.showToast({ title: '推送权限已关闭', icon: 'none' })
@@ -542,73 +737,13 @@ const onNotificationChange = (e: any) => {
     // 关闭订阅:不需要额外调用接口,只改变本地记录
     console.log('通知开关已关闭')
     notificationsEnabled.value = false
-    try {
-      ;(uni as any).setStorageSync('notificationsEnabled', false)
-    } catch (err) {}
+    saveReminders()
     uni.showToast({ title: '已关闭通知', icon: 'none' })
     // 隐藏权限引导
     showPermissionGuide.value = false
   }
 }
 
-const toggleReminder = (index: number) => {
-  reminders.value[index].enabled = !reminders.value[index].enabled
-  // 每日用药提醒不保存到本地存储
-  if (reminders.value[index].title !== '每日用药提醒') {
-    saveReminders()
-  }
-}
-
-/**
- * 删除时间点
- */
-const deleteTime = (reminderIndex: number, timeIndex: number) => {
-  // 每日用药提醒不允许删除时间点
-  if (reminders.value[reminderIndex].title === '每日用药提醒') {
-    return
-  }
-  reminders.value[reminderIndex].times.splice(timeIndex, 1)
-  saveReminders()
-}
-
-/**
- * 打开时间选择器
- */
-const openTimePicker = (index: number) => {
-  editingReminderIndex.value = index
-  selectedTimeIndex.value = 0
-  showTimePicker.value = true
-}
-
-/**
- * 关闭时间选择器
- */
-const closeTimePicker = () => {
-  showTimePicker.value = false
-  editingReminderIndex.value = -1
-}
-
-/**
- * 时间选择器变更
- */
-const onTimeChange = (e: any) => {
-  selectedTimeIndex.value = e.detail.value
-}
-
-/**
- * 确认时间选择
- */
-const confirmTime = () => {
-  if (editingReminderIndex.value >= 0) {
-    const time = timeOptions.value[selectedTimeIndex.value]
-    const times = reminders.value[editingReminderIndex.value].times
-    if (!times.includes(time)) {
-      times.push(time)
-      saveReminders()
-    }
-  }
-  closeTimePicker()
-}
 </script>
 
 <style scoped>
@@ -818,20 +953,57 @@ const confirmTime = () => {
   opacity: 0.7;
 }
 
+.modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9998;
+}
+
+.modal-backdrop {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.6);
+}
+
 .time-picker-panel {
-  height: 400rpx;
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background-color: #fff;
+  border-top-left-radius: 20rpx;
+  border-top-right-radius: 20rpx;
+  padding: 20rpx;
+  min-height: 300rpx;
+}
+
+.drag-handle {
+  width: 100rpx;
+  height: 10rpx;
+  background-color: #ddd;
+  border-radius: 5rpx;
+  margin: 0 auto 20rpx;
 }
 
 .time-picker-container {
   padding: 40rpx 24rpx;
+  text-align: center;
 }
 
 .time-value {
   font-size: 48rpx;
   color: #333;
-  padding: 10rpx 20rpx;
+  padding: 20rpx;
   background-color: #f5f5f5;
   border-radius: 10rpx;
+  display: inline-block;
+  min-width: 200rpx;
 }
 
 .btn-secondary {
@@ -841,6 +1013,8 @@ const confirmTime = () => {
   padding: 20rpx 0;
   font-size: 28rpx;
   border-radius: 8rpx;
+  flex: 1;
+  margin-right: 20rpx;
 }
 
 .btn-primary {
@@ -850,5 +1024,13 @@ const confirmTime = () => {
   padding: 20rpx 0;
   font-size: 28rpx;
   border-radius: 8rpx;
+  flex: 1;
+  margin-left: 20rpx;
+}
+
+.modal-footer {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 20rpx;
 }
 </style>