Jelajahi Sumber

feat(chart): 优化图表渲染与数据获取逻辑

- 支持单点居中显示并调整点半径
- 优化 x 轴标签显示,避免过多重叠
- 根据数据点数量动态控制 dataLabel 显示
- 重新计算 y 轴范围以适配最终数据
- 从 mock 数据切换为真实 API 获取数据
- 添加新增、删除记录的完整接口调用逻辑
- 增加 token 校验及自动跳转登录功能
- 优化加载提示与错误处理机制
mcbaiyun 1 bulan lalu
induk
melakukan
c2c264b189
2 mengubah file dengan 275 tambahan dan 85 penghapusan
  1. 103 13
      src/composables/useUChart.ts
  2. 172 72
      src/pages/patient/health/details/physical.vue

+ 103 - 13
src/composables/useUChart.ts

@@ -129,12 +129,52 @@ export function createUChart(params: Params) {
 
     const categoriesToUse = filteredCategories.length ? filteredCategories : categories
 
-  const validData = seriesData.flat().filter((v: number) => v > 0)
+  // 构建 series(初始)
+  let series: any = seriesData.map((dArr, idx) => ({ name: seriesNames[idx] || seriesNames[0] || `s${idx}`, data: dArr, color: colors[idx] || colors[0] }))
+
+  // 处理单点场景:如果只有一个类别,扩展为 ['', label, ''] 并把数据置于中间,这样点会居中显示;同时放大点半径以便可见
+  let categoriesFinal = categoriesToUse.slice()
+  let pointRadius = 0.5
+  if (categoriesFinal.length === 1) {
+    const label = categoriesFinal[0]
+    categoriesFinal = ['', label, '']
+    series = series.map((s: any) => {
+      const v = (s.data && s.data.length) ? s.data[0] : null
+      return { ...s, data: [null, v, null] }
+    })
+    pointRadius = 3
+  }
+
+  // 优化 x 轴标签显示:如果类别太多 (>7),只保留最多 7 个可见标签,其他替换为空字符串,但保留数据点
+  if (categoriesFinal.length > 7) {
+    const maxLabels = 7
+    const step = Math.ceil(categoriesFinal.length / maxLabels)
+    categoriesFinal = categoriesFinal.map((lbl, idx) => (idx % step === 0 ? lbl : ''))
+  }
+
+  // 计算实际数据点数量(任一序列在该索引有值则计为一点)
+  const pointCount = (() => {
+    const len = series.length ? series[0].data.length : categoriesFinal.length
+    let cnt = 0
+    for (let i = 0; i < len; i++) {
+      let has = false
+      for (let s of series) {
+        const v = s.data[i]
+        if (v != null && typeof v === 'number') { has = true; break }
+      }
+      if (has) cnt++
+    }
+    return cnt
+  })()
+
+  // 当点数 <= 10 时显示所有 dataLabel;否则隐藏所有 dataLabel(后续可扩展为仅显示极值)
+  const enableDataLabel = pointCount <= 10
+
+  // 重新计算 y 轴范围,基于最终 series
+  const validData = series.flatMap((s:any) => s.data).filter((v: any) => v != null && typeof v === 'number')
   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 {
@@ -179,7 +219,7 @@ export function createUChart(params: Params) {
       context: ctx,
       type: 'line',
       fontSize: 10,
-      categories: categoriesToUse,
+      categories: categoriesFinal,
       series: series,
       width: chartWidth,
       padding: [10, rightGap + 8, 18, 10],
@@ -188,11 +228,12 @@ export function createUChart(params: Params) {
       background: 'transparent',
       animation: false,
       enableScroll: false,
-      dataLabel: false,
+      // 根据点数决定是否显示 dataLabel
+      dataLabel: enableDataLabel,
       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 } }
+      extra: { line: { type: 'curve', width: 1, activeType: 'point', point: { radius: pointRadius, strokeWidth: 0.5 } }, tooltip: { showBox: false, showCategory: false } }
     }
 
     try {
@@ -287,10 +328,53 @@ export function createUChart(params: Params) {
     }
 
     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] }))
+
+    // 初始 series
+    let series: any = seriesData.map((dArr, idx) => ({ name: seriesNames[idx] || seriesNames[0] || `s${idx}`, data: dArr, color: colors[idx] || colors[0] }))
+
+    // 若只有单个类别,进行居中处理并增大点半径
+    let categoriesFinal = categoriesToUse.slice()
+    let pointRadius = 0.5
+    let seriesFinal = series
+    if (categoriesFinal.length === 1) {
+      const label = categoriesFinal[0]
+      categoriesFinal = ['', label, '']
+      seriesFinal = series.map((s: any) => {
+        const v = (s.data && s.data.length) ? s.data[0] : null
+        return { ...s, data: [null, v, null] }
+      })
+      pointRadius = 3
+    }
+
+    // 优化 x 轴标签显示:如果类别太多 (>7),只保留最多 7 个可见标签,其他替换为空字符串,但保留数据点
+    if (categoriesFinal.length > 7) {
+      const maxLabels = 7
+      const step = Math.ceil(categoriesFinal.length / maxLabels)
+      categoriesFinal = categoriesFinal.map((lbl, idx) => (idx % step === 0 ? lbl : ''))
+    }
+
+    // 计算实际数据点数量(任一序列在该索引有值则计为一点)
+    const pointCount = (() => {
+      const len = seriesFinal.length ? seriesFinal[0].data.length : categoriesFinal.length
+      let cnt = 0
+      for (let i = 0; i < len; i++) {
+        let has = false
+        for (let s of seriesFinal) {
+          const v = s.data[i]
+          if (v != null && typeof v === 'number') { has = true; break }
+        }
+        if (has) cnt++
+      }
+      return cnt
+    })()
+
+    // 当点数 <= 10 时显示所有 dataLabel;否则隐藏所有 dataLabel(后续可扩展为仅显示极值)
+    const enableDataLabel = pointCount <= 10
+
+    // 重新计算 y 轴范围,基于最终 series
+    const validDataFinal = seriesFinal.flatMap((s:any) => s.data).filter((v: any) => v != null && typeof v === 'number')
+    const minValFinal = validDataFinal.length ? Math.floor(Math.min(...validDataFinal)) - 1 : 2
+    const maxValFinal = validDataFinal.length ? Math.ceil(Math.max(...validDataFinal)) + 1 : 12
 
     try {
       // try to adjust opts
@@ -305,9 +389,15 @@ export function createUChart(params: Params) {
     } catch (e) { /* ignore */ }
 
     try {
-      chartInstance.value.updateData({ categories: categoriesToUse, series })
-      chartInstance.value.opts.yAxis.min = minVal
-      chartInstance.value.opts.yAxis.max = maxVal
+      // 使用最终计算好的 categories 和 series,并应用点半径与 y 轴范围
+      if (chartInstance.value.opts) chartInstance.value.opts.dataLabel = enableDataLabel
+      chartInstance.value.updateData({ categories: categoriesFinal, series: seriesFinal })
+      chartInstance.value.opts.yAxis.min = minValFinal
+      chartInstance.value.opts.yAxis.max = maxValFinal
+      if (!chartInstance.value.opts.extra) chartInstance.value.opts.extra = {}
+      if (!chartInstance.value.opts.extra.line) chartInstance.value.opts.extra.line = { point: { radius: pointRadius } }
+      else if (!chartInstance.value.opts.extra.line.point) chartInstance.value.opts.extra.line.point = { radius: pointRadius }
+      else chartInstance.value.opts.extra.line.point.radius = pointRadius
     } catch (e) {
       console.error('Update chart error', e)
       try { if (chartInstance.value && chartInstance.value.destroy) chartInstance.value.destroy() } catch (err) { console.warn(err) }

+ 172 - 72
src/pages/patient/health/details/physical.vue

@@ -98,6 +98,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
 import { createUChart } from '@/composables/useUChart'
 import CustomNav from '@/components/custom-nav.vue'
 
@@ -150,38 +151,7 @@ const displayPeriod = computed(() => {
   }
 })
 
-const records = ref<RecordItem[]>(generateMockRecords(current.value))
-
-function generateMockRecords(d: Date): RecordItem[] {
-  const arr: RecordItem[] = []
-  if (viewMode.value === 'month') {
-    const y = d.getFullYear()
-    const m = d.getMonth()
-    const n = Math.floor(Math.random() * Math.min(daysInMonth(y, m), 7))
-    for (let i = 0; i < n; i++) {
-      const day = Math.max(1, Math.floor(Math.random() * daysInMonth(y, m)) + 1)
-      const date = new Date(y, m, day)
-      // 随机生成身高(150-190)和体重(45-100)作为示例数据
-  const h = 150 + Math.floor(Math.random() * 40)
-  const w = 45 + Math.floor(Math.random() * 55)
-  const bmi = Math.round((w / ((h / 100) * (h / 100))) * 10) / 10
-  arr.push({ id: `${date.getTime()}-${i}`, date: formatDisplayDate(date), h, w, bmi })
-    }
-  } else {
-    const weekStart = getWeekStart(d)
-    const n = Math.floor(Math.random() * 7)
-    for (let i = 0; i < n; i++) {
-      const dayOffset = Math.floor(Math.random() * 7)
-      const date = new Date(weekStart)
-      date.setDate(weekStart.getDate() + dayOffset)
-  const h = 150 + Math.floor(Math.random() * 40)
-  const w = 45 + Math.floor(Math.random() * 55)
-  const bmi = Math.round((w / ((h / 100) * (h / 100))) * 10) / 10
-  arr.push({ id: `${date.getTime()}-${i}`, date: formatDisplayDate(date), h, w, bmi })
-    }
-  }
-  return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
-}
+const records = ref<RecordItem[]>([])
 
 // 将 records 聚合为每天一个点(取最新记录)
 function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
@@ -202,6 +172,98 @@ function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
   return map
 }
 
+async function fetchRecords() {
+  let startTime = ''
+  let endTime = ''
+  if (viewMode.value === 'month') {
+    const y = current.value.getFullYear()
+    const m = current.value.getMonth()
+    startTime = new Date(y, m, 1).toISOString()
+    // 将结束时间设为当日 23:59:59.999,避免 ISO 时间为当天 00:00
+    const endDate = new Date(y, m + 1, 0)
+    endDate.setHours(23, 59, 59, 999)
+    endTime = endDate.toISOString()
+  } else {
+    const weekStart = getWeekStart(current.value)
+    const weekEnd = getWeekEnd(current.value)
+    // 确保周结束时间也是该日的 23:59:59.999
+    startTime = weekStart.toISOString()
+    try {
+      const we = new Date(weekEnd)
+      we.setHours(23, 59, 59, 999)
+      endTime = we.toISOString()
+    } catch (e) {
+      endTime = weekEnd.toISOString()
+    }
+  }
+  // 在发送请求前显示 loading 提示;使用 finally 确保在任何情况下都隐藏
+  try {
+    if (typeof uni !== 'undefined' && uni.showLoading) uni.showLoading({ title: '加载中...' })
+  } catch (e) {
+    // ignore
+  }
+
+  try {
+    const token = uni.getStorageSync('token')
+    const res = await uni.request({
+      url: 'https://wx.baiyun.work/physical/list',
+      method: 'POST',
+      data: {
+        pageNum: 1,
+        pageSize: 100,
+        startTime,
+        endTime
+      },
+      header: {
+        'content-type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      }
+    })
+    if (res.statusCode === 401) {
+      // Token 无效,清除并跳转登录
+      uni.removeStorageSync('token')
+      uni.removeStorageSync('role')
+      uni.reLaunch({ url: '/pages/public/login/index' })
+      return
+    }
+    if ((res.data as any) && (res.data as any).code === 200) {
+      const apiRecords = (res.data as any).data?.records || []
+      // 将后端记录映射为前端格式:确保 height/weight 为数字,优先使用后端返回的 bmi,若无则在客户端计算并保留 1 位小数
+      records.value = apiRecords.map((item: any) => {
+        const h = item.height == null ? 0 : Number(item.height)
+        const w = item.weight == null ? 0 : Number(item.weight)
+        let bmiVal: number | null = null
+        if (item.bmi != null && item.bmi !== '') {
+          const parsed = Number(item.bmi)
+          if (!Number.isNaN(parsed)) bmiVal = Math.round(parsed * 10) / 10
+        }
+        if (bmiVal == null && h > 0 && w > 0) {
+          bmiVal = Math.round((w / ((h / 100) * (h / 100))) * 10) / 10
+        }
+        return {
+          id: String(item.id),
+          date: formatDisplayDate(new Date(item.measureTime)),
+          h,
+          w,
+          bmi: bmiVal ?? 0
+        }
+      })
+
+      rebuildChart()
+    } else {
+      console.error('Fetch records failed', res.data)
+    }
+  } catch (e) {
+    console.error('Fetch records error', e)
+  } finally {
+    try {
+      if (typeof uni !== 'undefined' && uni.hideLoading) uni.hideLoading()
+    } catch (e) {
+      // ignore
+    }
+  }
+}
+
 const averageHeight = computed(() => {
   if (records.value.length === 0) return '--'
   const sum = records.value.reduce((s, r) => s + r.h, 0)
@@ -287,6 +349,7 @@ onMounted(() => {
 
     // 先从路由读取 metric(如果有),然后创建 chart
     initMetricFromRoute()
+    await fetchRecords()
     try {
       if (!bpChart) bpChart = createChartForMetric(selectedMetric.value)
     } catch (e) {
@@ -301,6 +364,15 @@ onMounted(() => {
   }, 500)
 })
 
+// 如果在微信小程序端且未登录,自动跳转到登录页
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  }
+})
+
 // 监听并更新图表(轻微去抖)
 watch([() => current.value], async () => {
   setTimeout(async () => {
@@ -352,7 +424,7 @@ async function prevPeriod() {
   }
   current.value = d
   pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
-  records.value = generateMockRecords(d)
+  await fetchRecords()
   await rebuildChart()
 }
 
@@ -373,14 +445,14 @@ async function nextPeriod() {
   }
   current.value = d
   pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
-  records.value = generateMockRecords(d)
+  await fetchRecords()
   await rebuildChart()
 }
 
 async function setViewMode(mode: 'month' | 'week') {
   if (viewMode.value !== mode) {
     viewMode.value = mode
-    records.value = generateMockRecords(current.value)
+    await fetchRecords()
     await rebuildChart()
   }
 }
@@ -400,7 +472,7 @@ async function onPickerChange(e: any) {
       pickerValue.value = [Number(val[0]), Number(val[1])]
     }
     current.value = d
-    records.value = generateMockRecords(d)
+    await fetchRecords()
     await rebuildChart()
   }
 }
@@ -461,43 +533,44 @@ async function confirmAdd() {
       confirmText: '知道了'
     })
   }
-  const id = `user-${Date.now()}`
-  const bmiVal = Math.round((Math.round(addWeight.value) / ((Math.round(addHeight.value) / 100) * (Math.round(addHeight.value) / 100))) * 10) / 10
-  const item: RecordItem = { 
-    id, 
-    date: addDateLabel.value, 
-    h: Math.round(addHeight.value), 
-    w: Math.round(addWeight.value),
-    bmi: bmiVal
-  }
-  const parts = addDate.value.split('-')
-  const addY = parseInt(parts[0], 10)
-  const addM = parseInt(parts[1], 10) - 1
-  const addD = parseInt(parts[2] || '1', 10)
-  const addDateObj = new Date(addY, addM, addD)
-  if (isAfterTodayDate(addDateObj)) {
-    uni.showToast && uni.showToast({ title: '不能添加未来日期的数据', icon: 'none' })
-    return
-  }
-  if (viewMode.value === 'month') {
-    if (addY === current.value.getFullYear() && addM === current.value.getMonth()) {
-      records.value = [item, ...records.value]
+  try {
+    const token = uni.getStorageSync('token')
+    const res = await uni.request({
+      url: 'https://wx.baiyun.work/physical/add',
+      method: 'POST',
+      data: {
+        height: addHeight.value,
+        weight: addWeight.value,
+        measureTime: new Date(addDate.value).toISOString()
+      },
+      header: {
+        'content-type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      }
+    })
+    if (res.statusCode === 401) {
+      // Token 无效,清除并跳转登录
+      uni.removeStorageSync('token')
+      uni.removeStorageSync('role')
+      uni.reLaunch({ url: '/pages/public/login/index' })
+      return
     }
-  } else {
-    const addDateObj = new Date(addY, addM, parseInt(parts[2] || '1', 10))
-    const addWeekStart = getWeekStart(addDateObj)
-    const curWeekStart = getWeekStart(current.value)
-    if (addWeekStart.getTime() === curWeekStart.getTime()) {
-      records.value = [item, ...records.value]
+    if ((res.data as any) && (res.data as any).code === 200) {
+      uni.showToast && uni.showToast({ title: '已添加', icon: 'success' })
+      closeAdd()
+      await fetchRecords()
+      // 新增记录后彻底重建图表,确保像退出再进入一样刷新
+      try {
+        await rebuildChart()
+      } catch (e) {
+        console.warn('rebuildChart after add failed', e)
+      }
+    } else {
+      uni.showToast && uni.showToast({ title: '添加失败', icon: 'none' })
     }
-  }
-  uni.showToast && uni.showToast({ title: '已添加', icon: 'success' })
-  closeAdd()
-  // 新增记录后彻底重建图表,确保像退出再进入一样刷新
-  try {
-    await rebuildChart()
   } catch (e) {
-    console.warn('rebuildChart after add failed', e)
+    console.error('Add record error', e)
+    uni.showToast && uni.showToast({ title: '添加失败', icon: 'none' })
   }
 }
 
@@ -508,8 +581,35 @@ async function confirmDeleteRecord(id: string) {
       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) }
+          try {
+            const token = uni.getStorageSync('token')
+            const delRes = await uni.request({
+              url: 'https://wx.baiyun.work/physical/delete',
+              method: 'POST',
+              data: { id },
+              header: {
+                'content-type': 'application/json',
+                'Authorization': `Bearer ${token}`
+              }
+            })
+            if (delRes.statusCode === 401) {
+              // Token 无效,清除并跳转登录
+              uni.removeStorageSync('token')
+              uni.removeStorageSync('role')
+              uni.reLaunch({ url: '/pages/public/login/index' })
+              return
+            }
+            if ((delRes.data as any) && (delRes.data as any).code === 200) {
+              records.value = records.value.filter(r => r.id !== id)
+              uni.showToast && uni.showToast({ title: '已删除', icon: 'success' })
+              try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
+            } else {
+              uni.showToast && uni.showToast({ title: '删除失败', icon: 'none' })
+            }
+          } catch (e) {
+            console.error('Delete record error', e)
+            uni.showToast && uni.showToast({ title: '删除失败', icon: 'none' })
+          }
         }
       } 
     })