Browse Source

feat(health): 重构血糖趋势图组件以提升渲染稳定性

- 引入 uCharts 库替代原生 Canvas 绘图逻辑
- 动态计算并设置 Canvas 尺寸,适配不同设备屏幕宽度
- 聚合每日多条记录,确保图表仅显示每天最新数据点
- 优化 X/Y 轴标签显示策略,避免文本重叠和溢出问题
- 固定 pixelRatio 为 1,防止高分辨率设备上元素过大
- 增加图表实例管理机制,支持安全销毁与重建
- 修复月份切换和数据更新时的图表刷新问题
- 调整样式确保图表容器与绘图区域正确对齐
- 添加右侧留白以防止数据点和坐标轴标签被截断
- 关闭动画与提示框以提升性能和减少干扰
- 实现 updateChartData 方法以支持高效数据更新
- 增强错误处理逻辑,提升组件健壮性
- 更新新增/删除记录后的图表重建逻辑
- 优化空数据状态下的图表展示逻辑
- 增加调试日志以便于问题排查
mcbaiyun 2 months ago
parent
commit
3d115390db
1 changed files with 784 additions and 173 deletions
  1. 784 173
      src/pages/health/details/blood-glucose.vue

+ 784 - 173
src/pages/health/details/blood-glucose.vue

@@ -13,9 +13,15 @@
       </picker>
     </view>
 
+    <!-- 趋势图 - 简化canvas设置 -->
     <view class="chart-wrap">
       <view class="chart-header">本月趋势</view>
-      <canvas ref="chartCanvas" id="bgChart" canvas-id="bgChart" class="chart-canvas" width="340" height="120"></canvas>
+      <canvas 
+        canvas-id="bgChart" 
+        id="bgChart" 
+        class="chart-canvas"
+        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      ></canvas>
     </view>
 
     <view class="content">
@@ -82,15 +88,32 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
+import uCharts from '@qiun/ucharts'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import ScaleRuler from '@/components/scale-ruler.vue'
 
 type RecordItem = { id: string; date: string; value: number; type: string }
 
+// 当前展示年月
 const current = ref(new Date())
 const pickerValue = ref(formatPickerDate(current.value))
 
+// 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
+const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
+const canvasHeight = ref(240)
+
+// 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
+function getCanvasSize(): Promise<{ width: number; height: number }> {
+  return new Promise((resolve) => {
+    // 使用固定尺寸,参考微信小程序示例
+    const windowWidth = uni.getSystemInfoSync().windowWidth;
+    const width = windowWidth; // 占满屏幕宽度
+    const height = 240 / 750 * windowWidth; // 240rpx转换为px,与CSS高度匹配
+    resolve({ width, height });
+  });
+}
+
 function formatPickerDate(d: Date) {
   const y = d.getFullYear()
   const m = String(d.getMonth() + 1).padStart(2, '0')
@@ -114,11 +137,35 @@ function generateMockRecords(d: Date): RecordItem[] {
     const date = new Date(y, m, day)
     const val = Number((3 + Math.random() * 10).toFixed(1))
     const type = typesLocal[Math.random() > 0.5 ? 0 : 1]
-    arr.push({ id: `${y}${m}${i}${Date.now()}`, date: formatDisplayDate(date), value: val, type })
+    arr.push({ 
+      id: `${y}${m}${i}${Date.now()}`, 
+      date: formatDisplayDate(date), 
+      value: val, 
+      type 
+    })
   }
   return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
 }
 
+// 将 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
+}
+
 function formatDisplayDate(d: Date) {
   return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
 }
@@ -133,167 +180,471 @@ function daysInMonth(year: number, month: number) {
   return new Date(year, month + 1, 0).getDate()
 }
 
-const pointCoords = computed(() => {
+// Canvas / uCharts 绘图 - 修复版本
+const chartInstance = ref<any>(null)
+const vm = getCurrentInstance()
+let chartInitialized = false
+let chartBusy = false // 绘图锁,防止并发初始化/更新
+
+// 简化的图表绘制函数
+async function drawChart() {
+  // 防止并发调用
+  if (chartBusy) return
+  chartBusy = true
+
+  // 防止重复初始化(已初始化则更新数据)
+  if (chartInitialized && chartInstance.value) {
+    try {
+      await updateChartData()
+    } finally {
+      chartBusy = false
+    }
+    return
+  }
+
+  // 清理旧实例
+  if (chartInstance.value) {
+    try {
+      if (chartInstance.value.destroy) {
+        chartInstance.value.destroy()
+      }
+    } catch (e) {
+      console.warn('Destroy chart error:', e)
+    }
+    chartInstance.value = null
+  }
+
+  if (typeof uCharts === 'undefined') {
+    console.warn('uCharts not available')
+    return
+  }
+
+  // 动态获取 Canvas 容器的宽高 (单位: px)
+  const size = await getCanvasSize();
+  const cssWidth = size.width;
+  const cssHeight = size.height;
+
+  // 获取可靠的设备像素比 - 固定为1避免高分辨率设备上元素过大
+  const pixelRatio = 1; // 关键修复:固定pixelRatio为1
+
+  // 为避免 X 轴标签或绘图区域右侧溢出,保留右侧间距,让绘图区域略窄于 canvas
+  const rightGap = Math.max(24, Math.round(cssWidth * 0.04)) // 最小 24px 或 4% 屏宽
+  const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
+
+  console.log('Canvas 尺寸与像素比:', { cssWidth, cssHeight, pixelRatio });
+
   const year = current.value.getFullYear()
   const month = current.value.getMonth()
   const days = daysInMonth(year, month)
-  const W = 340
-  const H = 120
-  const leftPad = 10
-  const rightPad = 10
-  const topPad = 10
-  const bottomPad = 10
-  const innerW = W - leftPad - rightPad
-  const innerH = H - topPad - bottomPad
-  const values: Array<number | null> = Array(days).fill(null)
-  records.value.forEach(r => {
+  
+  // 生成合理的categories - 只显示关键日期
+  const categories: string[] = []
+  const showLabelDays = []
+  
+  // 选择要显示的标签:1号、中间几天、最后一天
+  if (days > 0) {
+    showLabelDays.push(1) // 第1天
+    if (days > 1) showLabelDays.push(days) // 最后一天
+    // 中间添加2-3个关键点
+    if (days > 7) showLabelDays.push(Math.ceil(days / 3))
+    if (days > 14) showLabelDays.push(Math.ceil(days * 2 / 3))
+  }
+  
+  for (let d = 1; d <= days; d++) {
+    if (showLabelDays.includes(d)) {
+      categories.push(`${d}日`)
+    } else {
+      categories.push('')
+    }
+  }
+
+  // 只为有记录的天生成categories和data,避免将无数据天设为0
+  const data: number[] = []
+  const filteredCategories: string[] = []
+  // 使用Map按日聚合,保留最新记录(records 数组头部为最新)
+  const dayMap = new Map<number, RecordItem>()
+  for (const r of records.value) {
     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 && d >= 1 && d <= days) {
-        values[d - 1] = r.value
+      if (y === year && m === month) {
+        // 以最后遍历到的(数组顺序保证头部为最新)作为最终值
+        dayMap.set(d, r)
       }
     }
-  })
-  const numeric = values.filter(v => v !== null) as number[]
-  const min = numeric.length ? Math.min(...numeric) : 4
-  const max = numeric.length ? Math.max(...numeric) : 12
-  const range = Math.max(0.1, max - min)
-  const coords = values.map((v, idx) => {
-    const x = leftPad + (innerW * idx) / Math.max(1, days - 1)
-    if (v === null) return null
-    const y = topPad + innerH - ((v - min) / range) * innerH
-    return { x, y }
-  })
-  return { coords, min, max }
-})
+  }
 
-const chartCanvas = ref<HTMLCanvasElement | null>(null)
-const vm = getCurrentInstance()
+  // 将有数据的日期按日顺序输出
+  const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b)
+  for (const d of sortedDays) {
+    const rec = dayMap.get(d)!
+    filteredCategories.push(`${d}日`)
+    data.push(rec.value)
+  }
 
-function getCanvasSize(): Promise<{ width: number; height: number }> {
-  return new Promise(resolve => {
+  // 如果没有任何数据,则回退为整月空数据(避免 uCharts 抛错)
+  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
+
+  // 计算合理的Y轴范围
+  const validData = data.filter(v => v > 0)
+  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 1 : 2
+  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 1 : 12
+
+  const series = [{
+    name: '血糖',
+    data: data,
+    color: '#ff6a00'
+  }]
+
+  // 获取canvas上下文
+  let ctx: any = null
+  try {
+    if (typeof uni !== 'undefined' && typeof uni.createCanvasContext === 'function') {
+      // 小程序环境:优先尝试传入组件实例
+      try {
+        ctx = vm?.proxy ? uni.createCanvasContext('bgChart', vm.proxy) : uni.createCanvasContext('bgChart')
+      } catch (e) {
+        // 再尝试不传vm
+        try { ctx = uni.createCanvasContext('bgChart') } catch (err) { ctx = null }
+      }
+    }
+  } catch (e) {
+    ctx = null
+  }
+
+  // H5环境尝试使用DOM获取2D上下文
+  if (!ctx && typeof document !== 'undefined') {
     try {
-      if (typeof uni !== 'undefined' && uni.createSelectorQuery) {
-        uni.createSelectorQuery().select('#bgChart').boundingClientRect((res: any) => {
-          if (res) resolve({ width: res.width || 340, height: res.height || 120 })
-          else resolve({ width: 340, height: 120 })
-        }).exec()
-      } else if (chartCanvas.value) {
-        resolve({ width: chartCanvas.value.clientWidth || 340, height: chartCanvas.value.clientHeight || 120 })
-      } else {
-        resolve({ width: 340, height: 120 })
+      // 重试逻辑:初次可能未渲染到 DOM
+      let el: HTMLCanvasElement | null = null
+      for (let attempt = 0; attempt < 3; attempt++) {
+        el = document.getElementById('bgChart') as HTMLCanvasElement | null
+        if (el) break
+        // 短延迟后重试(非阻塞)
+        await new Promise(r => setTimeout(r, 50))
+      }
+      
+      if (el && el.getContext) {
+        // Ensure canvas actual pixel size matches cssWidth * pixelRatio
+        try {
+          const physicalW = Math.floor(cssWidth * pixelRatio)
+          const physicalH = Math.floor(cssHeight * pixelRatio)
+          if (el.width !== physicalW || el.height !== physicalH) {
+            el.width = physicalW
+            el.height = physicalH
+            // also adjust style to keep layout consistent
+            el.style.width = cssWidth + 'px'
+            el.style.height = cssHeight + 'px'
+          }
+        } catch (e) {
+          console.warn('Set canvas physical size failed', e)
+        }
+        ctx = el.getContext('2d')
       }
     } catch (e) {
-      resolve({ width: 340, height: 120 })
+      ctx = null
     }
+  }
+
+  if (!ctx) {
+    console.warn('Unable to obtain canvas context for uCharts. Ensure canvas-id matches and vm proxy is available on mini-program.')
+    return
+  }
+
+  console.log('Canvas config:', {
+    width: cssWidth,
+    height: cssHeight,
+    pixelRatio,
+    categoriesLength: categories.length,
+    dataPoints: data.length
   })
-}
 
-async function drawChart() {
-  const canvas = chartCanvas.value as HTMLCanvasElement | null
-  if (canvas && canvas.getContext) {
-    const ctx = canvas.getContext('2d')
-    if (!ctx) return
-    const dpr = (typeof uni !== 'undefined' && uni.getSystemInfoSync) ? (uni.getSystemInfoSync().pixelRatio || (window?.devicePixelRatio || 1)) : (window?.devicePixelRatio || 1)
-    const cssWidth = canvas.clientWidth || 340
-    const cssHeight = canvas.clientHeight || 120
-    canvas.width = Math.round(cssWidth * dpr)
-    canvas.height = Math.round(cssHeight * dpr)
-    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
-    ctx.clearRect(0, 0, cssWidth, cssHeight)
-    ctx.strokeStyle = 'rgba(0,0,0,0.04)'
-    ctx.lineWidth = 1
-    for (let i = 0; i <= 4; i++) {
-      const y = (cssHeight / 4) * i
-      ctx.beginPath()
-      ctx.moveTo(0, y)
-      ctx.lineTo(cssWidth, y)
-      ctx.stroke()
+  // 简化的uCharts配置 - 关闭所有可能产生重叠的选项
+  const config = {
+    $this: vm?.proxy,
+    canvasId: 'bgChart',
+    context: ctx,
+    type: 'line',
+    fontSize: 10, // 全局字体大小,参考微信小程序示例
+    categories: categoriesToUse,
+    series: series,
+    // 使用比 canvas 略窄的绘图宽度并设置 padding 来避免右边溢出
+    width: chartWidth,
+    padding: [10, rightGap + 8, 18, 10],
+    height: cssHeight,
+    pixelRatio: pixelRatio,
+    background: 'transparent',
+    animation: false, // 关闭动画避免干扰
+    enableScroll: false,
+    dataLabel: false, // 关键:关闭数据点标签
+    legend: {
+      show: false
+    },
+    xAxis: {
+      disableGrid: true, // 简化网格
+      axisLine: true,
+      axisLineColor: '#e0e0e0',
+      fontColor: '#666666',
+      fontSize: 10, // 进一步调小X轴字体
+      boundaryGap: 'justify'
+    },
+    yAxis: {
+      disableGrid: false,
+      gridColor: '#f5f5f5',
+      splitNumber: 4, // 减少分割数
+      min: minVal,
+      max: maxVal,
+      axisLine: true,
+      axisLineColor: '#e0e0e0',
+      fontColor: '#666666',
+      fontSize: 10, // 进一步调小Y轴字体
+      format: (val: number) => val % 1 === 0 ? `${val}mmol/L` : '' // 只显示整数值
+    },
+    extra: {
+      line: {
+        type: 'curve',
+        width: 1, // 进一步调细线宽
+        activeType: 'point', // 简化点样式
+        point: {
+          radius: 0.5, // 进一步调小数据点半径
+          strokeWidth: 0.5 // 调小边框宽度
+        }
+      },
+      tooltip: {
+        showBox: false, // 关闭提示框避免重叠
+        showCategory: false
+      }
     }
-    const scaleX = cssWidth / 340
-    const scaleY = cssHeight / 120
-    const { coords, min, max } = pointCoords.value
-    const pts = coords.map((p: any) => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
-    const validPts = pts.filter((p: any) => p !== null) as { x: number; y: number }[]
-    ctx.fillStyle = '#999'
-    ctx.font = '12px sans-serif'
-    ctx.textAlign = 'right'
-    ctx.textBaseline = 'middle'
-    const range = Math.max(0.1, max - min)
-    for (let j = 0; j <= 4; j++) {
-      const val = Math.round((max - (range * j) / 4) * 10) / 10
-      const y = 10 * scaleY + ((cssHeight - 10 * 2) * j) / 4
-      ctx.fillText(String(val), 10 * scaleX - 6, y)
+  }
+
+  try {
+    // 在创建新实例前确保销毁旧实例
+    if (chartInstance.value && chartInstance.value.destroy) {
+      try { chartInstance.value.destroy() } catch (e) { console.warn('destroy before init failed', e) }
+      chartInstance.value = null
+      chartInitialized = false
     }
-    if (validPts.length) {
-      ctx.beginPath()
-      ctx.moveTo(validPts[0].x, validPts[0].y)
-      validPts.forEach(p => ctx.lineTo(p.x, p.y))
-      ctx.strokeStyle = '#ff6a00'
-      ctx.lineWidth = 2
-      ctx.stroke()
+
+    chartInstance.value = new uCharts(config)
+    chartInitialized = true
+    console.log('uCharts initialized successfully')
+  } catch (error) {
+    console.error('uCharts init error:', error)
+    chartInitialized = false
+  }
+  chartBusy = false
+}
+
+// 更新数据而不重新初始化
+async function updateChartData() {
+  if (chartBusy) return
+  chartBusy = true
+
+  if (!chartInstance.value || !chartInitialized) {
+    try {
+      await drawChart()
+    } finally {
+      chartBusy = false
     }
-    validPts.forEach(p => { ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); ctx.fillStyle = '#ff6a00'; ctx.fill() })
     return
   }
 
-  try {
-    const size = await getCanvasSize()
-    const cssWidth = size.width || 340
-    const cssHeight = size.height || 120
-    const ctx = (typeof uni !== 'undefined' && uni.createCanvasContext) ? uni.createCanvasContext('bgChart', vm?.proxy) : null
-    if (!ctx) return
-    ctx.clearRect && ctx.clearRect(0, 0, cssWidth, cssHeight)
-    ctx.setStrokeStyle && ctx.setStrokeStyle('rgba(0,0,0,0.04)')
-    for (let i = 0; i <= 4; i++) {
-      const y = (cssHeight / 4) * i
-      ctx.beginPath && ctx.beginPath()
-      ctx.moveTo && ctx.moveTo(0, y)
-      ctx.lineTo && ctx.lineTo(cssWidth, y)
-      ctx.stroke && ctx.stroke()
+  const year = current.value.getFullYear()
+  const month = current.value.getMonth()
+  const days = daysInMonth(year, month)
+  
+  const categories: string[] = []
+  const showLabelDays = []
+  
+  if (days > 0) {
+    showLabelDays.push(1)
+    if (days > 1) showLabelDays.push(days)
+    if (days > 7) showLabelDays.push(Math.ceil(days / 3))
+    if (days > 14) showLabelDays.push(Math.ceil(days * 2 / 3))
+  }
+  
+  for (let d = 1; d <= days; d++) {
+    if (showLabelDays.includes(d)) {
+      categories.push(`${d}日`)
+    } else {
+      categories.push('')
     }
-    const scaleX = cssWidth / 340
-    const scaleY = cssHeight / 120
-    const { coords, min, max } = pointCoords.value
-    const pts = coords.map((p: any) => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
-    const validPts = pts.filter((p: any) => p !== null) as { x: number; y: number }[]
-    ctx.setFillStyle && ctx.setFillStyle('#999')
-    ctx.setFontSize && ctx.setFontSize(12)
-    const range = Math.max(0.1, max - min)
-    for (let j = 0; j <= 4; j++) {
-      const val = Math.round((max - (range * j) / 4) * 10) / 10
-      const y = 10 * scaleY + ((cssHeight - 10 * 2) * j) / 4
-      ctx.fillText && ctx.fillText(String(val), 10 * scaleX - 6, y)
+  }
+
+  // 只为有记录的天生成categories和data
+  const data: number[] = []
+  const filteredCategories: string[] = []
+  const dayMap = new Map<number, RecordItem>()
+  for (const r of records.value) {
+    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) {
+        dayMap.set(d, r)
+      }
     }
-    if (validPts.length) {
-      ctx.beginPath && ctx.beginPath()
-      ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
-      validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
-      ctx.setStrokeStyle && ctx.setStrokeStyle('#ff6a00')
-      ctx.setLineWidth && ctx.setLineWidth(2)
-      ctx.stroke && ctx.stroke()
+  }
+  const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b)
+  for (const d of sortedDays) {
+    const rec = dayMap.get(d)!
+    filteredCategories.push(`${d}日`)
+    data.push(rec.value)
+  }
+  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
+
+  const validData = data.filter(v => v > 0)
+  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 1 : 2
+  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 1 : 12
+
+  try {
+    // 使用uCharts的更新方法,仅更新有数据的分类和序列
+    // 若实例存在,先更新宽度/padding(如果有配置)以避免右侧溢出
+    try {
+      const size = await getCanvasSize()
+      const cssWidth = size.width
+      const rightGap = Math.max(24, Math.round(cssWidth * 0.04))
+      const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
+      if (chartInstance.value.opts) {
+        chartInstance.value.opts.width = chartWidth
+        chartInstance.value.opts.padding = [10, rightGap + 8, 18, 10]
+      }
+    } catch (e) {
+      // 忽略尺寸更新错误
     }
-    validPts.forEach(p => { ctx.beginPath && ctx.beginPath(); ctx.arc && ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); ctx.setFillStyle && ctx.setFillStyle('#ff6a00'); ctx.fill && ctx.fill() })
-    ctx.draw && ctx.draw(true)
-  } catch (err) {
-    // ignore
+
+    chartInstance.value.updateData({
+      categories: categoriesToUse,
+      series: [{
+        name: '血糖',
+        data: data,
+        color: '#ff6a00'
+      }]
+    })
+
+    // 更新Y轴范围
+    chartInstance.value.opts.yAxis.min = minVal
+    chartInstance.value.opts.yAxis.max = maxVal
+
+  } catch (error) {
+    console.error('Update chart error:', error)
+    // 如果更新失败,重新销毁并重建实例
+    try {
+      if (chartInstance.value && chartInstance.value.destroy) chartInstance.value.destroy()
+    } catch (e) { console.warn('destroy on update failure failed', e) }
+    chartInstance.value = null
+    chartInitialized = false
+    try {
+      await drawChart()
+    } catch (e) { console.error('re-init after update failure also failed', e) }
   }
+  chartBusy = false
 }
 
-onMounted(() => { nextTick(() => drawChart()) })
+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 drawChart()
+  }, 500)
+})
 
-watch([() => records.value, () => current.value], () => { nextTick(() => drawChart()) }, { deep: true })
+// 简化监听,避免频繁重绘
+watch([() => current.value], async () => {
+  setTimeout(async () => {
+    await updateChartData()
+  }, 100)
+})
 
-onBeforeUnmount(() => { /* nothing */ })
+watch([() => records.value], async () => {
+  setTimeout(async () => {
+    await updateChartData()
+  }, 100)
+}, { deep: true })
 
-function prevMonth() { const d = new Date(current.value); d.setMonth(d.getMonth() - 1); current.value = d; pickerValue.value = formatPickerDate(d); records.value = generateMockRecords(d) }
-function nextMonth() { const d = new Date(current.value); d.setMonth(d.getMonth() + 1); current.value = d; pickerValue.value = formatPickerDate(d); records.value = generateMockRecords(d) }
-function onPickerChange(e: any) { const val = e?.detail?.value || e; const parts = (val as string).split('-'); if (parts.length >= 2) { const y = parseInt(parts[0], 10); const m = parseInt(parts[1], 10) - 1; const d = new Date(y, m, 1); current.value = d; pickerValue.value = formatPickerDate(d); records.value = generateMockRecords(d) } }
+onBeforeUnmount(() => {
+  if (chartInstance.value && chartInstance.value.destroy) {
+    try {
+      chartInstance.value.destroy()
+    } catch (e) {
+      console.warn('uCharts destroy error:', e)
+    }
+  }
+  chartInstance.value = null
+  chartInitialized = false
+})
 
-// 添加逻辑
+// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
+async function rebuildChart() {
+  // 如果正在绘制,等一小会儿再销毁
+  if (chartBusy) {
+    // 等待最大 300ms,避免长时间阻塞
+    await new Promise(r => setTimeout(r, 50))
+  }
+  try {
+    if (chartInstance.value && chartInstance.value.destroy) {
+      try { chartInstance.value.destroy() } catch (e) { console.warn('destroy in rebuildChart failed', e) }
+    }
+  } catch (e) {
+    console.warn('rebuildChart destroy error', e)
+  }
+  chartInstance.value = null
+  chartInitialized = false
+  // 等待 DOM/Tick 稳定
+  await nextTick()
+  // 重新初始化
+  try {
+    await drawChart()
+  } catch (e) {
+    console.error('rebuildChart drawChart failed', e)
+  }
+}
+
+// 其他函数保持不变
+async function prevMonth() {
+  const d = new Date(current.value)
+  d.setMonth(d.getMonth() - 1)
+  current.value = d
+  pickerValue.value = formatPickerDate(d)
+  records.value = generateMockRecords(d)
+  await rebuildChart()
+}
+
+async function nextMonth() {
+  const d = new Date(current.value)
+  d.setMonth(d.getMonth() + 1)
+  current.value = d
+  pickerValue.value = formatPickerDate(d)
+  records.value = generateMockRecords(d)
+  await rebuildChart()
+}
+
+async function onPickerChange(e: any) {
+  const val = e?.detail?.value || e
+  const parts = (val as string).split('-')
+  if (parts.length >= 2) {
+    const y = parseInt(parts[0], 10)
+    const m = parseInt(parts[1], 10) - 1
+    const d = new Date(y, m, 1)
+    current.value = d
+    pickerValue.value = formatPickerDate(d)
+    records.value = generateMockRecords(d)
+    await rebuildChart()
+  }
+}
+
+// 添加逻辑保持不变
 const showAdd = ref(false)
 const addDate = ref(formatPickerDate(new Date()))
 const addDateLabel = ref(formatDisplayDate(new Date()))
@@ -312,59 +663,319 @@ function closeAdd() { showAdd.value = false; addGlucose.value = null }
 
 function onAddDateChange(e: any) { const val = e?.detail?.value || e; addDate.value = val; addDateLabel.value = val.replace(/^(.{10}).*$/, '$1') }
 
-function confirmAdd() {
-  if (!addGlucose.value) { uni.showToast && uni.showToast({ title: '请输入血糖值', icon: 'none' }); return }
+async function confirmAdd() {
+  if (!addGlucose.value) { 
+    uni.showToast && uni.showToast({ title: '请输入血糖值', icon: 'none' }); 
+    return 
+  }
   const id = `user-${Date.now()}`
-  const item: RecordItem = { id, date: addDateLabel.value, value: Number(Number(addGlucose.value).toFixed(1)), type: types[typeIndex.value] }
+  const item: RecordItem = { 
+    id, 
+    date: addDateLabel.value, 
+    value: Number(Number(addGlucose.value).toFixed(1)), 
+    type: types[typeIndex.value] 
+  }
   const parts = addDate.value.split('-')
   const addY = parseInt(parts[0], 10)
   const addM = parseInt(parts[1], 10) - 1
-  if (addY === current.value.getFullYear() && addM === current.value.getMonth()) { records.value = [item, ...records.value] }
+  if (addY === current.value.getFullYear() && addM === current.value.getMonth()) { 
+    records.value = [item, ...records.value] 
+  }
   uni.showToast && uni.showToast({ title: '已添加', icon: 'success' })
   closeAdd()
+  // 新增记录后彻底重建图表,确保像退出再进入一样刷新
+  try {
+    await rebuildChart()
+  } catch (e) {
+    console.warn('rebuildChart after add failed', e)
+  }
 }
 
-function confirmDeleteRecord(id: string) { if (typeof uni !== 'undefined' && uni.showModal) { uni.showModal({ title: '删除', content: '确认删除该条记录吗?', success(res: any) { if (res.confirm) records.value = records.value.filter(r => r.id !== id) } }) } else { records.value = records.value.filter(r => r.id !== id) } }
+async function confirmDeleteRecord(id: string) { 
+  if (typeof uni !== 'undefined' && uni.showModal) { 
+    uni.showModal({ 
+      title: '删除', 
+      content: '确认删除该条记录吗?', 
+      success: async (res: any) => { 
+        if (res.confirm) {
+          records.value = records.value.filter(r => r.id !== id)
+          try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
+        }
+      } 
+    }) 
+  } else { 
+    records.value = records.value.filter(r => r.id !== id)
+    try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
+  } 
+}
 </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 }
-.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 { padding: 12rpx 24rpx }
-.chart-header { font-size: 28rpx; color: #666; margin-bottom: 10rpx }
-.chart-canvas { width: 100%; height: 160rpx; 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 }
-.btn-delete { width: 80rpx; height: 60rpx; min-width: 60rpx; min-height: 60rpx; display: inline-flex; align-items: center; justify-content: center; background: #fff0f0; color: #d9534f; border: 1rpx solid rgba(217,83,79,0.15); border-radius: 8rpx; margin-left: 30rpx }
-.fab { position: fixed; right: 28rpx; bottom: 160rpx; width: 110rpx; height: 110rpx; border-radius: 999px; background: linear-gradient(180deg, #ff7a00, #ff4a00); display: flex; align-items: center; justify-content: center; box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2); z-index: 1200 }
-.fab-inner { color: #fff; font-size: 56rpx; line-height: 56rpx }
-.modal { position: fixed; left: 0; right: 0; top: 0; bottom: 0; display: flex; align-items: flex-end; justify-content: center; z-index: 1300 }
-.modal-backdrop { position: absolute; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0, 0, 0, 0.4) }
-.modal-panel { position: relative; width: 100%; background: #fff; border-top-left-radius: 18rpx; border-top-right-radius: 18rpx; padding: 28rpx 24rpx 140rpx 24rpx; box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12) }
-.modal-title { font-size: 56rpx; margin-block:  60rpx; color: #222; font-weight: 700; letter-spacing: 1rpx }
-.modal-inner { max-width: 70%; margin: 0 auto }
-.form-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 34rpx; padding: 14rpx 0; font-size: 32rpx }
-.input { width: 240rpx; text-align: right; padding: 16rpx; border-radius: 14rpx; border: 1rpx solid #eee; background: #fff7f0 }
-.picker-display { color: #333 }
-.modal-actions { display: flex; justify-content: center; gap: 12rpx; margin-top: 18rpx }
-.modal-actions.full { padding: 6rpx 0 24rpx 0 }
-.btn-primary { background: #ff6a00; color: #fff; padding: 18rpx 22rpx; border-radius: 16rpx; text-align: center; width: 50%; box-shadow: 0 10rpx 28rpx rgba(255,106,0,0.18) }
-.drag-handle { width: 64rpx; height: 6rpx; background: rgba(0,0,0,0.08); border-radius: 999px; margin: 10rpx auto 14rpx auto }
-.modal-header { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin-bottom: 6rpx }
-.label { color: #666 }
-.ruler-wrap { margin: 12rpx 0 }
-.type-buttons { display: flex; gap: 12rpx }
-.type-btn { padding: 8rpx 18rpx; border-radius: 12rpx; border: 1rpx solid #eee; background: #fff; color: #333 }
-.type-btn.active { background: #ff6a00; color: #fff; border-color: #ff6a00 }
-.unit { color: #666; font-size: 28rpx }
-.fixed-footer { position: absolute; left: 0; right: 0; bottom: 40rpx; padding: 0 24rpx }
-.btn-full { width: 100%; padding: 18rpx; border-radius: 12rpx; }
+.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 
+}
+
+.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 { 
+  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 {
+  width: 750rpx;
+  height: 240rpx;
+  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 
+}
+
+.btn-delete { 
+  width: 80rpx; 
+  height: 60rpx; 
+  min-width: 60rpx; 
+  min-height: 60rpx; 
+  display: inline-flex; 
+  align-items: center; 
+  justify-content: center; 
+  background: #fff0f0; 
+  color: #d9534f; 
+  border: 1rpx solid rgba(217,83,79,0.15); 
+  border-radius: 8rpx; 
+  margin-left: 30rpx 
+}
+
+.fab { 
+  position: fixed; 
+  right: 28rpx; 
+  bottom: 160rpx; 
+  width: 110rpx; 
+  height: 110rpx; 
+  border-radius: 999px; 
+  background: linear-gradient(180deg, #ff7a00, #ff4a00); 
+  display: flex; 
+  align-items: center; 
+  justify-content: center; 
+  box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2); 
+  z-index: 1200 
+}
+
+.fab-inner { 
+  color: #fff; 
+  font-size: 56rpx; 
+  line-height: 56rpx 
+}
+
+.modal { 
+  position: fixed; 
+  left: 0; 
+  right: 0; 
+  top: 0; 
+  bottom: 0; 
+  display: flex; 
+  align-items: flex-end; 
+  justify-content: center; 
+  z-index: 1300 
+}
+
+.modal-backdrop { 
+  position: absolute; 
+  left: 0; 
+  right: 0; 
+  top: 0; 
+  bottom: 0; 
+  background: rgba(0, 0, 0, 0.4) 
+}
+
+.modal-panel { 
+  position: relative; 
+  width: 100%; 
+  background: #fff; 
+  border-top-left-radius: 18rpx; 
+  border-top-right-radius: 18rpx; 
+  padding: 28rpx 24rpx 140rpx 24rpx; 
+  box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12) 
+}
+
+.modal-title { 
+  font-size: 56rpx; 
+  margin-block: 60rpx; 
+  color: #222; 
+  font-weight: 700; 
+  letter-spacing: 1rpx 
+}
+
+.modal-inner { 
+  max-width: 70%; 
+  margin: 0 auto 
+}
+
+.form-row { 
+  display: flex; 
+  align-items: center; 
+  justify-content: space-between; 
+  margin-bottom: 34rpx; 
+  padding: 14rpx 0; 
+  font-size: 32rpx 
+}
+
+.input { 
+  width: 240rpx; 
+  text-align: right; 
+  padding: 16rpx; 
+  border-radius: 14rpx; 
+  border: 1rpx solid #eee; 
+  background: #fff7f0 
+}
+
+.picker-display { 
+  color: #333 
+}
+
+.btn-primary { 
+  background: #ff6a00; 
+  color: #fff; 
+  padding: 18rpx 22rpx; 
+  border-radius: 16rpx; 
+  text-align: center; 
+  width: 50%; 
+  box-shadow: 0 10rpx 28rpx rgba(255,106,0,0.18) 
+}
+
+.drag-handle { 
+  width: 64rpx; 
+  height: 6rpx; 
+  background: rgba(0,0,0,0.08); 
+  border-radius: 999px; 
+  margin: 10rpx auto 14rpx auto 
+}
+
+.modal-header { 
+  display: flex; 
+  align-items: center; 
+  justify-content: center; 
+  gap: 12rpx; 
+  margin-bottom: 6rpx 
+}
+
+.label { 
+  color: #666 
+}
+
+.ruler-wrap { 
+  margin: 12rpx 0 
+}
+
+.type-buttons { 
+  display: flex; 
+  gap: 12rpx 
+}
+
+.type-btn { 
+  padding: 8rpx 18rpx; 
+  border-radius: 12rpx; 
+  border: 1rpx solid #eee; 
+  background: #fff; 
+  color: #333 
+}
+
+.type-btn.active { 
+  background: #ff6a00; 
+  color: #fff; 
+  border-color: #ff6a00 
+}
+
+.unit { 
+  color: #666; 
+  font-size: 28rpx 
+}
+
+.fixed-footer { 
+  position: absolute; 
+  left: 0; 
+  right: 0; 
+  bottom: 40rpx; 
+  padding: 0 24rpx 
+}
+
+.btn-full { 
+  width: 100%; 
+  padding: 18rpx; 
+  border-radius: 12rpx; 
+}
 </style>