Răsfoiți Sursa

feat(charts): 提取并重构uCharts逻辑到可复用composable

- 创建 useUChart composable 统一封装 uCharts 初始化、更新和销毁逻辑
- 支持单序列和多序列数据渲染
- 自动处理 Canvas 上下文获取及跨平台兼容性(小程序/H5)
- 实现图表尺寸动态适配与 Y 轴范围自动计算
- 优化月视图和周视图下的数据聚合与分类展示
- 在 blood-glucose 和 blood-pressure 页面中应用新的 composable 替代原有内联实现
- 简化组件代码结构,提高可维护性和复用性
mcbaiyun 2 luni în urmă
părinte
comite
2f7782fde4

+ 339 - 0
src/composables/useUChart.ts

@@ -0,0 +1,339 @@
+import { ref } from 'vue'
+import uCharts from '@qiun/ucharts'
+import { getWeekStart, getWeekEnd, formatDisplayDate, daysInMonth } from '@/utils/date'
+
+type Params = {
+  canvasId: string,
+  vm?: any,
+  getCanvasSize: () => Promise<{ width: number; height: number }>,
+  // 支持单序列或多序列
+  seriesNames?: string[] | string,
+  valueAccessors?: Array<(rec: any) => number> | ((rec: any) => number),
+  colors?: string[] | string
+}
+
+export function createUChart(params: Params) {
+  const chartInstance = ref<any>(null)
+  let chartInitialized = false
+  let chartBusy = false
+
+  const valueAccessors = Array.isArray(params.valueAccessors)
+    ? params.valueAccessors as Array<(rec:any)=>number>
+    : (params.valueAccessors ? [params.valueAccessors as (rec:any)=>number] : [ (r: any) => Number(r.value ?? 0) ])
+  const seriesNames = Array.isArray(params.seriesNames) ? params.seriesNames as string[] : (params.seriesNames ? [params.seriesNames as string] : ['数据'])
+  const colors = Array.isArray(params.colors) ? params.colors as string[] : (params.colors ? [params.colors as string] : ['#ff6a00'])
+
+  async function draw(recordsRef: any, currentRef: any, viewModeRef: any) {
+    if (chartBusy) return
+    chartBusy = true
+
+    // If already initialized, delegate to update
+    if (chartInitialized && chartInstance.value) {
+      try { await update(recordsRef, currentRef, viewModeRef) } finally { chartBusy = false }
+      return
+    }
+
+    // destroy existing
+    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')
+      chartBusy = false
+      return
+    }
+
+    const size = await params.getCanvasSize()
+    const cssWidth = size.width
+    const cssHeight = size.height
+    const pixelRatio = 1
+    const rightGap = Math.max(24, Math.round(cssWidth * 0.04))
+    const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
+
+    // prepare categories & data
+    const year = currentRef.value.getFullYear()
+    const month = currentRef.value.getMonth()
+    let categories: string[] = []
+    let showLabelDays: number[] = []
+
+    if (viewModeRef.value === 'month') {
+      const days = daysInMonth(year, month)
+      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 <= (daysInMonth(year, month)); d++) {
+        categories.push(showLabelDays.includes(d) ? `${d}日` : '')
+      }
+    } else {
+      const weekStart = getWeekStart(currentRef.value)
+      const weekDays = ['一','二','三','四','五','六','日']
+      for (let i = 0; i < 7; i++) {
+        const date = new Date(weekStart)
+        date.setDate(weekStart.getDate() + i)
+        categories.push(`${date.getDate()}日(${weekDays[i]})`)
+      }
+    }
+
+    // aggregate -> support multiple series
+    const seriesCount = valueAccessors.length
+    const seriesData: number[][] = Array.from({ length: seriesCount }, () => [])
+    const filteredCategories: string[] = []
+    const dayMap = new Map<number, any>()
+
+    if (viewModeRef.value === 'month') {
+      for (const r of recordsRef.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)
+        }
+      }
+      const sortedDays = Array.from(dayMap.keys()).sort((a,b)=>a-b)
+      for (const d of sortedDays) {
+        const rec = dayMap.get(d)
+        filteredCategories.push(`${d}日`)
+        for (let si = 0; si < seriesCount; si++) seriesData[si].push(valueAccessors[si](rec))
+      }
+    } else {
+      const weekStart = getWeekStart(currentRef.value)
+      for (const r of recordsRef.value) {
+        const parts = r.date.split('-')
+        if (parts.length >= 3) {
+          const recordDate = new Date(parseInt(parts[0],10), parseInt(parts[1],10)-1, parseInt(parts[2],10))
+          recordDate.setHours(0,0,0,0)
+          const recordWeekStart = getWeekStart(recordDate)
+          if (recordWeekStart.getTime() === weekStart.getTime()) {
+            const dayIndex = Math.round((recordDate.getTime() - weekStart.getTime()) / 86400000) + 1
+            dayMap.set(dayIndex, { rec: r, date: recordDate })
+          }
+        }
+      }
+      const weekDays = ['一','二','三','四','五','六','日']
+      for (let i = 1; i <= 7; i++) {
+        const entry = dayMap.get(i)
+        if (entry) {
+          const rec = entry.rec
+          const date = entry.date
+          filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
+          for (let si = 0; si < seriesCount; si++) seriesData[si].push(valueAccessors[si](rec))
+        }
+      }
+    }
+
+    const categoriesToUse = filteredCategories.length ? filteredCategories : categories
+
+  const validData = seriesData.flat().filter((v: number) => 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 = seriesData.map((dArr, idx) => ({ name: seriesNames[idx] || seriesNames[0] || `s${idx}`, data: dArr, color: colors[idx] || colors[0] }))
+
+    // obtain canvas context
+    let ctx: any = null
+    try {
+      if (typeof uni !== 'undefined' && typeof uni.createCanvasContext === 'function') {
+        try { ctx = params.vm?.proxy ? uni.createCanvasContext(params.canvasId, params.vm.proxy) : uni.createCanvasContext(params.canvasId) } catch (e) { try { ctx = uni.createCanvasContext(params.canvasId) } catch (err) { ctx = null } }
+      }
+    } catch (e) { ctx = null }
+
+    if (!ctx && typeof document !== 'undefined') {
+      try {
+        let el: HTMLCanvasElement | null = null
+        for (let attempt=0; attempt<3; attempt++) {
+          el = document.getElementById(params.canvasId) as HTMLCanvasElement | null
+          if (el) break
+          await new Promise(r=>setTimeout(r,50))
+        }
+        if (el && el.getContext) {
+          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
+              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) { ctx = null }
+    }
+
+    if (!ctx) {
+      console.warn('Unable to obtain canvas context for uCharts.')
+      chartBusy = false
+      return
+    }
+
+    const config = {
+      $this: params.vm?.proxy,
+      canvasId: params.canvasId,
+      context: ctx,
+      type: 'line',
+      fontSize: 10,
+      categories: categoriesToUse,
+      series: series,
+      width: chartWidth,
+      padding: [10, rightGap + 8, 18, 10],
+      height: cssHeight,
+      pixelRatio,
+      background: 'transparent',
+      animation: false,
+      enableScroll: false,
+      dataLabel: false,
+      legend: { show: false },
+      xAxis: { disableGrid: true, axisLine: true, axisLineColor: '#e0e0e0', fontColor: '#666666', fontSize: 10, boundaryGap: 'justify' },
+      yAxis: { disableGrid: false, gridColor: '#f5f5f5', splitNumber: 4, min: minVal, max: maxVal, axisLine: true, axisLineColor: '#e0e0e0', fontColor: '#666666', fontSize: 10 },
+      extra: { line: { type: 'curve', width: 1, activeType: 'point', point: { radius: 0.5, strokeWidth: 0.5 } }, tooltip: { showBox: false, showCategory: false } }
+    }
+
+    try {
+      chartInstance.value = new uCharts(config)
+      chartInitialized = true
+    } catch (e) {
+      console.error('uCharts init error', e)
+      chartInitialized = false
+    }
+
+    chartBusy = false
+  }
+
+  async function update(recordsRef: any, currentRef: any, viewModeRef: any) {
+    if (chartBusy) return
+    chartBusy = true
+    if (!chartInstance.value || !chartInitialized) {
+      try { await draw(recordsRef, currentRef, viewModeRef) } finally { chartBusy = false }
+      return
+    }
+
+    const year = currentRef.value.getFullYear()
+    const month = currentRef.value.getMonth()
+    let categories: string[] = []
+    let showLabelDays: number[] = []
+
+    if (viewModeRef.value === 'month') {
+      const days = daysInMonth(year, month)
+      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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
+    } else {
+      const weekStart = getWeekStart(currentRef.value)
+      const weekDays = ['一','二','三','四','五','六','日']
+      for (let i = 0; i < 7; i++) {
+        const date = new Date(weekStart)
+        date.setDate(weekStart.getDate() + i)
+        categories.push(`${date.getDate()}日(${weekDays[i]})`)
+      }
+    }
+
+    // build seriesData for update (support multiple series)
+    const seriesCount = valueAccessors.length
+    const seriesData: number[][] = Array.from({ length: seriesCount }, () => [])
+    const filteredCategories: string[] = []
+    const dayMap = new Map<number, any>()
+
+    if (viewModeRef.value === 'month') {
+      for (const r of recordsRef.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)
+        }
+      }
+      const sortedDays = Array.from(dayMap.keys()).sort((a,b)=>a-b)
+      for (const d of sortedDays) {
+        const rec = dayMap.get(d)
+        filteredCategories.push(`${d}日`)
+        for (let si = 0; si < seriesCount; si++) seriesData[si].push(valueAccessors[si](rec))
+      }
+    } else {
+      const weekStart = getWeekStart(currentRef.value)
+      for (const r of recordsRef.value) {
+        const parts = r.date.split('-')
+        if (parts.length >= 3) {
+          const recordDate = new Date(parseInt(parts[0],10), parseInt(parts[1],10)-1, parseInt(parts[2],10))
+          recordDate.setHours(0,0,0,0)
+          const recordWeekStart = getWeekStart(recordDate)
+          if (recordWeekStart.getTime() === weekStart.getTime()) {
+            const dayIndex = Math.round((recordDate.getTime() - weekStart.getTime()) / 86400000) + 1
+            dayMap.set(dayIndex, { rec: r, date: recordDate })
+          }
+        }
+      }
+      const weekDays = ['一','二','三','四','五','六','日']
+      for (let i = 1; i <= 7; i++) {
+        const entry = dayMap.get(i)
+        if (entry) {
+          const rec = entry.rec
+          const date = entry.date
+          filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
+          for (let si = 0; si < seriesCount; si++) seriesData[si].push(valueAccessors[si](rec))
+        }
+      }
+    }
+
+    const categoriesToUse = filteredCategories.length ? filteredCategories : categories
+    const validData = seriesData.flat().filter((v: number) => 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 = seriesData.map((dArr, idx) => ({ name: seriesNames[idx] || seriesNames[0] || `s${idx}`, data: dArr, color: colors[idx] || colors[0] }))
+
+    try {
+      // try to adjust opts
+      const size = await params.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) { /* ignore */ }
+
+    try {
+      chartInstance.value.updateData({ categories: categoriesToUse, series })
+      chartInstance.value.opts.yAxis.min = minVal
+      chartInstance.value.opts.yAxis.max = maxVal
+    } catch (e) {
+      console.error('Update chart error', e)
+      try { if (chartInstance.value && chartInstance.value.destroy) chartInstance.value.destroy() } catch (err) { console.warn(err) }
+      chartInstance.value = null
+      chartInitialized = false
+      try { await draw(recordsRef, currentRef, viewModeRef) } catch (err) { console.error('re-init after update failed', err) }
+    }
+
+    chartBusy = false
+  }
+
+  async function rebuild(recordsRef:any, currentRef:any, viewModeRef:any) {
+    if (chartBusy) {
+      await new Promise(r=>setTimeout(r,50))
+    }
+    try { if (chartInstance.value && chartInstance.value.destroy) chartInstance.value.destroy() } catch (e) { console.warn('destroy in rebuild failed', e) }
+    chartInstance.value = null
+    chartInitialized = false
+    await draw(recordsRef, currentRef, viewModeRef)
+  }
+
+  function destroy() {
+    try { if (chartInstance.value && chartInstance.value.destroy) chartInstance.value.destroy() } catch (e) { console.warn('destroy error', e) }
+    chartInstance.value = null
+    chartInitialized = false
+  }
+
+  return { chartInstance, draw, update, rebuild, destroy }
+}

+ 11 - 443
src/pages/health/details/blood-glucose.vue

@@ -93,10 +93,10 @@
 
 <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'
+import { createUChart } from '@/composables/useUChart'
 import { getWeekStart, getWeekEnd, getWeekNumber, formatDisplayDate, formatPickerDate, daysInMonth, weekDayIndex } from '@/utils/date'
 
 type RecordItem = { id: string; date: string; value: number; type: string }
@@ -213,426 +213,18 @@ const averageGlucose = computed(() => {
 
 // 使用 daysInMonth 从 src/utils/date.ts
 
-// Canvas / uCharts 绘图 - 修复版本
-const chartInstance = ref<any>(null)
+// Canvas / uCharts 绘图 - 使用 composable
 const vm = getCurrentInstance()
-let chartInitialized = false
-let chartBusy = false // 绘图锁,防止并发初始化/更新
+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() {
-  // 防止并发调用
-  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()
-  let categories: string[] = []
-  let showLabelDays: number[] = []
-  
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    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++) {
-      categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-    }
-  } else {
-    // 周视图
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  // 只为有记录的天生成categories和data,避免将无数据天设为0
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  // dayMap 在月视图中存放 RecordItem,在周视图中存放 { rec: RecordItem, date: Date }
-  const dayMap = new Map<number, any>()
-  if (viewMode.value === 'month') {
-    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)
-        }
-      }
-    }
-    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)
-    }
-  } else {
-    // 周视图:按周聚合
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        recordDate.setHours(0, 0, 0, 0)
-        const recordWeekStart = getWeekStart(recordDate)
-        if (recordWeekStart.getTime() === weekStart.getTime()) {
-          // 基于时间差计算周内索引(支持跨月)
-          const dayIndex = Math.round((recordDate.getTime() - weekStart.getTime()) / 86400000) + 1
-          dayMap.set(dayIndex, { rec: r, date: recordDate })
-        }
-      }
-    }
-    // 按星期一到星期日输出
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 1; i <= 7; i++) {
-      const entry = dayMap.get(i)
-      if (entry) {
-        const rec = entry.rec as RecordItem
-        const date = entry.date as Date
-        filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
-        data.push(rec.value)
-      }
-    }
-  }
-
-  // 如果没有任何数据,则回退为整月空数据(避免 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 {
-      // 重试逻辑:初次可能未渲染到 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) {
-      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
-  })
-
-  // 简化的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
-      }
-    }
-  }
-
-  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
-    }
-
-    chartInstance.value = new uCharts(config)
-    chartInitialized = true
-    console.log('uCharts initialized successfully')
-  } catch (error) {
-    console.error('uCharts init error:', error)
-    chartInitialized = false
-  }
-  chartBusy = false
+  return chart.draw(records, current, viewMode)
 }
 
-// 更新数据而不重新初始化
+// 更新数据由 composable 处理
 async function updateChartData() {
-  if (chartBusy) return
-  chartBusy = true
-
-  if (!chartInstance.value || !chartInitialized) {
-    try {
-      await drawChart()
-    } finally {
-      chartBusy = false
-    }
-    return
-  }
-
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-  let categories: string[] = []
-  let showLabelDays: number[] = []
-
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    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++) {
-      categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-    }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  // 只为有记录的天生成categories和data,周视图支持跨月计算
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  // dayMap 在月视图中存放 RecordItem,在周视图中存放 { rec: RecordItem, date: Date }
-  const dayMap = new Map<number, any>()
-
-  if (viewMode.value === 'month') {
-    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)
-        }
-      }
-    }
-    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)
-    }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        recordDate.setHours(0, 0, 0, 0)
-        const recordWeekStart = getWeekStart(recordDate)
-        if (recordWeekStart.getTime() === weekStart.getTime()) {
-          // 基于时间差计算周内索引,支持跨月
-          const dayIndex = Math.round((recordDate.getTime() - weekStart.getTime()) / 86400000) + 1
-          dayMap.set(dayIndex, { rec: r, date: recordDate })
-        }
-      }
-    }
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 1; i <= 7; i++) {
-      const entry = dayMap.get(i)
-      if (entry) {
-        const rec = entry.rec as RecordItem
-        const date = entry.date as Date
-        filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
-        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) {
-      // 忽略尺寸更新错误
-    }
-
-    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
+  return chart.update(records, current, viewMode)
 }
 
 onMounted(() => {
@@ -665,40 +257,16 @@ watch([() => records.value], async () => {
 }, { deep: true })
 
 onBeforeUnmount(() => {
-  if (chartInstance.value && chartInstance.value.destroy) {
-    try {
-      chartInstance.value.destroy()
-    } catch (e) {
-      console.warn('uCharts destroy error:', e)
-    }
-  }
-  chartInstance.value = null
-  chartInitialized = false
+  try { chart.destroy() } catch (e) { console.warn('uCharts destroy error:', e) }
 })
 
 // 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
 async function rebuildChart() {
-  // 如果正在绘制,等一小会儿再销毁
-  if (chartBusy) {
-    // 等待最大 300ms,避免长时间阻塞
-    await new Promise(r => setTimeout(r, 50))
-  }
+  // delegate to composable rebuild
   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()
+    await chart.rebuild(records, current, viewMode)
   } catch (e) {
-    console.error('rebuildChart drawChart failed', e)
+    console.error('rebuildChart failed', e)
   }
 }
 

+ 18 - 292
src/pages/health/details/blood-pressure.vue

@@ -91,7 +91,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
-import uCharts from '@qiun/ucharts'
+import { createUChart } from '@/composables/useUChart'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import ScaleRuler from '@/components/scale-ruler.vue'
@@ -206,266 +206,21 @@ const averageDiastolic = computed(() => {
 
 // 使用共享日期工具 (src/utils/date.ts)
 
-// Canvas / uCharts 绘图 - 修复版本
-const chartInstance = ref<any>(null)
+// 使用可复用的 chart composable,支持多序列
 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 }
-
-  const size = await getCanvasSize()
-  const cssWidth = size.width
-  const cssHeight = size.height
-  const pixelRatio = 1
-  const rightGap = Math.max(24, Math.round(cssWidth * 0.04))
-  const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
-
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-
-  const categories: string[] = []
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    const showLabelDays: number[] = []
-    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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一','二','三','四','五','六','日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  const sData: number[] = []
-  const dData: number[] = []
-  const filteredCategories: string[] = []
-  const dayMap = new Map<number, RecordItem>()
-
-  if (viewMode.value === 'month') {
-    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)
-      }
-    }
-    const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b)
-    for (const day of sortedDays) { const rec = dayMap.get(day)!; filteredCategories.push(`${day}日`); sData.push(rec.s); dData.push(rec.d) }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        const weekStartDate = getWeekStart(recordDate)
-        if (weekStartDate.getTime() === weekStart.getTime()) {
-          const dayOfWeek = recordDate.getDay() || 7
-          dayMap.set(dayOfWeek, r)
-        }
-      }
-    }
-    const weekDays = ['一','二','三','四','五','六','日']
-    for (let i = 1; i <= 7; i++) {
-      const rec = dayMap.get(i)
-      if (rec) { const date = new Date(rec.date); filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`); sData.push(rec.s); dData.push(rec.d) }
-    }
-  }
-
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  const allData = [...sData, ...dData]
-  const validData = allData.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 5 : 60
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 5 : 180
-
-  const series = [ { name: '收缩压', data: sData, color: '#ff6a00' }, { name: '舒张压', data: dData, color: '#007aff' } ]
-
-  let ctx: any = null
-  try {
-    if (typeof uni !== 'undefined' && typeof uni.createCanvasContext === 'function') {
-      try { ctx = vm?.proxy ? uni.createCanvasContext('bpChart', vm.proxy) : uni.createCanvasContext('bpChart') } catch (e) { try { ctx = uni.createCanvasContext('bpChart') } catch (err) { ctx = null } }
-    }
-  } catch (e) { ctx = null }
-
-  if (!ctx && typeof document !== 'undefined') {
-    try {
-      let el: HTMLCanvasElement | null = null
-      for (let attempt = 0; attempt < 3; attempt++) {
-        el = document.getElementById('bpChart') as HTMLCanvasElement | null
-        if (el) break
-        await new Promise(r => setTimeout(r, 50))
-      }
-      if (el && el.getContext) {
-        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; 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) { ctx = null }
-  }
-
-  if (!ctx) { console.warn('Unable to obtain canvas context for uCharts.'); return }
-
-  const config = {
-    $this: vm?.proxy,
-    canvasId: 'bpChart',
-    context: ctx,
-    type: 'line',
-    fontSize: 10,
-    categories: categoriesToUse,
-    series: series,
-    width: chartWidth,
-    padding: [10, rightGap + 8, 18, 10],
-    height: cssHeight,
-    pixelRatio,
-    background: 'transparent',
-    animation: false,
-    enableScroll: false,
-    dataLabel: false,
-    legend: { show: true, position: 'top', float: 'right', fontSize: 10, spacing: 0 },
-    xAxis: { disableGrid: true, axisLine: true, axisLineColor: '#e0e0e0', fontColor: '#666666', fontSize: 10, boundaryGap: 'justify' },
-    yAxis: { disableGrid: false, gridColor: '#f5f5f5', splitNumber: 4, min: minVal, max: maxVal, axisLine: true, axisLineColor: '#e0e0e0', fontColor: '#666666', fontSize: 10, format: (val: number) => val % 1 === 0 ? `${val}mmHg` : '' },
-    extra: { line: { type: 'curve', width: 1, activeType: 'point', point: { radius: 0.5, strokeWidth: 0.5 } }, tooltip: { showBox: false, showCategory: false } }
-  }
-
-  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 }
-    chartInstance.value = new uCharts(config)
-    chartInitialized = true
-  } 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
-    }
-    return
-  }
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-
-  const categories: string[] = []
-  const dataS: number[] = []
-  const dataD: number[] = []
-  const filteredCategories: string[] = []
-  const dayMap = new Map<number, RecordItem>()
-
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    const showLabelDays: number[] = []
-    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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-
-    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)
-      }
-    }
-    const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b)
-    for (const d of sortedDays) { const rec = dayMap.get(d)!; filteredCategories.push(`${d}日`); dataS.push(rec.s); dataD.push(rec.d) }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一','二','三','四','五','六','日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        const weekStartDate = getWeekStart(recordDate)
-        if (weekStartDate.getTime() === weekStart.getTime()) {
-          const dayOfWeek = recordDate.getDay() || 7
-          dayMap.set(dayOfWeek, r)
-        }
-      }
-    }
-    for (let i = 1; i <= 7; i++) {
-      const rec = dayMap.get(i)
-      if (rec) { const date = new Date(rec.date); filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`); dataS.push(rec.s); dataD.push(rec.d) }
-    }
-  }
-
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-  const allData = [...dataS, ...dataD]
-  const validData = allData.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 5 : 60
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 5 : 180
-
-  try {
-    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) {}
-
-    chartInstance.value.updateData({ categories: categoriesToUse, series: [{ name: '收缩压', data: dataS, color: '#ff6a00' }, { name: '舒张压', data: dataD, color: '#007aff' }] })
-    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
-}
+const bpChart = createUChart({
+  canvasId: 'bpChart',
+  vm,
+  getCanvasSize,
+  seriesNames: ['收缩压', '舒张压'],
+  valueAccessors: [ (r: RecordItem) => r.s, (r: RecordItem) => r.d ],
+  colors: ['#ff6a00', '#007aff']
+})
 
 onMounted(() => {
-  // 延迟确保DOM渲染完成
+  // 延迟确保DOM渲染完成并设置canvas尺寸
   setTimeout(async () => {
     await nextTick()
-    // 由 getCanvasSize 计算并覆盖 canvasWidth/canvasHeight(确保模板样式和绘图一致)
     try {
       const size = await getCanvasSize()
       canvasWidth.value = size.width
@@ -473,59 +228,30 @@ onMounted(() => {
     } catch (e) {
       console.warn('getCanvasSize failed on mounted', e)
     }
-    await drawChart()
+    await bpChart.draw(records, current, viewMode)
   }, 500)
 })
 
-// 简化监听,避免频繁重绘
+// 监听并更新图表(轻微去抖)
 watch([() => current.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await bpChart.update(records, current, viewMode)
   }, 100)
 })
 
 watch([() => records.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await bpChart.update(records, current, viewMode)
   }, 100)
 }, { deep: true })
 
 onBeforeUnmount(() => {
-  if (chartInstance.value && chartInstance.value.destroy) {
-    try {
-      chartInstance.value.destroy()
-    } catch (e) {
-      console.warn('uCharts destroy error:', e)
-    }
-  }
-  chartInstance.value = null
-  chartInitialized = false
+  try { bpChart.destroy() } catch (e) { console.warn('bpChart destroy error', e) }
 })
 
-// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入
+// 强制重建图表(用于切换月份时彻底刷新)
 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)
-  }
+  try { await bpChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart failed', e) }
 }
 
 // 周/月周期导航与 Picker 处理

+ 18 - 451
src/pages/health/details/heart-rate.vue

@@ -82,7 +82,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
-import uCharts from '@qiun/ucharts'
+import { createUChart } from '@/composables/useUChart'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import ScaleRuler from '@/components/scale-ruler.vue'
@@ -193,425 +193,21 @@ const averageHR = computed(() => {
 
 // 使用 daysInMonth 从 src/utils/date.ts
 
-// Canvas / uCharts 绘图 - 修复版本
-const chartInstance = ref<any>(null)
+// 使用可复用的 chart composable(单序列)
 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()
-  let categories: string[] = []
-  let showLabelDays: number[] = []
-
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  // 只为有记录的天生成categories和data,避免将无数据天设为0
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  // dayMap 在月视图中存放 RecordItem,在周视图中存放 { rec: RecordItem, date: Date }
-  const dayMap = new Map<number, any>()
-
-  if (viewMode.value === 'month') {
-    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)
-      }
-    }
-    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.hr)
-    }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        // 使用年/月/日构造本地日期,避免字符串解析带来时区差异
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        recordDate.setHours(0, 0, 0, 0)
-        const recordWeekStart = getWeekStart(recordDate)
-        if (recordWeekStart.getTime() === weekStart.getTime()) {
-          // 通过时间差计算当天在周内的索引,支持跨月情况
-          const dayIndex = Math.round((recordDate.getTime() - weekStart.getTime()) / 86400000) + 1
-          // 存入记录和对应的 Date 对象,后续使用更可靠的 Date
-          dayMap.set(dayIndex, { rec: r, date: recordDate })
-        }
-      }
-    }
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 1; i <= 7; i++) {
-      const entry = dayMap.get(i)
-      if (entry) {
-        const rec = entry.rec as RecordItem
-        const date = entry.date as Date
-        filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
-        data.push(rec.hr)
-      }
-    }
-  }
-
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  const validData = data.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 2 : 40
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 2 : 130
-
-  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('hrChart', vm.proxy) : uni.createCanvasContext('hrChart')
-      } catch (e) {
-        // 再尝试不传vm
-        try { ctx = uni.createCanvasContext('hrChart') } catch (err) { ctx = null }
-      }
-    }
-  } catch (e) {
-    ctx = null
-  }
-
-  // H5环境尝试使用DOM获取2D上下文
-  if (!ctx && typeof document !== 'undefined') {
-    try {
-      // 重试逻辑:初次可能未渲染到 DOM
-      let el: HTMLCanvasElement | null = null
-      for (let attempt = 0; attempt < 3; attempt++) {
-        el = document.getElementById('hrChart') 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) {
-      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
-  })
-
-  // 简化的uCharts配置 - 关闭所有可能产生重叠的选项
-  const config = {
-    $this: vm?.proxy,
-    canvasId: 'hrChart',
-    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}bpm` : '' // 只显示整数值
-    },
-    extra: {
-      line: {
-        type: 'curve',
-        width: 1, // 进一步调细线宽
-        activeType: 'point', // 简化点样式
-        point: {
-          radius: 0.5, // 进一步调小数据点半径
-          strokeWidth: 0.5 // 调小边框宽度
-        }
-      },
-      tooltip: {
-        showBox: false, // 关闭提示框避免重叠
-        showCategory: false
-      }
-    }
-  }
-
-  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
-    }
-
-    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
-    }
-    return
-  }
-
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-  let categories: string[] = []
-  let showLabelDays: number[] = []
-
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  // dayMap 在月视图中存放 RecordItem,在周视图中存放 { rec: RecordItem, date: Date }
-  const dayMap = new Map<number, any>()
-
-  if (viewMode.value === 'month') {
-    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)
-      }
-    }
-    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.hr)
-    }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        recordDate.setHours(0, 0, 0, 0)
-        const recordWeekStart = getWeekStart(recordDate)
-        if (recordWeekStart.getTime() === weekStart.getTime()) {
-          const dayIndex = Math.round((recordDate.getTime() - weekStart.getTime()) / 86400000) + 1
-          dayMap.set(dayIndex, { rec: r, date: recordDate })
-        }
-      }
-    }
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 1; i <= 7; i++) {
-      const entry = dayMap.get(i)
-      if (entry) {
-        const rec = entry.rec as RecordItem
-        const date = entry.date as Date
-        filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
-        data.push(rec.hr)
-      }
-    }
-  }
-
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  const validData = data.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 2 : 40
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 2 : 130
-
-  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) {
-      // 忽略尺寸更新错误
-    }
-
-    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
-}
+const hrChart = createUChart({
+  canvasId: 'hrChart',
+  vm,
+  getCanvasSize,
+  seriesNames: '心率',
+  valueAccessors: (r: RecordItem) => r.hr,
+  colors: '#ff6a00'
+})
 
 onMounted(() => {
-  // 延迟确保DOM渲染完成
+  // 延迟确保DOM渲染完成并设置canvas尺寸
   setTimeout(async () => {
     await nextTick()
-    // 由 getCanvasSize 计算并覆盖 canvasWidth/canvasHeight(确保模板样式和绘图一致)
     try {
       const size = await getCanvasSize()
       canvasWidth.value = size.width
@@ -619,59 +215,30 @@ onMounted(() => {
     } catch (e) {
       console.warn('getCanvasSize failed on mounted', e)
     }
-    await drawChart()
+    await hrChart.draw(records, current, viewMode)
   }, 500)
 })
 
-// 简化监听,避免频繁重绘
+// 监听并更新图表(轻微去抖)
 watch([() => current.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await hrChart.update(records, current, viewMode)
   }, 100)
 })
 
 watch([() => records.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await hrChart.update(records, current, viewMode)
   }, 100)
 }, { deep: true })
 
 onBeforeUnmount(() => {
-  if (chartInstance.value && chartInstance.value.destroy) {
-    try {
-      chartInstance.value.destroy()
-    } catch (e) {
-      console.warn('uCharts destroy error:', e)
-    }
-  }
-  chartInstance.value = null
-  chartInitialized = false
+  try { hrChart.destroy() } catch (e) { console.warn('hrChart destroy error', e) }
 })
 
-// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入
+// 强制重建图表(用于切换月份时彻底刷新)
 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)
-  }
+  try { await hrChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart failed', e) }
 }
 
 // 周/月周期切换与 picker 处理

+ 22 - 484
src/pages/health/details/height.vue

@@ -83,7 +83,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
-import uCharts from '@qiun/ucharts'
+import { createUChart } from '@/composables/useUChart'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import ScaleRuler from '@/components/scale-ruler.vue'
@@ -205,448 +205,20 @@ const averageHeight = computed(() => {
 
 // 使用共享日期工具函数 (src/utils/date.ts)
 
-// Canvas / uCharts 绘图 - 修复版本
-const chartInstance = ref<any>(null)
+// 使用 createUChart composable
 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()
-  let categories: string[] = []
-  let showLabelDays: number[] = []
-  
-  if (viewMode.value === 'month') {
-    // 月视图
-    const days = daysInMonth(year, month)
-    // 选择要显示的标签: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('')
-      }
-    }
-  } else {
-    // 周视图:显示星期一到星期日
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  // 只为有记录的天生成categories和data,避免将无数据天设为0
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  // 使用Map按日聚合,保留最新记录(records 数组头部为最新)
-  const dayMap = new Map<number, RecordItem>()
-  
-  if (viewMode.value === 'month') {
-    // 月视图:按日期聚合
-    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)
-        }
-      }
-    }
-    
-    // 将有数据的日期按日顺序输出
-    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.height)
-    }
-  } else {
-    // 周视图:按星期聚合
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        const weekStartDate = getWeekStart(recordDate)
-        // 检查记录是否在本周内
-        if (weekStartDate.getTime() === weekStart.getTime()) {
-          const dayOfWeek = recordDate.getDay() || 7 // 0=周日,转换为7
-          dayMap.set(dayOfWeek, r)
-        }
-      }
-    }
-    
-    // 按星期一到星期日顺序输出
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 1; i <= 7; i++) {
-      const rec = dayMap.get(i)
-      if (rec) {
-        const date = new Date(rec.date)
-        filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
-        data.push(rec.height)
-      }
-    }
-  }
-
-  // 如果没有任何数据,则回退为整月/周空数据(避免 uCharts 抛错)
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  // 计算合理的Y轴范围
-  const validData = data.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 2 : 140
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 2 : 200
-
-  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('heightChart', vm.proxy) : uni.createCanvasContext('heightChart')
-      } catch (e) {
-        // 再尝试不传vm
-        try { ctx = uni.createCanvasContext('heightChart') } catch (err) { ctx = null }
-      }
-    }
-  } catch (e) {
-    ctx = null
-  }
-
-  // H5环境尝试使用DOM获取2D上下文
-  if (!ctx && typeof document !== 'undefined') {
-    try {
-      // 重试逻辑:初次可能未渲染到 DOM
-      let el: HTMLCanvasElement | null = null
-      for (let attempt = 0; attempt < 3; attempt++) {
-        el = document.getElementById('heightChart') 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) {
-      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
-  })
-
-  // 简化的uCharts配置 - 关闭所有可能产生重叠的选项
-  const config = {
-    $this: vm?.proxy,
-    canvasId: 'heightChart',
-    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}cm` : '' // 只显示整数值
-    },
-    extra: {
-      line: {
-        type: 'curve',
-        width: 1, // 进一步调细线宽
-        activeType: 'point', // 简化点样式
-        point: {
-          radius: 0.5, // 进一步调小数据点半径
-          strokeWidth: 0.5 // 调小边框宽度
-        }
-      },
-      tooltip: {
-        showBox: false, // 关闭提示框避免重叠
-        showCategory: false
-      }
-    }
-  }
-
-  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
-    }
-
-    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
-    }
-    return
-  }
-
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-  
-  let categories: string[] = []
-  let showLabelDays: number[] = []
-  
-  if (viewMode.value === 'month') {
-    // 月视图
-    const days = daysInMonth(year, month)
-    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('')
-      }
-    }
-  } else {
-    // 周视图
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  // 只为有记录的天生成categories和data
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  const dayMap = new Map<number, RecordItem>()
-  
-  if (viewMode.value === 'month') {
-    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)
-        }
-      }
-    }
-    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.height)
-    }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        const weekStartDate = getWeekStart(recordDate)
-        if (weekStartDate.getTime() === weekStart.getTime()) {
-          const dayOfWeek = recordDate.getDay() || 7
-          dayMap.set(dayOfWeek, r)
-        }
-      }
-    }
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 1; i <= 7; i++) {
-      const rec = dayMap.get(i)
-      if (rec) {
-        const date = new Date(rec.date)
-        filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`)
-        data.push(rec.height)
-      }
-    }
-  }
-  
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  const validData = data.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 2 : 140
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 2 : 200
-
-  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) {
-      // 忽略尺寸更新错误
-    }
+const heightChart = createUChart({
+  canvasId: 'heightChart',
+  vm,
+  getCanvasSize,
+  seriesNames: '身高',
+  valueAccessors: r => r.height,
+  colors: '#ff6a00'
+})
 
-    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(() => {
   // 延迟确保DOM渲染完成
   setTimeout(async () => {
@@ -659,61 +231,27 @@ onMounted(() => {
     } catch (e) {
       console.warn('getCanvasSize failed on mounted', e)
     }
-    await drawChart()
+    await heightChart.draw(records, current, viewMode)
   }, 500)
 })
 
 // 简化监听,避免频繁重绘
 watch([() => current.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await heightChart.update(records, current, viewMode)
   }, 100)
 })
 
 watch([() => records.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await heightChart.update(records, current, viewMode)
   }, 100)
 }, { deep: true })
 
 onBeforeUnmount(() => {
-  if (chartInstance.value && chartInstance.value.destroy) {
-    try {
-      chartInstance.value.destroy()
-    } catch (e) {
-      console.warn('uCharts destroy error:', e)
-    }
-  }
-  chartInstance.value = null
-  chartInitialized = false
+  heightChart.destroy()
 })
 
-// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
-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 prevPeriod() {
   const d = new Date(current.value)
@@ -725,7 +263,7 @@ async function prevPeriod() {
   current.value = d
   pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
   records.value = generateMockRecords(d)
-  await rebuildChart()
+  await heightChart.rebuild(records, current, viewMode)
 }
 
 async function nextPeriod() {
@@ -738,7 +276,7 @@ async function nextPeriod() {
   current.value = d
   pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
   records.value = generateMockRecords(d)
-  await rebuildChart()
+  await heightChart.rebuild(records, current, viewMode)
 }
 
 async function setViewMode(mode: 'month' | 'week') {
@@ -746,7 +284,7 @@ async function setViewMode(mode: 'month' | 'week') {
     viewMode.value = mode
     // 重新生成数据和图表
     records.value = generateMockRecords(current.value)
-    await rebuildChart()
+    await heightChart.rebuild(records, current, viewMode)
   }
 }
 
@@ -759,7 +297,7 @@ async function onPickerChange(e: any) {
     current.value = d
     pickerValue.value = [val[0], val[1]]
     records.value = generateMockRecords(d)
-    await rebuildChart()
+    await heightChart.rebuild(records, current, viewMode)
   }
 }
 
@@ -823,7 +361,7 @@ async function confirmAdd() {
   closeAdd()
   // 新增记录后彻底重建图表,确保像退出再进入一样刷新
   try {
-    await rebuildChart()
+    await heightChart.rebuild(records, current, viewMode)
   } catch (e) {
     console.warn('rebuildChart after add failed', e)
   }
@@ -837,13 +375,13 @@ async function confirmDeleteRecord(id: string) {
       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) }
+          try { await heightChart.rebuild(records, current, viewMode) } 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) }
+    try { await heightChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart after delete failed', e) }
   }
 }
 </script>

+ 22 - 306
src/pages/health/details/weight.vue

@@ -81,7 +81,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
-import uCharts from '@qiun/ucharts'
+import { createUChart } from '@/composables/useUChart'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
 import ScaleRuler from '@/components/scale-ruler.vue'
@@ -196,270 +196,21 @@ const averageWeight = computed(() => {
 })
 // 使用共享日期工具 (src/utils/date.ts)
 
-// Canvas / uCharts 绘图 - 修复版本
-const chartInstance = ref<any>(null)
+// 使用 createUChart composable (替换本地 uCharts 管理)
 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 }
-
-  const size = await getCanvasSize()
-  const cssWidth = size.width
-  const cssHeight = size.height
-  const pixelRatio = 1
-  const rightGap = Math.max(24, Math.round(cssWidth * 0.04))
-  const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
-
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-
-  // categories 构造,区分月/周视图
-  const categories: string[] = []
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    const showLabelDays: number[] = []
-    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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一', '二', '三', '四', '五', '六', '日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-  }
-
-  // 聚合数据
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  const dayMap = new Map<number, RecordItem>()
-
-  if (viewMode.value === 'month') {
-    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)
-      }
-    }
-    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.weight) }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        const weekStartDate = getWeekStart(recordDate)
-        if (weekStartDate.getTime() === weekStart.getTime()) {
-          const dayOfWeek = recordDate.getDay() || 7
-          dayMap.set(dayOfWeek, r)
-        }
-      }
-    }
-    const weekDays = ['一','二','三','四','五','六','日']
-    for (let i = 1; i <= 7; i++) {
-      const rec = dayMap.get(i)
-      if (rec) { const date = new Date(rec.date); filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`); data.push(rec.weight) }
-    }
-  }
-
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  const validData = data.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 2 : 40
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 2 : 100
-
-  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('weightChart', vm.proxy) : uni.createCanvasContext('weightChart') } catch (e) { try { ctx = uni.createCanvasContext('weightChart') } catch (err) { ctx = null } }
-    }
-  } catch (e) { ctx = null }
-
-  if (!ctx && typeof document !== 'undefined') {
-    try {
-      let el: HTMLCanvasElement | null = null
-      for (let attempt = 0; attempt < 3; attempt++) {
-        el = document.getElementById('weightChart') as HTMLCanvasElement | null
-        if (el) break
-        await new Promise(r => setTimeout(r, 50))
-      }
-      if (el && el.getContext) {
-        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; 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) { ctx = null }
-  }
-
-  if (!ctx) { console.warn('Unable to obtain canvas context for uCharts.'); return }
-
-  const config = {
-    $this: vm?.proxy,
-    canvasId: 'weightChart',
-    context: ctx,
-    type: 'line',
-    fontSize: 10,
-    categories: categoriesToUse,
-    series: series,
-    width: chartWidth,
-    padding: [10, rightGap + 8, 18, 10],
-    height: cssHeight,
-    pixelRatio,
-    background: 'transparent',
-    animation: false,
-    enableScroll: false,
-    dataLabel: false,
-    legend: { show: false },
-    xAxis: { disableGrid: true, axisLine: true, axisLineColor: '#e0e0e0', fontColor: '#666666', fontSize: 10, boundaryGap: 'justify' },
-    yAxis: { disableGrid: false, gridColor: '#f5f5f5', splitNumber: 4, min: minVal, max: maxVal, axisLine: true, axisLineColor: '#e0e0e0', fontColor: '#666666', fontSize: 10, format: (val: number) => val % 1 === 0 ? `${val}kg` : '' },
-    extra: { line: { type: 'curve', width: 1, activeType: 'point', point: { radius: 0.5, strokeWidth: 0.5 } }, tooltip: { showBox: false, showCategory: false } }
-  }
-
-  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 }
-    chartInstance.value = new uCharts(config)
-    chartInitialized = true
-  } 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
-    }
-    return
-  }
-  const year = current.value.getFullYear()
-  const month = current.value.getMonth()
-
-  const categories: string[] = []
-  const data: number[] = []
-  const filteredCategories: string[] = []
-  const dayMap = new Map<number, RecordItem>()
-
-  if (viewMode.value === 'month') {
-    const days = daysInMonth(year, month)
-    const showLabelDays: number[] = []
-    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++) categories.push(showLabelDays.includes(d) ? `${d}日` : '')
-
-    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)
-      }
-    }
-    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.weight) }
-  } else {
-    const weekStart = getWeekStart(current.value)
-    const weekDays = ['一','二','三','四','五','六','日']
-    for (let i = 0; i < 7; i++) {
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + i)
-      categories.push(`${date.getDate()}日(${weekDays[i]})`)
-    }
-
-    for (const r of records.value) {
-      const parts = r.date.split('-')
-      if (parts.length >= 3) {
-        const recordDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10))
-        const weekStartDate = getWeekStart(recordDate)
-        if (weekStartDate.getTime() === weekStart.getTime()) {
-          const dayOfWeek = recordDate.getDay() || 7
-          dayMap.set(dayOfWeek, r)
-        }
-      }
-    }
-    for (let i = 1; i <= 7; i++) {
-      const rec = dayMap.get(i)
-      if (rec) { const date = new Date(rec.date); filteredCategories.push(`${date.getDate()}日(${weekDays[i-1]})`); data.push(rec.weight) }
-    }
-  }
-
-  const categoriesToUse = filteredCategories.length ? filteredCategories : categories
-
-  const validData = data.filter(v => v > 0)
-  const minVal = validData.length ? Math.floor(Math.min(...validData)) - 2 : 40
-  const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 2 : 100
-
-  try {
-    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) {}
-
-    chartInstance.value.updateData({ categories: categoriesToUse, series: [{ name: '体重', data: data, color: '#ff6a00' }] })
-    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
-}
+const weightChart = createUChart({
+  canvasId: 'weightChart',
+  vm,
+  getCanvasSize,
+  seriesNames: '体重',
+  valueAccessors: r => r.weight,
+  colors: '#ff6a00'
+})
 
+// 生命周期:初始化 / 更新 / 销毁 通过 composable 调用
 onMounted(() => {
-  // 延迟确保DOM渲染完成
   setTimeout(async () => {
     await nextTick()
-    // 由 getCanvasSize 计算并覆盖 canvasWidth/canvasHeight(确保模板样式和绘图一致)
     try {
       const size = await getCanvasSize()
       canvasWidth.value = size.width
@@ -467,61 +218,26 @@ onMounted(() => {
     } catch (e) {
       console.warn('getCanvasSize failed on mounted', e)
     }
-    await drawChart()
+    await weightChart.draw(records, current, viewMode)
   }, 500)
 })
 
-// 简化监听,避免频繁重绘
 watch([() => current.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await weightChart.update(records, current, viewMode)
   }, 100)
 })
 
 watch([() => records.value], async () => {
   setTimeout(async () => {
-    await updateChartData()
+    await weightChart.update(records, current, viewMode)
   }, 100)
 }, { deep: true })
 
 onBeforeUnmount(() => {
-  if (chartInstance.value && chartInstance.value.destroy) {
-    try {
-      chartInstance.value.destroy()
-    } catch (e) {
-      console.warn('uCharts destroy error:', e)
-    }
-  }
-  chartInstance.value = null
-  chartInitialized = false
+  weightChart.destroy()
 })
 
-// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
-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 prevPeriod() {
   const d = new Date(current.value)
@@ -533,7 +249,7 @@ async function prevPeriod() {
   current.value = d
   pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
   records.value = generateMockRecords(d)
-  await rebuildChart()
+  await weightChart.rebuild(records, current, viewMode)
 }
 
 async function nextPeriod() {
@@ -546,14 +262,14 @@ async function nextPeriod() {
   current.value = d
   pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
   records.value = generateMockRecords(d)
-  await rebuildChart()
+  await weightChart.rebuild(records, current, viewMode)
 }
 
 async function setViewMode(mode: 'month' | 'week') {
   if (viewMode.value !== mode) {
     viewMode.value = mode
     records.value = generateMockRecords(current.value)
-    await rebuildChart()
+    await weightChart.rebuild(records, current, viewMode)
   }
 }
 
@@ -566,7 +282,7 @@ async function onPickerChange(e: any) {
     current.value = d
     pickerValue.value = [val[0], val[1]]
     records.value = generateMockRecords(d)
-    await rebuildChart()
+    await weightChart.rebuild(records, current, viewMode)
   }
 }
 
@@ -617,7 +333,7 @@ async function confirmAdd() {
   closeAdd()
   // 新增记录后彻底重建图表,确保像退出再进入一样刷新
   try {
-    await rebuildChart()
+    await weightChart.rebuild(records, current, viewMode)
   } catch (e) {
     console.warn('rebuildChart after add failed', e)
   }
@@ -631,13 +347,13 @@ async function confirmDeleteRecord(id: string) {
       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) }
+          try { await weightChart.rebuild(records, current, viewMode) } 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) }
+    try { await weightChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart after delete failed', e) }
   }
 }
 </script>