Kaynağa Gözat

feat(api): 新增医生-家属查询患者健康数据接口

- 在 bloodGlucose.ts 中新增 listBloodGlucoseByBoundUser 方法,用于分页查询患者血糖数据
- 在 bloodPressure.ts 中新增 listBloodPressureByBoundUser 方法,用于分页查询患者血压数据
- 在 heartRate.ts 中新增 listHeartRateByBoundUser 方法,用于分页查询患者心率数据
- 在 physical.ts 中新增 listPhysicalByBoundUser 方法,用于分页查询患者体格数据
- 所有新增接口均支持按时间范围筛选,并通过 patientUserId 和 bindingType 参数进行权限控制
- 接口地址统一采用 POST 请求方式,数据传输格式为 application/json
- 添加了对应的注释说明接口用途和参数含义
mcbaiyun 1 ay önce
ebeveyn
işleme
8ab9f2b25e

+ 24 - 1
src/api/bloodGlucose.ts

@@ -12,6 +12,29 @@ export async function listBloodGlucose(params: { pageNum?: number; pageSize?: nu
   return res
 }
 
+// 医生-家属分页查询患者血糖数据
+export async function listBloodGlucoseByBoundUser(params: { 
+  patientUserId: number | string; 
+  bindingType: string;
+  baseQueryRequest: { 
+    pageNum?: number; 
+    pageSize?: number; 
+    startTime?: string; 
+    endTime?: string 
+  } 
+}) {
+  const { patientUserId, bindingType, baseQueryRequest } = params;
+  const res: any = await request({
+    url: `https://wx.baiyun.work/blood-glucose/list-by-bound-user?patientUserId=${patientUserId}&bindingType=${bindingType}`,
+    method: 'POST',
+    data: baseQueryRequest,
+    header: {
+      'content-type': 'application/json'
+    }
+  })
+  return res
+}
+
 export async function addBloodGlucose(data: { type: string; value: number; measureTime: string }) {
   const res: any = await request({
     url: 'https://wx.baiyun.work/blood-glucose/add',
@@ -34,4 +57,4 @@ export async function deleteBloodGlucose(id: string) {
     }
   })
   return res
-}
+}

+ 24 - 1
src/api/bloodPressure.ts

@@ -12,6 +12,29 @@ export async function listBloodPressure(params: { pageNum?: number; pageSize?: n
   return res
 }
 
+// 医生-家属分页查询患者血压数据
+export async function listBloodPressureByBoundUser(params: { 
+  patientUserId: number | string; 
+  bindingType: string;
+  baseQueryRequest: { 
+    pageNum?: number; 
+    pageSize?: number; 
+    startTime?: string; 
+    endTime?: string 
+  } 
+}) {
+  const { patientUserId, bindingType, baseQueryRequest } = params;
+  const res: any = await request({
+    url: `https://wx.baiyun.work/blood-pressure/list-by-bound-user?patientUserId=${patientUserId}&bindingType=${bindingType}`,
+    method: 'POST',
+    data: baseQueryRequest,
+    header: {
+      'content-type': 'application/json'
+    }
+  })
+  return res
+}
+
 export async function addBloodPressure(data: { systolicPressure: number; diastolicPressure: number; measureTime: string }) {
   const res: any = await request({
     url: 'https://wx.baiyun.work/blood-pressure/add',
@@ -34,4 +57,4 @@ export async function deleteBloodPressure(id: string) {
     }
   })
   return res
-}
+}

+ 24 - 1
src/api/heartRate.ts

@@ -10,6 +10,29 @@ export async function listHeartRate(params: { pageNum?: number; pageSize?: numbe
   return res
 }
 
+// 医生-家属分页查询患者心率数据
+export async function listHeartRateByBoundUser(params: { 
+  patientUserId: number | string; 
+  bindingType: string;
+  baseQueryRequest: { 
+    pageNum?: number; 
+    pageSize?: number; 
+    startTime?: string; 
+    endTime?: string 
+  } 
+}) {
+  const { patientUserId, bindingType, baseQueryRequest } = params;
+  const res: any = await request({
+    url: `https://wx.baiyun.work/heart-rate/list-by-bound-user?patientUserId=${patientUserId}&bindingType=${bindingType}`,
+    method: 'POST',
+    data: baseQueryRequest,
+    header: {
+      'content-type': 'application/json'
+    }
+  })
+  return res
+}
+
 export async function addHeartRate(data: { heartRate: number; measureTime: string }) {
   const res: any = await request({
     url: 'https://wx.baiyun.work/heart-rate/add',
@@ -28,4 +51,4 @@ export async function deleteHeartRate(id: string) {
     header: { 'content-type': 'application/json' }
   })
   return res
-}
+}

+ 24 - 1
src/api/physical.ts

@@ -13,6 +13,29 @@ export async function listPhysical(params: { pageNum: number; pageSize: number;
   return res
 }
 
+// 医生-家属分页查询患者体格数据
+export async function listPhysicalByBoundUser(params: { 
+  patientUserId: number | string; 
+  bindingType: string;
+  baseQueryRequest: { 
+    pageNum?: number; 
+    pageSize?: number; 
+    startTime?: string; 
+    endTime?: string 
+  } 
+}) {
+  const { patientUserId, bindingType, baseQueryRequest } = params;
+  const res: any = await request({
+    url: `https://wx.baiyun.work/physical/list-by-bound-user?patientUserId=${patientUserId}&bindingType=${bindingType}`,
+    method: 'POST',
+    data: baseQueryRequest,
+    header: {
+      'content-type': 'application/json'
+    }
+  })
+  return res
+}
+
 export async function addPhysical(payload: { height: number; weight: number; measureTime: string }) {
   const res: any = await request({
     url: 'https://wx.baiyun.work/physical/add',
@@ -35,4 +58,4 @@ export async function deletePhysical(id: string) {
     data: { id }
   })
   return res
-}
+}

+ 25 - 1
src/pages.json

@@ -18,6 +18,30 @@
 				"navigationBarTitleText": "健康数据"
 			}
 		},
+		{
+			"path": "pages/public/health/details/physical",
+			"style": {
+				"navigationBarTitleText": "体格数据"
+			}
+		},
+		{
+			"path": "pages/public/health/details/blood-pressure",
+			"style": {
+				"navigationBarTitleText": "血压记录"
+			}
+		},
+		{
+			"path": "pages/public/health/details/blood-glucose",
+			"style": {
+				"navigationBarTitleText": "血糖记录"
+			}
+		},
+		{
+			"path": "pages/public/health/details/heart-rate",
+			"style": {
+				"navigationBarTitleText": "心率记录"
+			}
+		},
 		{
 			"path": "pages/public/profile/index",
 			"style": {
@@ -210,4 +234,4 @@
 			}
 		]
 	}
-}
+}

+ 4 - 4
src/pages/patient-family/index/my-family.vue

@@ -82,7 +82,7 @@ const fetchFamilies = async () => {
     // 查询家属绑定的家人列表(使用新的接口)
     const response = await listUserBindingsByBoundUser(
       familyUserId, 
-      'PATIENT', 
+      'FAMILY', 
       {
         pageNum: pageData.value.pageNum,
         pageSize: pageData.value.pageSize
@@ -136,9 +136,9 @@ const familyAvatar = (family: FamilyInfo) => {
 }
 
 const viewHealthData = (family: FamilyInfo) => {
-  uni.showToast({
-    title: '健康数据功能开发中',
-    icon: 'none'
+  // 跳转到公共健康数据查看页面,传递患者ID和绑定类型参数
+  uni.navigateTo({
+    url: `/pages/public/health/index?patientId=${family.patientUserId}&bindingType=FAMILY`
   })
 }
 

+ 502 - 0
src/pages/public/health/details/blood-glucose.vue

@@ -0,0 +1,502 @@
+<template>
+  <CustomNav title="血糖" leftType="back" />
+  <view class="page">
+
+    <view class="header">
+      <view class="month-selector">
+        <button class="btn" @click="prevPeriod">‹</button>
+        <view class="period-controls">
+          <picker mode="multiSelector" :value="pickerValue" :range="pickerRange" @change="onPickerChange">
+            <view class="month-label">{{ displayPeriod }}</view>
+          </picker>
+          <view class="view-toggle">
+            <button :class="['toggle-btn', { active: viewMode === 'month' }]" @click="setViewMode('month')">月</button>
+            <button :class="['toggle-btn', { active: viewMode === 'week' }]" @click="setViewMode('week')">周</button>
+          </view>
+        </view>
+        <button class="btn" @click="nextPeriod">›</button>
+      </view>
+    </view>
+
+    <!-- 趋势图 - 简化canvas设置 -->
+    <view class="chart-wrap">
+      <view class="chart-header">{{ viewMode === 'month' ? '本月' : '本周' }}趋势</view>
+      <canvas canvas-id="bgChart" id="bgChart" class="chart-canvas"
+        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"></canvas>
+    </view>
+
+    <view class="content">
+      <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageGlucose }} mmol/L</view>
+
+      <view class="list">
+        <view v-if="records.length === 0" class="empty">暂无记录</view>
+        <view v-for="item in records" :key="item.id" class="list-item" :style="{ backgroundColor: getItemColor(item.value, item.type) }">
+          <view class="date">{{ item.date }}</view>
+          <view class="value">{{ item.value }} mmol/L · {{ item.type }}</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 删除了添加按钮和相关功能,因为这是公共页面,仅供医生或家属查看患者健康数据 -->
+    <!-- Removed add button and related functions because this is a public page for doctors or family members to view patient health data -->
+
+  </view>
+
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
+import { onShow, onLoad } from '@dcloudio/uni-app'
+import { listBloodGlucoseByBoundUser } from '@/api/bloodGlucose'
+import CustomNav from '@/components/custom-nav.vue'
+
+import ScaleRuler from '@/components/scale-ruler.vue'
+import { createUChart } from '@/composables/useUChart'
+import { getWeekStart, getWeekEnd, formatDisplayDate, formatPickerDate, daysInMonth, getTodayStart, isAfterTodayDate, isMonthAfterToday, isWeekAfterToday } from '@/utils/date'
+import { getWindowWidth } from '@/utils/platform'
+
+type RecordItem = { id: string; date: string; value: number; type: string }
+
+// 当前展示年月
+const current = ref(new Date())
+// pickerValue 使用 multiSelector,两列:年份偏移(2000起),月份(0-11)
+const pickerValue = ref([current.value.getFullYear() - 2000, current.value.getMonth()]) // [yearOffset, month]
+
+// 视图模式:'month' 或 'week'
+const viewMode = ref<'month' | 'week'>('month')
+
+// 年月选择器的选项范围
+const pickerRange = ref([
+  Array.from({ length: 50 }, (_, i) => `${2000 + i}年`), // 2000-2049年
+  Array.from({ length: 12 }, (_, i) => `${i + 1}月`) // 1-12月
+])
+
+// 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
+const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
+const canvasHeight = ref(280)
+
+// 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
+function getCanvasSize(): Promise<{ width: number; height: number }> {
+  return new Promise(async (resolve) => {
+    const width = await getWindowWidth().catch(() => 375)
+    const height = Math.round((280 / 750) * width)
+    resolve({ width, height })
+  })
+}
+
+// 使用共享日期工具 (src/utils/date.ts)
+
+const displayYear = computed(() => current.value.getFullYear())
+const displayMonth = computed(() => current.value.getMonth() + 1)
+
+// 显示周期的计算属性
+const displayPeriod = computed(() => {
+  if (viewMode.value === 'month') {
+    return `${displayYear.value}年 ${displayMonth.value}月`
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    return `${formatDisplayDate(weekStart)} - ${formatDisplayDate(weekEnd)}`
+  }
+})
+
+const records = ref<RecordItem[]>([])
+const patientId = ref<string | null>(null)
+const bindingType = ref<string | null>(null)
+
+// 页面加载时检查是否传入了患者ID和绑定类型
+onLoad((options) => {
+  if (options && options.patientId && options.bindingType) {
+    patientId.value = options.patientId
+    bindingType.value = options.bindingType
+  } else {
+    // 如果没有传入patientId或bindingType,则弹窗提示并返回上一页
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none',
+      duration: 2000
+    })
+    setTimeout(() => {
+      uni.navigateBack()
+    }, 2000)
+  }
+})
+
+async function fetchRecords() {
+  let startTime = ''
+  let endTime = ''
+  if (viewMode.value === 'month') {
+    const y = current.value.getFullYear()
+    const m = current.value.getMonth()
+    startTime = new Date(y, m, 1).toISOString()
+    const endDate = new Date(y, m + 1, 0)
+    endDate.setHours(23, 59, 59, 999)
+    endTime = endDate.toISOString()
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    startTime = weekStart.toISOString()
+    try {
+      const we = new Date(weekEnd)
+      we.setHours(23, 59, 59, 999)
+      endTime = we.toISOString()
+    } catch (e) {
+      endTime = weekEnd.toISOString()
+    }
+  }
+
+  try { if (typeof uni !== 'undefined' && uni.showLoading) uni.showLoading({ title: '加载中...' }) } catch (e) {}
+
+  try {
+    // 使用新的 ByBoundUser 接口
+    if (patientId.value && bindingType.value) {
+      const params = {
+        patientUserId: patientId.value,
+        bindingType: bindingType.value,
+        baseQueryRequest: { 
+          pageNum: 1, 
+          pageSize: 100, 
+          startTime, 
+          endTime 
+        }
+      }
+      
+      const res: any = await listBloodGlucoseByBoundUser(params)
+      if (res.statusCode === 401) {
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
+        uni.reLaunch({ url: '/pages/public/login/index' })
+        return
+      }
+      if ((res.data as any) && (res.data as any).code === 200) {
+        const apiRecords = (res.data as any).data?.records || []
+        records.value = apiRecords.map((item: any) => ({ id: String(item.id), date: formatDisplayDate(new Date(item.measureTime)), value: Number(item.value ?? 0), type: item.type || '随机' }))
+        try { await drawChart() } catch (e) { console.warn('bgChart draw failed', e) }
+      } else {
+        console.error('Fetch blood-glucose records failed', res.data)
+      }
+    }
+  } catch (e) {
+    console.error('Fetch blood-glucose error', e)
+  } finally {
+    try { if (typeof uni !== 'undefined' && uni.hideLoading) uni.hideLoading() } catch (e) {}
+  }
+}
+
+// 获取指定日期所在周的开始日期(星期一)
+// 使用共享日期工具 (src/utils/date.ts)
+
+// 将 records 聚合为每天一个点(取最新记录)
+function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
+  const map = new Map<number, RecordItem>()
+  for (const r of recordsArr) {
+    const parts = r.date.split('-')
+    if (parts.length >= 3) {
+      const y = parseInt(parts[0], 10)
+      const m = parseInt(parts[1], 10) - 1
+      const d = parseInt(parts[2], 10)
+      if (y === year && m === month) {
+        // 覆盖同一天,保留最新的(数组头部为最新)
+        map.set(d, r)
+      }
+    }
+  }
+  // 返回按日索引的数组
+  return map
+}
+
+// 使用 formatDisplayDate 从 src/utils/date.ts
+
+const averageGlucose = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.value, 0)
+  return (sum / records.value.length).toFixed(1)
+})
+
+// 根据血糖值获取颜色
+function getItemColor(value: number, type: string): string {
+  if (type === '空腹') {
+    if (value > 7.0) {
+      return '#f8d7da' // 红
+    } else if (value > 6.1) {
+      return '#fff3cd' // 黄
+    } else {
+      return '#e8f5e8' // 绿
+    }
+  } else {
+    // 随机血糖
+    if (value > 11.1) {
+      return '#f8d7da' // 红
+    } else if (value > 7.8) {
+      return '#fff3cd' // 黄
+    } else {
+      return '#e8f5e8' // 绿
+    }
+  }
+}
+
+// 使用 daysInMonth 从 src/utils/date.ts
+
+// Canvas / uCharts 绘图 - 使用 composable
+const vm = getCurrentInstance()
+const chart = createUChart({ canvasId: 'bgChart', vm, getCanvasSize, seriesNames: '血糖', valueAccessors: (r: any) => Number(r.value ?? 0), colors: '#ff6a00' })
+
+// Draw / init handled by composable
+async function drawChart() {
+  return chart.draw(records, current, viewMode)
+}
+
+// 更新数据由 composable 处理
+async function updateChartData() {
+  return chart.update(records, current, viewMode)
+}
+
+onMounted(() => {
+  // 延迟确保DOM渲染完成
+  setTimeout(async () => {
+    await nextTick()
+    // 由 getCanvasSize 计算并覆盖 canvasWidth/canvasHeight(确保模板样式和绘图一致)
+    try {
+      const size = await getCanvasSize()
+      canvasWidth.value = size.width
+      canvasHeight.value = size.height
+    } catch (e) {
+      console.warn('getCanvasSize failed on mounted', e)
+    }
+    // 拉取数据并绘制
+    await fetchRecords()
+    await drawChart()
+  }, 500)
+})
+
+// 简化监听,避免频繁重绘
+watch([() => current.value], async () => {
+  setTimeout(async () => {
+    await updateChartData()
+  }, 100)
+})
+
+watch([() => records.value], async () => {
+  setTimeout(async () => {
+    await updateChartData()
+  }, 100)
+}, { deep: true })
+
+onBeforeUnmount(() => {
+  try { chart.destroy() } catch (e) { console.warn('uCharts destroy error:', e) }
+})
+
+// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
+async function rebuildChart() {
+  // delegate to composable rebuild
+  try {
+    await chart.rebuild(records, current, viewMode)
+  } catch (e) {
+    console.error('rebuildChart failed', e)
+  }
+}
+
+// 使用共享日期工具(在 src/utils/date.ts 中定义)
+
+// 其他函数(含周期切换、picker 处理)
+async function prevPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() - 1)
+  } else {
+    d.setDate(d.getDate() - 7)
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function nextPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() + 1)
+    if (isMonthAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  } else {
+    d.setDate(d.getDate() + 7)
+    if (isWeekAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function setViewMode(mode: 'month' | 'week') {
+  if (viewMode.value !== mode) {
+    viewMode.value = mode
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+async function onPickerChange(e: any) {
+  // multiSelector 会返回 [yearOffset, monthIndex]
+  const val = e?.detail?.value || e
+  if (Array.isArray(val) && val.length >= 2) {
+      const y = 2000 + Number(val[0])
+      const m = Number(val[1])
+      let d = new Date(y, m, 1)
+      if (isMonthAfterToday(d)) {
+        const today = getTodayStart()
+        uni.showToast && uni.showToast({ title: '不能选择未来的月份,已切换到当前月份', icon: 'none' })
+        d = new Date(today.getFullYear(), today.getMonth(), 1)
+        pickerValue.value = [today.getFullYear() - 2000, today.getMonth()]
+      } else {
+        pickerValue.value = [Number(val[0]), Number(val[1])]
+      }
+      current.value = d
+      await fetchRecords()
+      await rebuildChart()
+  }
+}
+
+// 删除了添加逻辑,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed add logic because this is a public page for doctors or family members to view patient health data
+
+// 删除了删除记录功能,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed delete record function because this is a public page for doctors or family members to view patient health data
+</script>
+
+<style scoped>
+.page {
+  min-height: calc(100vh);
+  padding-top: calc(var(--status-bar-height) + 44px);
+  background: #f5f6f8;
+  box-sizing: border-box
+}
+
+.header {
+  padding: 20rpx 40rpx
+}
+
+.month-selector {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx
+}
+
+.period-controls {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.view-toggle {
+  display: flex;
+  gap: 4rpx;
+}
+
+.toggle-btn {
+  padding: 4rpx 12rpx;
+  border: 1rpx solid #ddd;
+  background: #f5f5f5;
+  color: #666;
+  border-radius: 6rpx;
+  font-size: 24rpx;
+  min-width: 60rpx;
+  text-align: center;
+}
+
+.toggle-btn.active {
+  background: #ff6a00;
+  color: #fff;
+  border-color: #ff6a00;
+}
+
+.month-label {
+  font-size: 34rpx;
+  color: #333
+}
+
+.btn {
+  background: transparent;
+  border: none;
+  font-size: 36rpx;
+  color: #666
+}
+
+.content {
+  padding: 20rpx 24rpx 100rpx 24rpx
+}
+
+.chart-wrap {
+  height: 340rpx;
+  overflow: hidden; /* 隐藏溢出内容 */
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  margin: 0 24rpx 20rpx 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
+}
+
+.chart-header {
+  font-size: 32rpx;
+  color: #333;
+  margin-bottom: 20rpx;
+  font-weight: 600
+}
+
+/* 关键修复:确保canvas样式正确,参考微信小程序示例 */
+.chart-canvas {    margin-left: -10rpx;
+  height: 280rpx;
+  background-color: #FFFFFF;
+  display: block;
+}
+
+.summary {
+  padding: 20rpx;
+  color: #666;
+  font-size: 28rpx
+}
+
+.list {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 10rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
+}
+
+.empty {
+  padding: 40rpx;
+  text-align: center;
+  color: #999
+}
+
+.list-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx;
+  border-bottom: 1rpx solid #f0f0f0
+}
+
+.list-item .date {
+  color: #666
+}
+
+.list-item .value {
+  color: #333;
+  font-weight: 600;
+  flex: 1;
+  text-align: right
+}
+
+/* 删除了浮动按钮样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed floating button styles because this is a public page for doctors or family members to view patient health data */
+
+/* 删除了模态框样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed modal styles because this is a public page for doctors or family members to view patient health data */
+</style>

+ 494 - 0
src/pages/public/health/details/blood-pressure.vue

@@ -0,0 +1,494 @@
+<template>
+  <CustomNav title="血压" leftType="back" />
+  <view class="page">
+
+    <view class="header">
+      <view class="month-selector">
+        <button class="btn" @click="prevPeriod">‹</button>
+        <view class="period-controls">
+          <picker mode="multiSelector" :value="pickerValue" :range="pickerRange" @change="onPickerChange">
+            <view class="month-label">{{ displayPeriod }}</view>
+          </picker>
+          <view class="view-toggle">
+            <button :class="['toggle-btn', { active: viewMode === 'month' }]" @click="setViewMode('month')">月</button>
+            <button :class="['toggle-btn', { active: viewMode === 'week' }]" @click="setViewMode('week')">周</button>
+          </view>
+        </view>
+        <button class="btn" @click="nextPeriod">›</button>
+      </view>
+    </view>
+
+    <!-- 趋势图 - 简化canvas设置 -->
+    <view class="chart-wrap">
+      <view class="chart-header">本月趋势</view>
+      <canvas 
+        canvas-id="bpChart" 
+        id="bpChart" 
+        class="chart-canvas"
+        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      ></canvas>
+    </view>
+
+    <view class="content">
+      <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageSystolic }}/{{ averageDiastolic }} mmHg</view>
+
+      <view class="list">
+        <view v-if="records.length === 0" class="empty">暂无记录</view>
+        <view v-for="item in records" :key="item.id" class="list-item" :style="{ backgroundColor: getItemColor(item.s, item.d) }">
+          <view class="date">{{ item.date }}</view>
+          <view class="value">{{ item.s }}/{{ item.d }} mmHg</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 删除了添加按钮和相关功能,因为这是公共页面,仅供医生或家属查看患者健康数据 -->
+    <!-- Removed add button and related functions because this is a public page for doctors or family members to view patient health data -->
+
+  </view>
+
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
+import { onShow, onLoad } from '@dcloudio/uni-app'
+import { listBloodPressureByBoundUser } from '@/api/bloodPressure'
+import { createUChart } from '@/composables/useUChart'
+import CustomNav from '@/components/custom-nav.vue'
+
+import ScaleRuler from '@/components/scale-ruler.vue'
+import { getWeekStart, getWeekEnd, formatDisplayDate, formatPickerDate, daysInMonth, getTodayStart, isAfterTodayDate, isMonthAfterToday, isWeekAfterToday } from '@/utils/date'
+import { getWindowWidth } from '@/utils/platform'
+
+type RecordItem = { id: string; date: string; s: number; d: number }
+
+// 当前展示年月
+const current = ref(new Date())
+// 使用 multiSelector 的索引形式: [yearOffset从2000起, month(0-11)]
+const pickerValue = ref([current.value.getFullYear() - 2000, current.value.getMonth()])
+
+// 视图模式:'month' 或 'week'
+const viewMode = ref<'month' | 'week'>('month')
+
+// 年月选择器的选项范围(与 height/weight 保持一致)
+const pickerRange = ref([
+  Array.from({ length: 50 }, (_, i) => `${2000 + i}年`),
+  Array.from({ length: 12 }, (_, i) => `${i + 1}月`)
+])
+
+// 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
+const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
+const canvasHeight = ref(320)
+
+// 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
+function getCanvasSize(): Promise<{ width: number; height: number }> {
+  return new Promise(async (resolve) => {
+    const width = await getWindowWidth().catch(() => 375)
+    const height = Math.round((320 / 750) * width)
+    resolve({ width, height })
+  })
+}
+
+// 使用 formatPickerDate 从 src/utils/date.ts
+
+const displayYear = computed(() => current.value.getFullYear())
+const displayMonth = computed(() => current.value.getMonth() + 1)
+
+// 显示周期(支持月/周)
+const displayPeriod = computed(() => {
+  if (viewMode.value === 'month') {
+    return `${displayYear.value}年 ${displayMonth.value}月`
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    return `${formatDisplayDate(weekStart)} - ${formatDisplayDate(weekEnd)}`
+  }
+})
+
+const records = ref<RecordItem[]>([])
+const patientId = ref<string | null>(null)
+const bindingType = ref<string | null>(null)
+
+// 页面加载时检查是否传入了患者ID和绑定类型
+onLoad((options) => {
+  if (options && options.patientId && options.bindingType) {
+    patientId.value = options.patientId
+    bindingType.value = options.bindingType
+  } else {
+    // 如果没有传入patientId或bindingType,则弹窗提示并返回上一页
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none',
+      duration: 2000
+    })
+    setTimeout(() => {
+      uni.navigateBack()
+    }, 2000)
+  }
+})
+
+async function fetchRecords() {
+  let startTime = ''
+  let endTime = ''
+  if (viewMode.value === 'month') {
+    const y = current.value.getFullYear()
+    const m = current.value.getMonth()
+    startTime = new Date(y, m, 1).toISOString()
+    const endDate = new Date(y, m + 1, 0)
+    endDate.setHours(23, 59, 59, 999)
+    endTime = endDate.toISOString()
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    startTime = weekStart.toISOString()
+    try {
+      const we = new Date(weekEnd)
+      we.setHours(23, 59, 59, 999)
+      endTime = we.toISOString()
+    } catch (e) {
+      endTime = weekEnd.toISOString()
+    }
+  }
+
+  try { if (typeof uni !== 'undefined' && uni.showLoading) uni.showLoading({ title: '加载中...' }) } catch (e) {}
+
+  try {
+    // 使用新的 ByBoundUser 接口
+    if (patientId.value && bindingType.value) {
+      const params = {
+        patientUserId: patientId.value,
+        bindingType: bindingType.value,
+        baseQueryRequest: { 
+          pageNum: 1, 
+          pageSize: 100, 
+          startTime, 
+          endTime 
+        }
+      }
+      
+      const res: any = await listBloodPressureByBoundUser(params)
+      if (res.statusCode === 401) {
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
+        uni.reLaunch({ url: '/pages/public/login/index' })
+        return
+      }
+      if ((res.data as any) && (res.data as any).code === 200) {
+        const apiRecords = (res.data as any).data?.records || []
+        records.value = apiRecords.map((item: any) => ({ id: String(item.id), date: formatDisplayDate(new Date(item.measureTime)), s: Number(item.systolicPressure || 0), d: Number(item.diastolicPressure || 0) }))
+        try { await bpChart.draw(records, current, viewMode) } catch (e) { console.warn('bpChart draw failed', e) }
+      } else {
+        console.error('Fetch blood-pressure records failed', res.data)
+      }
+    }
+  } catch (e) {
+    console.error('Fetch blood-pressure error', e)
+  } finally {
+    try { if (typeof uni !== 'undefined' && uni.hideLoading) uni.hideLoading() } catch (e) {}
+  }
+}
+
+// 将 records 聚合为每天一个点(取最新记录)
+function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
+  const map = new Map<number, RecordItem>()
+  for (const r of recordsArr) {
+    const parts = r.date.split('-')
+    if (parts.length >= 3) {
+      const y = parseInt(parts[0], 10)
+      const m = parseInt(parts[1], 10) - 1
+      const d = parseInt(parts[2], 10)
+      if (y === year && m === month) {
+        // 覆盖同一天,保留最新的(数组头部为最新)
+        map.set(d, r)
+      }
+    }
+  }
+  // 返回按日索引的数组
+  return map
+}
+
+const averageSystolic = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.s, 0)
+  return Math.round(sum / records.value.length)
+})
+const averageDiastolic = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.d, 0)
+  return Math.round(sum / records.value.length)
+})
+
+// 根据血压值获取颜色
+function getItemColor(s: number, d: number): string {
+  if (s < 120 && d < 80) {
+    return '#e8f5e8' // 绿
+  } else if (s < 140 && d < 90) {
+    return '#fff3cd' // 黄
+  } else {
+    return '#f8d7da' // 红
+  }
+}
+
+// 使用共享日期工具 (src/utils/date.ts)
+
+// 使用可复用的 chart composable,支持多序列
+const vm = getCurrentInstance()
+const bpChart = createUChart({
+  canvasId: 'bpChart',
+  vm,
+  getCanvasSize,
+  seriesNames: ['收缩压', '舒张压'],
+  valueAccessors: [ (r: RecordItem) => r.s, (r: RecordItem) => r.d ],
+  colors: ['#ff6a00', '#007aff']
+})
+
+onMounted(() => {
+  // 延迟确保DOM渲染完成并设置canvas尺寸
+  setTimeout(async () => {
+    await nextTick()
+    try {
+      const size = await getCanvasSize()
+      canvasWidth.value = size.width
+      canvasHeight.value = size.height
+    } catch (e) {
+      console.warn('getCanvasSize failed on mounted', e)
+    }
+    // 拉取数据并绘制
+    await fetchRecords()
+    try { await bpChart.draw(records, current, viewMode) } catch (e) { console.warn('bpChart draw failed', e) }
+  }, 500)
+})
+
+// 页面显示时检查登录态
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  }
+})
+
+// 监听并更新图表(轻微去抖)
+watch([() => current.value], async () => {
+  setTimeout(async () => {
+    await bpChart.update(records, current, viewMode)
+  }, 100)
+})
+
+watch([() => records.value], async () => {
+  setTimeout(async () => {
+    await bpChart.update(records, current, viewMode)
+  }, 100)
+}, { deep: true })
+
+onBeforeUnmount(() => {
+  try { bpChart.destroy() } catch (e) { console.warn('bpChart destroy error', e) }
+})
+
+// 强制重建图表(用于切换月份时彻底刷新)
+async function rebuildChart() {
+  try { await bpChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart failed', e) }
+}
+
+// 使用共享日期工具(在 src/utils/date.ts 中定义)
+
+// 周/月周期导航与 Picker 处理
+async function prevPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() - 1)
+  } else {
+    d.setDate(d.getDate() - 7)
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function nextPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() + 1)
+    if (isMonthAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  } else {
+    d.setDate(d.getDate() + 7)
+    if (isWeekAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function setViewMode(mode: 'month' | 'week') {
+  if (viewMode.value !== mode) {
+    viewMode.value = mode
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+async function onPickerChange(e: any) {
+  const val = e?.detail?.value || e
+  if (Array.isArray(val) && val.length >= 2) {
+    const y = 2000 + val[0]
+    const m = val[1]
+    let d = new Date(y, m, 1)
+    // 不允许选择未来的月份
+    if (isMonthAfterToday(d)) {
+      const today = getTodayStart()
+      uni.showToast && uni.showToast({ title: '不能选择未来的月份,已切换到当前月份', icon: 'none' })
+      d = new Date(today.getFullYear(), today.getMonth(), 1)
+      pickerValue.value = [today.getFullYear() - 2000, today.getMonth()]
+    } else {
+      pickerValue.value = [val[0], val[1]]
+    }
+    current.value = d
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+// 删除了添加逻辑,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed add logic because this is a public page for doctors or family members to view patient health data
+
+// 删除了删除记录功能,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed delete record function because this is a public page for doctors or family members to view patient health data
+</script>
+
+<style scoped>
+.page { 
+  min-height: calc(100vh); 
+  padding-top: calc(var(--status-bar-height) + 44px); 
+  background: #f5f6f8; 
+  box-sizing: border-box 
+}
+
+.header { 
+  padding: 20rpx 40rpx 
+}
+
+.month-selector { 
+  display: flex; 
+  align-items: center; 
+  justify-content: center; 
+  gap: 12rpx 
+}
+
+.period-controls {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.view-toggle {
+  display: flex;
+  gap: 4rpx;
+}
+
+.toggle-btn {
+  padding: 4rpx 12rpx;
+  border: 1rpx solid #ddd;
+  background: #f5f5f5;
+  color: #666;
+  border-radius: 6rpx;
+  font-size: 24rpx;
+  min-width: 60rpx;
+  text-align: center;
+}
+
+.toggle-btn.active {
+  background: #ff6a00;
+  color: #fff;
+  border-color: #ff6a00;
+}
+
+.month-label { 
+  font-size: 34rpx; 
+  color: #333 
+}
+
+.btn { 
+  background: transparent; 
+  border: none; 
+  font-size: 36rpx; 
+  color: #666 
+}
+
+.content { 
+  padding: 20rpx 24rpx 100rpx 24rpx 
+}
+
+.chart-wrap { 
+  height: 380rpx;
+  overflow: hidden; /* 隐藏溢出内容 */
+  background: #fff; 
+  border-radius: 12rpx; 
+  padding: 24rpx; 
+  margin: 0 24rpx 20rpx 24rpx; 
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) 
+}
+
+.chart-header { 
+  font-size: 32rpx; 
+  color: #333; 
+  margin-bottom: 20rpx; 
+  font-weight: 600 
+}
+
+/* 关键修复:确保canvas样式正确,参考微信小程序示例 */
+.chart-canvas {    margin-left: -10rpx;
+  height: 320rpx;
+  background-color: #FFFFFF;
+  display: block;
+}
+
+.summary { 
+  padding: 20rpx; 
+  color: #666; 
+  font-size: 28rpx 
+}
+
+.list { 
+  background: #fff; 
+  border-radius: 12rpx; 
+  padding: 10rpx; 
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) 
+}
+
+.empty { 
+  padding: 40rpx; 
+  text-align: center; 
+  color: #999 
+}
+
+.list-item { 
+  display: flex; 
+  align-items: center; 
+  padding: 20rpx; 
+  border-bottom: 1rpx solid #f0f0f0 
+}
+
+.list-item .date { 
+  color: #666 
+}
+
+.list-item .value { 
+  color: #333; 
+  font-weight: 600; 
+  flex: 1; 
+  text-align: right 
+}
+
+/* 删除了浮动按钮样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed floating button styles because this is a public page for doctors or family members to view patient health data */
+
+/* 删除了模态框样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed modal styles because this is a public page for doctors or family members to view patient health data */
+</style>

+ 488 - 0
src/pages/public/health/details/heart-rate.vue

@@ -0,0 +1,488 @@
+<template>
+  <CustomNav title="心率" leftType="back" />
+  <view class="page">
+
+    <view class="header">
+      <view class="month-selector">
+        <button class="btn" @click="prevPeriod">‹</button>
+        <view class="period-controls">
+          <picker mode="multiSelector" :value="pickerValue" :range="pickerRange" @change="onPickerChange">
+            <view class="month-label">{{ displayPeriod }}</view>
+          </picker>
+          <view class="view-toggle">
+            <button :class="['toggle-btn', { active: viewMode === 'month' }]" @click="setViewMode('month')">月</button>
+            <button :class="['toggle-btn', { active: viewMode === 'week' }]" @click="setViewMode('week')">周</button>
+          </view>
+        </view>
+        <button class="btn" @click="nextPeriod">›</button>
+      </view>
+    </view>
+
+    <!-- 趋势图 - 简化canvas设置 -->
+    <view class="chart-wrap">
+      <view class="chart-header">本月趋势</view>
+      <canvas 
+        canvas-id="hrChart" 
+        id="hrChart" 
+        class="chart-canvas"
+        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      ></canvas>
+    </view>
+
+    <view class="content">
+      <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHR }} bpm</view>
+
+      <view class="list">
+        <view v-if="records.length === 0" class="empty">暂无记录</view>
+        <view v-for="item in records" :key="item.id" class="list-item">
+          <view class="date">{{ item.date }}</view>
+          <view class="value">{{ item.hr }} bpm</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 删除了添加按钮和相关功能,因为这是公共页面,仅供医生或家属查看患者健康数据 -->
+    <!-- Removed add button and related functions because this is a public page for doctors or family members to view patient health data -->
+
+  </view>
+
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
+import { onShow, onLoad } from '@dcloudio/uni-app'
+import { listHeartRateByBoundUser } from '@/api/heartRate'
+import { createUChart } from '@/composables/useUChart'
+import CustomNav from '@/components/custom-nav.vue'
+
+import ScaleRuler from '@/components/scale-ruler.vue'
+import { getWeekStart, getWeekEnd, formatDisplayDate, formatPickerDate, daysInMonth, getTodayStart, isAfterTodayDate, isMonthAfterToday, isWeekAfterToday } from '@/utils/date'
+import { getWindowWidth } from '@/utils/platform'
+
+type RecordItem = { id: string; date: string; hr: number }
+
+// 当前展示年月
+const current = ref(new Date())
+// picker 使用 multiSelector,两列:年份偏移(2000起),月份(0-11)
+const pickerValue = ref([current.value.getFullYear() - 2000, current.value.getMonth()])
+
+// 视图模式:'month' 或 'week'
+const viewMode = ref<'month' | 'week'>('month')
+
+// 年月选择器的选项范围
+const pickerRange = ref([
+  Array.from({ length: 50 }, (_, i) => `${2000 + i}年`),
+  Array.from({ length: 12 }, (_, i) => `${i + 1}月`)
+])
+
+// 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
+const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
+const canvasHeight = ref(280)
+
+// 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
+function getCanvasSize(): Promise<{ width: number; height: number }> {
+  return new Promise(async (resolve) => {
+    const width = await getWindowWidth().catch(() => 375)
+    const height = Math.round((280 / 750) * width)
+    resolve({ width, height })
+  })
+}
+
+// 使用共享日期工具 (src/utils/date.ts)
+
+const displayYear = computed(() => current.value.getFullYear())
+const displayMonth = computed(() => current.value.getMonth() + 1)
+
+const displayPeriod = computed(() => {
+  if (viewMode.value === 'month') {
+    return `${displayYear.value}年 ${displayMonth.value}月`
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    return `${formatDisplayDate(weekStart)} - ${formatDisplayDate(weekEnd)}`
+  }
+})
+
+const records = ref<RecordItem[]>([])
+const patientId = ref<string | null>(null)
+const bindingType = ref<string | null>(null)
+
+// 页面加载时检查是否传入了患者ID和绑定类型
+onLoad((options) => {
+  if (options && options.patientId && options.bindingType) {
+    patientId.value = options.patientId
+    bindingType.value = options.bindingType
+  } else {
+    // 如果没有传入patientId或bindingType,则弹窗提示并返回上一页
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none',
+      duration: 2000
+    })
+    setTimeout(() => {
+      uni.navigateBack()
+    }, 2000)
+  }
+})
+
+// 从后端拉取心率记录
+async function fetchRecords() {
+  let startTime = ''
+  let endTime = ''
+  if (viewMode.value === 'month') {
+    const y = current.value.getFullYear()
+    const m = current.value.getMonth()
+    startTime = new Date(y, m, 1).toISOString()
+    const endDate = new Date(y, m + 1, 0)
+    endDate.setHours(23, 59, 59, 999)
+    endTime = endDate.toISOString()
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    startTime = weekStart.toISOString()
+    try {
+      const we = new Date(weekEnd)
+      we.setHours(23, 59, 59, 999)
+      endTime = we.toISOString()
+    } catch (e) {
+      endTime = weekEnd.toISOString()
+    }
+  }
+
+  try {
+    if (typeof uni !== 'undefined' && uni.showLoading) uni.showLoading({ title: '加载中...' })
+  } catch (e) {}
+
+  try {
+    // 使用新的 ByBoundUser 接口
+    if (patientId.value && bindingType.value) {
+      const params = {
+        patientUserId: patientId.value,
+        bindingType: bindingType.value,
+        baseQueryRequest: { 
+          pageNum: 1, 
+          pageSize: 100, 
+          startTime, 
+          endTime 
+        }
+      }
+      
+      const res: any = await listHeartRateByBoundUser(params)
+      if (res.statusCode === 401) {
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
+        uni.reLaunch({ url: '/pages/public/login/index' })
+        return
+      }
+      if ((res.data as any) && (res.data as any).code === 200) {
+        const apiRecords = (res.data as any).data?.records || []
+        records.value = apiRecords.map((item: any) => {
+          const hr = item.heartRate == null ? 0 : Number(item.heartRate)
+          return {
+            id: String(item.id),
+            date: formatDisplayDate(new Date(item.measureTime)),
+            hr: Number.isNaN(hr) ? 0 : hr
+          }
+        })
+        try { await hrChart.draw(records, current, viewMode) } catch (e) { console.warn('hrChart draw failed', e) }
+      } else {
+        console.error('Fetch heart-rate records failed', res.data)
+      }
+    }
+  } catch (e) {
+    console.error('Fetch heart-rate error', e)
+  } finally {
+    try { if (typeof uni !== 'undefined' && uni.hideLoading) uni.hideLoading() } catch (e) {}
+  }
+}
+
+// 获取指定日期所在周的开始日期(星期一),并规范化到本地午夜
+// 使用共享日期工具 (src/utils/date.ts)
+
+// 将 records 聚合为每天一个点(取最新记录)
+function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
+  const map = new Map<number, RecordItem>()
+  for (const r of recordsArr) {
+    const parts = r.date.split('-')
+    if (parts.length >= 3) {
+      const y = parseInt(parts[0], 10)
+      const m = parseInt(parts[1], 10) - 1
+      const d = parseInt(parts[2], 10)
+      if (y === year && m === month) {
+        // 覆盖同一天,保留最新的(数组头部为最新)
+        map.set(d, r)
+      }
+    }
+  }
+  // 返回按日索引的数组
+  return map
+}
+
+// 使用 formatDisplayDate 从 src/utils/date.ts
+
+const averageHR = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.hr, 0)
+  return (sum / records.value.length).toFixed(0)
+})
+
+// 使用 daysInMonth 从 src/utils/date.ts
+
+// 使用可复用的 chart composable(单序列)
+const vm = getCurrentInstance()
+const hrChart = createUChart({
+  canvasId: 'hrChart',
+  vm,
+  getCanvasSize,
+  seriesNames: '心率',
+  valueAccessors: (r: RecordItem) => r.hr,
+  colors: '#ff6a00'
+})
+
+onMounted(() => {
+  // 延迟确保DOM渲染完成并设置canvas尺寸
+  setTimeout(async () => {
+    await nextTick()
+    try {
+      const size = await getCanvasSize()
+      canvasWidth.value = size.width
+      canvasHeight.value = size.height
+    } catch (e) {
+      console.warn('getCanvasSize failed on mounted', e)
+    }
+    // 拉取数据并绘制
+    await fetchRecords()
+    try { await hrChart.draw(records, current, viewMode) } catch (e) { console.warn('hrChart draw failed', e) }
+  }, 500)
+})
+
+// 页面显示时检查登录态(与 physical.vue 保持一致)
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  }
+})
+
+// 监听并更新图表(轻微去抖)
+watch([() => current.value], async () => {
+  setTimeout(async () => {
+    await hrChart.update(records, current, viewMode)
+  }, 100)
+})
+
+watch([() => records.value], async () => {
+  setTimeout(async () => {
+    await hrChart.update(records, current, viewMode)
+  }, 100)
+}, { deep: true })
+
+onBeforeUnmount(() => {
+  try { hrChart.destroy() } catch (e) { console.warn('hrChart destroy error', e) }
+})
+
+// 强制重建图表(用于切换月份时彻底刷新)
+async function rebuildChart() {
+  try { await hrChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart failed', e) }
+}
+
+// 使用共享日期工具(在 src/utils/date.ts 中定义)
+
+// 周/月周期切换与 picker 处理
+async function prevPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') d.setMonth(d.getMonth() - 1)
+  else d.setDate(d.getDate() - 7)
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function nextPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() + 1)
+    if (isMonthAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  } else {
+    d.setDate(d.getDate() + 7)
+    if (isWeekAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function setViewMode(mode: 'month' | 'week') {
+  if (viewMode.value !== mode) {
+    viewMode.value = mode
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+async function onPickerChange(e: any) {
+  const val = e?.detail?.value || e
+  if (Array.isArray(val) && val.length >= 2) {
+    const y = 2000 + Number(val[0])
+    const m = Number(val[1])
+    let d = new Date(y, m, 1)
+    if (isMonthAfterToday(d)) {
+      const today = getTodayStart()
+      uni.showToast && uni.showToast({ title: '不能选择未来的月份,已切换到当前月份', icon: 'none' })
+      d = new Date(today.getFullYear(), today.getMonth(), 1)
+      pickerValue.value = [today.getFullYear() - 2000, today.getMonth()]
+    } else {
+      pickerValue.value = [Number(val[0]), Number(val[1])]
+    }
+    current.value = d
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+// 删除了添加逻辑,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed add logic because this is a public page for doctors or family members to view patient health data
+
+// 删除了删除记录功能,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed delete record function because this is a public page for doctors or family members to view patient health data
+</script>
+
+<style scoped>
+.page { 
+  min-height: calc(100vh); 
+  padding-top: calc(var(--status-bar-height) + 44px); 
+  background: #f5f6f8; 
+  box-sizing: border-box 
+}
+
+.header { 
+  padding: 20rpx 40rpx 
+}
+
+.month-selector { 
+  display: flex; 
+  align-items: center; 
+  justify-content: center; 
+  gap: 12rpx 
+}
+
+.period-controls {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.view-toggle {
+  display: flex;
+  gap: 4rpx;
+}
+
+.toggle-btn {
+  padding: 4rpx 12rpx;
+  border: 1rpx solid #ddd;
+  background: #f5f5f5;
+  color: #666;
+  border-radius: 6rpx;
+  font-size: 24rpx;
+  min-width: 60rpx;
+  text-align: center;
+}
+
+.toggle-btn.active {
+  background: #ff6a00;
+  color: #fff;
+  border-color: #ff6a00;
+}
+
+.month-label { 
+  font-size: 34rpx; 
+  color: #333 
+}
+
+.btn { 
+  background: transparent; 
+  border: none; 
+  font-size: 36rpx; 
+  color: #666 
+}
+
+.content { 
+  padding: 20rpx 24rpx 100rpx 24rpx 
+}
+
+.chart-wrap { 
+  height: 340rpx;
+  overflow: hidden; /* 隐藏溢出内容 */
+  background: #fff; 
+  border-radius: 12rpx; 
+  padding: 24rpx; 
+  margin: 0 24rpx 20rpx 24rpx; 
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) 
+}
+
+.chart-header { 
+  font-size: 32rpx; 
+  color: #333; 
+  margin-bottom: 20rpx; 
+  font-weight: 600 
+}
+
+/* 关键修复:确保canvas样式正确,参考微信小程序示例 */
+.chart-canvas {    margin-left: -10rpx;
+  height: 280rpx;
+  background-color: #FFFFFF;
+  display: block;
+}
+
+.summary { 
+  padding: 20rpx; 
+  color: #666; 
+  font-size: 28rpx 
+}
+
+.list { 
+  background: #fff; 
+  border-radius: 12rpx; 
+  padding: 10rpx; 
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) 
+}
+
+.empty { 
+  padding: 40rpx; 
+  text-align: center; 
+  color: #999 
+}
+
+.list-item { 
+  display: flex; 
+  align-items: center; 
+  padding: 20rpx; 
+  border-bottom: 1rpx solid #f0f0f0 
+}
+
+.list-item .date { 
+  color: #666 
+}
+
+.list-item .value { 
+  color: #333; 
+  font-weight: 600; 
+  flex: 1; 
+  text-align: right 
+}
+
+/* 删除了浮动按钮样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed floating button styles because this is a public page for doctors or family members to view patient health data */
+
+/* 删除了模态框样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed modal styles because this is a public page for doctors or family members to view patient health data */
+</style>

+ 596 - 0
src/pages/public/health/details/physical.vue

@@ -0,0 +1,596 @@
+<template>
+  <CustomNav title="体格数据" leftType="back" />
+  <view class="page">
+
+    <view class="header">
+      <view class="month-selector">
+        <button class="btn" @click="prevPeriod">‹</button>
+        <view class="period-controls">
+          <picker mode="multiSelector" :value="pickerValue" :range="pickerRange" @change="onPickerChange">
+            <view class="month-label">{{ displayPeriod }}</view>
+          </picker>
+          <view class="view-toggle">
+            <button :class="['toggle-btn', { active: viewMode === 'month' }]" @click="setViewMode('month')">月</button>
+            <button :class="['toggle-btn', { active: viewMode === 'week' }]" @click="setViewMode('week')">周</button>
+          </view>
+                  <view class="metric-toggle" style="margin-top:8rpx; display:flex; gap:8rpx;">
+                    <button :class="['toggle-btn', { active: selectedMetric === 'all' }]" @click.prevent="selectedMetric = 'all'">全部</button>
+                    <button :class="['toggle-btn', { active: selectedMetric === 'height' }]" @click.prevent="selectedMetric = 'height'">身高</button>
+                    <button :class="['toggle-btn', { active: selectedMetric === 'weight' }]" @click.prevent="selectedMetric = 'weight'">体重</button>
+                    <button :class="['toggle-btn', { active: selectedMetric === 'bmi' }]" @click.prevent="selectedMetric = 'bmi'">BMI</button>
+                  </view>
+        </view>
+        <button class="btn" @click="nextPeriod">›</button>
+      </view>
+    </view>
+
+    <!-- 趋势图 - 简化canvas设置 -->
+    <view class="chart-wrap">
+  <view class="chart-header">本月趋势</view>
+      <canvas 
+        canvas-id="bpChart" 
+        id="bpChart" 
+        class="chart-canvas"
+        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      ></canvas>
+    </view>
+
+    <view class="content">
+  <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHeight }} cm / {{ averageWeight }} kg;平均 BMI:{{ averageBMI }}</view>
+
+      <view class="list">
+        <view v-if="records.length === 0" class="empty">暂无记录</view>
+        <view v-for="item in records" :key="item.id" class="list-item" :style="{ backgroundColor: getItemColor(item.h, item.w) }">
+          <view class="date">{{ item.date }}</view>
+          <view class="value">{{ item.h }} cm / {{ item.w }} kg · BMI {{ item.bmi }}</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 删除了添加按钮和相关功能,因为这是公共页面,仅供医生或家属查看患者健康数据 -->
+    <!-- Removed add button and related functions because this is a public page for doctors or family members to view patient health data -->
+
+  </view>
+
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
+import { onShow, onLoad } from '@dcloudio/uni-app'
+import { createUChart } from '@/composables/useUChart'
+import CustomNav from '@/components/custom-nav.vue'
+
+import ScaleRuler from '@/components/scale-ruler.vue'
+import { getWeekStart, getWeekEnd, formatDisplayDate, formatPickerDate, daysInMonth, getTodayStart, isAfterTodayDate, isMonthAfterToday, isWeekAfterToday } from '@/utils/date'
+import { getWindowWidth, rpxToPx } from '@/utils/platform'
+import { listPhysicalByBoundUser } from '@/api/physical'
+
+type RecordItem = { id: string; date: string; h: number; w: number; bmi: number }
+
+// 当前展示年月
+const current = ref(new Date())
+// 使用 multiSelector 的索引形式: [yearOffset从2000起, month(0-11)]
+const pickerValue = ref([current.value.getFullYear() - 2000, current.value.getMonth()])
+
+// 视图模式:'month' 或 'week'
+const viewMode = ref<'month' | 'week'>('month')
+
+// 年月选择器的选项范围(与 height/weight 保持一致)
+const pickerRange = ref([
+  Array.from({ length: 50 }, (_, i) => `${2000 + i}年`),
+  Array.from({ length: 12 }, (_, i) => `${i + 1}月`)
+])
+
+// 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
+const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
+const canvasHeight = ref(320)
+
+// 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
+function getCanvasSize(): Promise<{ width: number; height: number }> {
+  return new Promise(async (resolve) => {
+    const width = await getWindowWidth().catch(() => 375)
+    const height = Math.round((320 / 750) * width)
+    resolve({ width, height })
+  })
+}
+
+// 使用 formatPickerDate 从 src/utils/date.ts
+
+const displayYear = computed(() => current.value.getFullYear())
+const displayMonth = computed(() => current.value.getMonth() + 1)
+
+// 显示周期(支持月/周)
+const displayPeriod = computed(() => {
+  if (viewMode.value === 'month') {
+    return `${displayYear.value}年 ${displayMonth.value}月`
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    return `${formatDisplayDate(weekStart)} - ${formatDisplayDate(weekEnd)}`
+  }
+})
+
+const records = ref<RecordItem[]>([])
+const patientId = ref<string | null>(null)
+const bindingType = ref<string | null>(null)
+
+// 页面加载时检查是否传入了患者ID和绑定类型
+onLoad((options) => {
+  if (options && options.patientId && options.bindingType) {
+    patientId.value = options.patientId
+    bindingType.value = options.bindingType
+  } else {
+    // 如果没有传入patientId或bindingType,则弹窗提示并返回上一页
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none',
+      duration: 2000
+    })
+    setTimeout(() => {
+      uni.navigateBack()
+    }, 2000)
+  }
+})
+
+// 将 records 聚合为每天一个点(取最新记录)
+function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
+  const map = new Map<number, RecordItem>()
+  for (const r of recordsArr) {
+    const parts = r.date.split('-')
+    if (parts.length >= 3) {
+      const y = parseInt(parts[0], 10)
+      const m = parseInt(parts[1], 10) - 1
+      const d = parseInt(parts[2], 10)
+      if (y === year && m === month) {
+        // 覆盖同一天,保留最新的(数组头部为最新)
+        map.set(d, r)
+      }
+    }
+  }
+  // 返回按日索引的数组
+  return map
+}
+
+async function fetchRecords() {
+  let startTime = ''
+  let endTime = ''
+  if (viewMode.value === 'month') {
+    const y = current.value.getFullYear()
+    const m = current.value.getMonth()
+    startTime = new Date(y, m, 1).toISOString()
+    // 将结束时间设为当日 23:59:59.999,避免 ISO 时间为当天 00:00
+    const endDate = new Date(y, m + 1, 0)
+    endDate.setHours(23, 59, 59, 999)
+    endTime = endDate.toISOString()
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    // 确保周结束时间也是该日的 23:59:59.999
+    startTime = weekStart.toISOString()
+    try {
+      const we = new Date(weekEnd)
+      we.setHours(23, 59, 59, 999)
+      endTime = we.toISOString()
+    } catch (e) {
+      endTime = weekEnd.toISOString()
+    }
+  }
+  // 在发送请求前显示 loading 提示;使用 finally 确保在任何情况下都隐藏
+  try {
+    if (typeof uni !== 'undefined' && uni.showLoading) uni.showLoading({ title: '加载中...' })
+  } catch (e) {
+    // ignore
+  }
+
+  try {
+    // 使用新的 ByBoundUser 接口
+    if (patientId.value && bindingType.value) {
+      const params = {
+        patientUserId: patientId.value,
+        bindingType: bindingType.value,
+        baseQueryRequest: { 
+          pageNum: 1, 
+          pageSize: 100, 
+          startTime, 
+          endTime 
+        }
+      }
+      
+      const res = await listPhysicalByBoundUser(params)
+      if (res.statusCode === 401) {
+        // Token 无效,清除并跳转登录
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
+        uni.reLaunch({ url: '/pages/public/login/index' })
+        return
+      }
+      if ((res.data as any) && (res.data as any).code === 200) {
+        const apiRecords = (res.data as any).data?.records || []
+        // 将后端记录映射为前端格式:确保 height/weight 为数字,优先使用后端返回的 bmi,若无则在客户端计算并保留 1 位小数
+        records.value = apiRecords.map((item: any) => {
+          const h = item.height == null ? 0 : Number(item.height)
+          const w = item.weight == null ? 0 : Number(item.weight)
+          let bmiVal: number | null = null
+          if (item.bmi != null && item.bmi !== '') {
+            const parsed = Number(item.bmi)
+            if (!Number.isNaN(parsed)) bmiVal = Math.round(parsed * 10) / 10
+          }
+          if (bmiVal == null && h > 0 && w > 0) {
+            bmiVal = Math.round((w / ((h / 100) * (h / 100))) * 10) / 10
+          }
+          return {
+            id: String(item.id),
+            date: formatDisplayDate(new Date(item.measureTime)),
+            h,
+            w,
+            bmi: bmiVal ?? 0
+          }
+        })
+
+        rebuildChart()
+      } else {
+        console.error('Fetch records failed', res.data)
+      }
+    }
+  } catch (e) {
+    console.error('Fetch records error', e)
+  } finally {
+    try {
+      if (typeof uni !== 'undefined' && uni.hideLoading) uni.hideLoading()
+    } catch (e) {
+      // ignore
+    }
+  }
+}
+
+const averageHeight = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.h, 0)
+  return Math.round(sum / records.value.length)
+})
+const averageWeight = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + r.w, 0)
+  return Math.round(sum / records.value.length)
+})
+const averageBMI = computed(() => {
+  if (records.value.length === 0) return '--'
+  const sum = records.value.reduce((s, r) => s + (r.bmi || 0), 0)
+  return Math.round((sum / records.value.length) * 10) / 10
+})
+
+// 根据血压值获取颜色
+function getItemColor(h: number, w: number): string {
+  // 根据 BMI 简单分类
+  if (!h || !w) return '#ffffff'
+  const bmi = w / ((h / 100) * (h / 100))
+  if (bmi < 18.5) return '#fff3cd' // 偏瘦 - 黄
+  if (bmi < 25) return '#e8f5e8' // 正常 - 绿
+  if (bmi < 30) return '#fff3cd' // 超重 - 黄
+  return '#f8d7da' // 肥胖 - 红
+}
+
+// 使用共享日期工具 (src/utils/date.ts)
+
+// 使用可复用的 chart composable,支持多序列
+const vm = getCurrentInstance()
+
+// 选择显示的指标:'all' | 'height' | 'weight' | 'bmi'
+const selectedMetric = ref<'all'|'height'|'weight'|'bmi'>('all')
+
+let bpChart: any = null
+
+function createChartForMetric(metric: 'all'|'height'|'weight'|'bmi') {
+  const seriesMap: Record<string, { names: string[]; accessors: Array<(r:any)=>number>; colors: string[] }> = {
+    all: { names: ['身高','体重','BMI'], accessors: [ (r: RecordItem) => r.h, (r: RecordItem) => r.w, (r: RecordItem) => r.bmi ], colors: ['#ff6a00', '#007aff', '#28c76f'] },
+    height: { names: ['身高'], accessors: [ (r: RecordItem) => r.h ], colors: ['#ff6a00'] },
+    weight: { names: ['体重'], accessors: [ (r: RecordItem) => r.w ], colors: ['#007aff'] },
+    bmi: { names: ['BMI'], accessors: [ (r: RecordItem) => r.bmi ], colors: ['#28c76f'] }
+  }
+  const cfg = seriesMap[metric]
+  return createUChart({
+    canvasId: 'bpChart',
+    vm,
+    getCanvasSize,
+    seriesNames: cfg.names,
+    valueAccessors: cfg.accessors,
+    colors: cfg.colors
+  })
+}
+
+// 延迟创建 chart:在 mounted 时先读取路由参数(metric),然后创建 chart 并绘制
+function initMetricFromRoute() {
+  try {
+    // 在 uni-app 中,可通过 getCurrentPages 获取当前页面的 options(包含 query)
+    const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
+    const currentPage = pages[pages.length - 1] || {}
+  const opts = (currentPage && (currentPage as any).options) ? (currentPage as any).options : {}
+  const metricParam = opts.metric || opts?.metric
+    if (metricParam && ['all', 'height', 'weight', 'bmi'].includes(metricParam)) {
+      ;(selectedMetric as any).value = metricParam
+    }
+  } catch (e) {
+    console.warn('initMetricFromRoute error', e)
+  }
+}
+
+onMounted(() => {
+  // 延迟确保DOM渲染完成并设置canvas尺寸
+  setTimeout(async () => {
+    await nextTick()
+    try {
+      const size = await getCanvasSize()
+      canvasWidth.value = size.width
+      canvasHeight.value = size.height
+    } catch (e) {
+      console.warn('getCanvasSize failed on mounted', e)
+    }
+
+    // 先从路由读取 metric(如果有),然后创建 chart
+    initMetricFromRoute()
+    await fetchRecords()
+    try {
+      if (!bpChart) bpChart = createChartForMetric(selectedMetric.value)
+    } catch (e) {
+      console.warn('createChartForMetric failed', e)
+    }
+
+    try {
+      if (bpChart && bpChart.draw) await bpChart.draw(records, current, viewMode)
+    } catch (e) {
+      console.warn('bpChart draw failed', e)
+    }
+  }, 500)
+})
+
+// 如果在微信小程序端且未登录,自动跳转到登录页
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  }
+})
+
+// 监听并更新图表(轻微去抖)
+watch([() => current.value], async () => {
+  setTimeout(async () => {
+    await bpChart.update(records, current, viewMode)
+  }, 100)
+})
+
+watch([() => records.value], async () => {
+  setTimeout(async () => {
+    // 如果图表实例已被替换,根据 current selectedMetric 的 chart 实例更新
+    try {
+      if (bpChart && bpChart.update) await bpChart.update(records, current, viewMode)
+    } catch (e) {
+      console.warn('bpChart update failed', e)
+    }
+  }, 100)
+}, { deep: true })
+
+// 监听 selectedMetric 变化,重建图表以使用不同的 series 配置
+watch(() => selectedMetric.value, async (val) => {
+  try {
+    if (bpChart && bpChart.destroy) {
+      try { bpChart.destroy() } catch (e) { /* ignore */ }
+    }
+    bpChart = createChartForMetric(val)
+    // small delay to ensure DOM/canvas availability
+    await nextTick()
+    try { await bpChart.draw(records, current, viewMode) } catch (e) { console.warn('draw after metric change failed', e) }
+  } catch (e) {
+    console.warn('selectedMetric watch error', e)
+  }
+})
+
+onBeforeUnmount(() => {
+  try { bpChart.destroy() } catch (e) { console.warn('bpChart destroy error', e) }
+})
+
+// 强制重建图表(用于切换月份时彻底刷新)
+async function rebuildChart() {
+  try { if (bpChart && bpChart.rebuild) await bpChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart failed', e) }
+}
+// 周/月周期导航与 Picker 处理
+async function prevPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() - 1)
+  } else {
+    d.setDate(d.getDate() - 7)
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function nextPeriod() {
+  const d = new Date(current.value)
+  if (viewMode.value === 'month') {
+    d.setMonth(d.getMonth() + 1)
+    if (isMonthAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  } else {
+    d.setDate(d.getDate() + 7)
+    if (isWeekAfterToday(d)) {
+      uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
+      return
+    }
+  }
+  current.value = d
+  pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
+  await fetchRecords()
+  await rebuildChart()
+}
+
+async function setViewMode(mode: 'month' | 'week') {
+  if (viewMode.value !== mode) {
+    viewMode.value = mode
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+async function onPickerChange(e: any) {
+  const val = e?.detail?.value || e
+  if (Array.isArray(val) && val.length >= 2) {
+    const y = 2000 + Number(val[0])
+    const m = Number(val[1])
+    let d = new Date(y, m, 1)
+    if (isMonthAfterToday(d)) {
+      const today = getTodayStart()
+      uni.showToast && uni.showToast({ title: '不能选择未来的月份,已切换到当前月份', icon: 'none' })
+      d = new Date(today.getFullYear(), today.getMonth(), 1)
+      pickerValue.value = [today.getFullYear() - 2000, today.getMonth()]
+    } else {
+      pickerValue.value = [Number(val[0]), Number(val[1])]
+    }
+    current.value = d
+    await fetchRecords()
+    await rebuildChart()
+  }
+}
+
+// 删除了添加逻辑,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed add logic because this is a public page for doctors or family members to view patient health data
+
+// 删除了删除记录功能,因为这是公共页面,仅供医生或家属查看患者健康数据
+// Removed delete record function because this is a public page for doctors or family members to view patient health data
+</script>
+
+<style scoped>
+.page { 
+  min-height: calc(100vh); 
+  padding-top: calc(var(--status-bar-height) + 44px); 
+  background: #f5f6f8; 
+  box-sizing: border-box 
+}
+
+.header { 
+  padding: 20rpx 40rpx 
+}
+
+.month-selector { 
+  display: flex; 
+  align-items: center; 
+  justify-content: center; 
+  gap: 12rpx 
+}
+
+.period-controls {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.view-toggle {
+  display: flex;
+  gap: 4rpx;
+}
+
+.toggle-btn {
+  padding: 4rpx 12rpx;
+  border: 1rpx solid #ddd;
+  background: #f5f5f5;
+  color: #666;
+  border-radius: 6rpx;
+  font-size: 24rpx;
+  min-width: 60rpx;
+  text-align: center;
+}
+
+.toggle-btn.active {
+  background: #ff6a00;
+  color: #fff;
+  border-color: #ff6a00;
+}
+
+.month-label { 
+  font-size: 34rpx; 
+  color: #333 
+}
+
+.btn { 
+  background: transparent; 
+  border: none; 
+  font-size: 36rpx; 
+  color: #666 
+}
+
+.content { 
+  padding: 20rpx 24rpx 100rpx 24rpx 
+}
+
+.chart-wrap { 
+  height: 380rpx;
+  overflow: hidden; /* 隐藏溢出内容 */
+  background: #fff; 
+  border-radius: 12rpx; 
+  padding: 24rpx; 
+  margin: 0 24rpx 20rpx 24rpx; 
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) 
+}
+
+.chart-header { 
+  font-size: 32rpx; 
+  color: #333; 
+  margin-bottom: 20rpx; 
+  font-weight: 600 
+}
+
+/* 关键修复:确保canvas样式正确,参考微信小程序示例 */
+.chart-canvas {    margin-left: -10rpx;
+  height: 320rpx;
+  background-color: #FFFFFF;
+  display: block;
+}
+
+.summary { 
+  padding: 20rpx; 
+  color: #666; 
+  font-size: 28rpx 
+}
+
+.list { 
+  background: #fff; 
+  border-radius: 12rpx; 
+  padding: 10rpx; 
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03) 
+}
+
+.empty { 
+  padding: 40rpx; 
+  text-align: center; 
+  color: #999 
+}
+
+.list-item { 
+  display: flex; 
+  align-items: center; 
+  padding: 20rpx; 
+  border-bottom: 1rpx solid #f0f0f0 
+}
+
+.list-item .date { 
+  color: #666 
+}
+
+.list-item .value { 
+  color: #333; 
+  font-weight: 600; 
+  flex: 1; 
+  text-align: right 
+}
+
+/* 删除了浮动按钮样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed floating button styles because this is a public page for doctors or family members to view patient health data */
+
+/* 删除了模态框样式,因为这是公共页面,仅供医生或家属查看患者健康数据 */
+/* Removed modal styles because this is a public page for doctors or family members to view patient health data */
+</style>

+ 214 - 71
src/pages/public/health/index.vue

@@ -1,79 +1,222 @@
 <template>
-	<CustomNav title="健康数据" leftType="back" />
-
-	<view class="page-container">
-		<view class="content">
-			<view v-if="!isLoggedIn" class="not-login">
-				<text class="tip">您需要登录才能体验健康数据管理功能。</text>
-				<button class="login-btn" @click="goLogin">去登录</button>
-			</view>
-
-			<!-- 已登录时不显示任何文字;重定向由脚本逻辑处理 -->
-		</view>
-	</view>
-	<TabBar />
+  <CustomNav title="健康数据" leftType="back" />
+  <view class="content">
+    <view class="menu-card">
+      <view class="menu-list">
+        <view class="menu-item" @click="openDetail('physical','height')">
+          <image src="/static/icons/remixicon/height.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">身高</text>
+          <text class="menu-value">--</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
+        <view class="menu-item" @click="openDetail('physical','weight')">
+          <image src="/static/icons/remixicon/weight.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">体重</text>
+          <text class="menu-value">80.0 公斤</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
+        <view class="menu-item" @click="openDetail('physical','bmi')">
+          <image src="/static/icons/remixicon/bmi.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">BMI</text>
+          <text class="menu-value">--</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
+        <view class="menu-item" @click="openDetail('blood-pressure')"><image src="/static/icons/remixicon/scan-line.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">血压</text>
+          <text class="menu-value">--</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
+        <view class="menu-item" @click="openDetail('blood-glucose')"><image src="/static/icons/remixicon/contrast-drop-2-line.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">血糖</text>
+          <text class="menu-value">--</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
+        <view class="menu-item" @click="openDetail('heart-rate')">
+          <image src="/static/icons/remixicon/heart-pulse.svg" class="menu-icon" mode="widthFix" />
+          <text class="menu-text">心率</text>
+          <text class="menu-value">--</text>
+          <uni-icons class="menu-arrow" type="arrowright" size="20" color="#c0c0c0" />
+        </view>
+      </view>
+    </view>
+
+    <view class="reminder-card">
+      <view class="reminder-button" @click="openReminder">
+        <image src="/static/icons/remixicon/alarm-line.svg" class="reminder-icon" mode="widthFix" />
+        <text class="reminder-text">健康提醒</text>
+        <uni-icons class="reminder-arrow" type="arrowright" size="20" color="#c0c0c0" />
+      </view>
+    </view>
+  </view>
+  <!-- 删除了TabBar组件,因为这是一个公共页面,供医生或家属像病人一样快速查看患者的健康数据 -->
+  <!-- TabBar is removed because this is a public page for doctors or family members to quickly view patient health data like patients do -->
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
-import { onShow } from '@dcloudio/uni-app'
+import { ref, onMounted } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+
 import CustomNav from '@/components/custom-nav.vue'
-import TabBar from '@/components/tab-bar.vue'
-import { isLoggedIn as checkLogin, getRole, roleToString } from '@/composables/useAuth'
-
-const isLoggedIn = ref<boolean>(checkLogin())
-const roleDisplay = ref<string>('')
-
-function goLogin() {
-	uni.navigateTo({ url: '/pages/public/login/index' })
-}
-
-// TODO: 目前我们不向后端请求来确认角色(虽然更可靠),而是直接使用本地存储中的 role 字段。
-// 注:后续可改为 fetchUserInfo 接口以后端返回的角色为准,或实现 token 刷新/校验逻辑。
-onShow(() => {
-	try {
-		const logged = checkLogin()
-		isLoggedIn.value = logged
-		if (!logged) {
-			roleDisplay.value = ''
-			return
-		}
-
-		const r = getRole()
-		roleDisplay.value = roleToString(r)
-
-		// 仅当角色为患者(3)时,发起跳转到患者端健康页(该页在患者端为 Tab)
-		// 未登录或非患者则留在公共健康页
-    // 我们已经在tab-bar.vue中处理了点击健康tab时的跳转逻辑,这里一般来说不会触发,
-    // 但为了保险起见,仍然保留这段代码以防万一
-		// TODO: 后续可支持医生/家属等其他角色的专属页面
-		if (r === 3) {
-			// patient health 在患者端 tab 中,使用 switchTab
-			console.log('[health/index] redirecting to patient health tab, logged=', logged, 'role=', r)
-			uni.switchTab({ url: '/pages/patient/health/index' })
-			return
-		}
-	} catch (err) {
-		console.error('检查登录态时出错:', err)
-		isLoggedIn.value = false
-		roleDisplay.value = ''
-	}
+// 删除了TabBar导入,因为这是一个公共页面,供医生或家属像病人一样快速查看患者的健康数据
+// Removed TabBar import because this is a public page for doctors or family members to quickly view patient health data like patients do
+
+const title = ref('健康数据')
+const patientId = ref<string | null>(null)
+const bindingType = ref<string | null>(null)
+
+// 页面加载时检查是否传入了患者ID和绑定类型
+onLoad((options) => {
+  console.log('传入的参数:', options)
+  if (options && options.patientId && options.bindingType) {
+    patientId.value = options.patientId
+    bindingType.value = options.bindingType
+    console.log('患者ID:', patientId.value)
+    console.log('绑定类型:', bindingType.value)
+  } else {
+    // 如果没有传入patientId或bindingType,则弹窗提示并返回上一页
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none',
+      duration: 2000
+    })
+    setTimeout(() => {
+      uni.navigateBack()
+    }, 2000)
+  }
 })
+
+const openDetail = (type: string, metric?: string) => {
+  // 检查是否有患者ID和绑定类型
+  if (!patientId.value || !bindingType.value) {
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none'
+    })
+    return
+  }
+  
+  const url = metric 
+    ? `details/${type}?metric=${metric}&patientId=${patientId.value}&bindingType=${bindingType.value}` 
+    : `details/${type}?patientId=${patientId.value}&bindingType=${bindingType.value}`
+  uni.navigateTo({ url })
+}
+
+const openReminder = () => {
+  // 检查是否有患者ID和绑定类型
+  if (!patientId.value || !bindingType.value) {
+    uni.showToast({
+      title: '未携带必要参数',
+      icon: 'none'
+    })
+    return
+  }
+  
+  uni.navigateTo({ url: `reminder?patientId=${patientId.value}&bindingType=${bindingType.value}` })
+}
 </script>
 
-<style>
-.page-container {
-	min-height: 100vh;
-	padding-top: calc(var(--status-bar-height) + 44px);
-	box-sizing: border-box;
-	display: flex;
-	justify-content: center;
-	align-items: center;
-	background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
-}
-.content { width: 100%; max-width: 720rpx; padding: 40rpx }
-.not-login, .logged { background: #fff; padding: 40rpx; border-radius: 16rpx; text-align:center; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.08) }
-.tip { display:block; font-size:28rpx; color:#333; margin-bottom:20rpx }
-.hint { display:block; font-size:22rpx; color:#666; margin-bottom:20rpx }
-.login-btn { background: linear-gradient(135deg,#07C160 0%, #00A854 100%); color: #fff; padding: 18rpx 30rpx; border-radius: 12rpx; border:none; font-size:28rpx }
-</style>
+<style scoped>
+.content {
+  padding-top: calc(var(--status-bar-height) + 44px);
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  box-sizing: border-box;
+}
+
+.text-area {
+  display: flex;
+  justify-content: center;
+}
+
+.title {
+  font-size: 36rpx;
+  color: #8f8f94;
+}
+
+.menu-card {
+  padding: 50rpx 0rpx;
+}
+
+.menu-list {
+  background-color: #fff;
+  border-radius: 12rpx;
+  overflow: hidden;
+}
+
+.menu-item {
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  padding: 30rpx 40rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.menu-item:last-child {
+  border-bottom: none
+}
+
+.menu-text {
+  font-size: 32rpx;
+  color: #000000;
+  flex: 1;
+  /* 字符间距,调整数值以获得所需视觉效果 */
+  letter-spacing: 1rpx;
+}
+
+.menu-value {
+  font-size: 28rpx;
+  color: #5a5a5a;
+  margin-right: 10rpx
+}
+
+.menu-arrow {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 44rpx;
+  height: 44rpx;
+}
+
+.menu-icon {
+  width: 40rpx;
+  height: 40rpx;
+  margin-right: 30rpx;
+  display: inline-block;
+}
+
+.reminder-card {
+  margin-top: 20rpx;
+  padding: 50rpx 0rpx;
+}
+
+.reminder-button {
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  padding: 30rpx 40rpx;
+  background-color: #fff;
+  border-radius: 12rpx;
+}
+
+.reminder-text {
+  font-size: 32rpx;
+  color: #000000;
+  flex: 1;
+  letter-spacing: 1rpx;
+}
+
+.reminder-arrow {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 44rpx;
+  height: 44rpx;
+}
+
+.reminder-icon {
+  width: 40rpx;
+  height: 40rpx;
+  margin-right: 30rpx;
+  display: inline-block;
+}
+</style>