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