|
@@ -1,18 +1,522 @@
|
|
|
<template>
|
|
<template>
|
|
|
<CustomNav title="身高" leftType="back" />
|
|
<CustomNav title="身高" leftType="back" />
|
|
|
- <view class="content">
|
|
|
|
|
- <view class="placeholder">这里会显示身高历史和录入界面</view>
|
|
|
|
|
|
|
+ <view class="page">
|
|
|
|
|
+ <view class="header">
|
|
|
|
|
+ <view class="month-selector">
|
|
|
|
|
+ <button class="btn" @click="prevMonth">‹</button>
|
|
|
|
|
+ <picker mode="date" :value="pickerValue" @change="onPickerChange">
|
|
|
|
|
+ <view class="month-label">{{ displayYear }} 年 {{ displayMonth }} 月</view>
|
|
|
|
|
+ </picker>
|
|
|
|
|
+ <button class="btn" @click="nextMonth">›</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 趋势图:放在月份选择下面 -->
|
|
|
|
|
+ <view class="chart-wrap">
|
|
|
|
|
+ <view class="chart-header">本月趋势</view>
|
|
|
|
|
+ <canvas ref="chartCanvas" id="chartCanvas" canvas-id="heightChart" class="chart-canvas" width="340" height="120"></canvas>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="content">
|
|
|
|
|
+ <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHeight }} cm</view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="list">
|
|
|
|
|
+ <view v-if="records.length === 0" class="empty">本月暂无身高记录,点击右下角 + 添加</view>
|
|
|
|
|
+ <view v-for="(r, idx) in records" :key="r.id" class="list-item">
|
|
|
|
|
+ <view class="date">{{ r.date }}</view>
|
|
|
|
|
+ <view class="value">{{ r.height }} cm</view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 悬浮添加按钮 -->
|
|
|
|
|
+ <view class="fab" @click="openAdd">
|
|
|
|
|
+ <view class="fab-inner">+</view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 添加模态 -->
|
|
|
|
|
+ <view class="modal" v-if="showAdd">
|
|
|
|
|
+ <view class="modal-backdrop" @click="closeAdd"></view>
|
|
|
|
|
+ <view class="modal-panel">
|
|
|
|
|
+ <view class="modal-title">新增身高记录</view>
|
|
|
|
|
+ <view class="form-row">
|
|
|
|
|
+ <text>日期</text>
|
|
|
|
|
+ <picker mode="date" :value="pickerValue" @change="onAddDateChange">
|
|
|
|
|
+ <view class="picker-display">{{ addDateLabel }}</view>
|
|
|
|
|
+ </picker>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="form-row">
|
|
|
|
|
+ <text>身高 (cm)</text>
|
|
|
|
|
+ <input type="number" v-model="addHeight" class="input" placeholder="请输入身高" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="modal-actions">
|
|
|
|
|
+ <button class="btn-primary" @click="confirmAdd">保存</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
</view>
|
|
</view>
|
|
|
<TabBar />
|
|
<TabBar />
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
|
|
+import { ref, computed } from 'vue'
|
|
|
import CustomNav from '@/components/CustomNav.vue'
|
|
import CustomNav from '@/components/CustomNav.vue'
|
|
|
-
|
|
|
|
|
import TabBar from '@/components/TabBar.vue'
|
|
import TabBar from '@/components/TabBar.vue'
|
|
|
|
|
+
|
|
|
|
|
+type RecordItem = { id: string; date: string; height: number }
|
|
|
|
|
+
|
|
|
|
|
+// 当前展示的年月(以 JS Date 管理)
|
|
|
|
|
+const current = ref(new Date())
|
|
|
|
|
+
|
|
|
|
|
+const pickerValue = ref(formatPickerDate(current.value))
|
|
|
|
|
+
|
|
|
|
|
+function formatPickerDate(d: Date) {
|
|
|
|
|
+ // 返回 yyyy-MM-dd,用于 picker 的 value
|
|
|
|
|
+ const y = d.getFullYear()
|
|
|
|
|
+ const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
+ const day = String(d.getDate()).padStart(2, '0')
|
|
|
|
|
+ return `${y}-${m}-${day}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const displayYear = computed(() => current.value.getFullYear())
|
|
|
|
|
+const displayMonth = computed(() => current.value.getMonth() + 1)
|
|
|
|
|
+
|
|
|
|
|
+// 模拟数据:默认展示本月数据
|
|
|
|
|
+const records = ref<RecordItem[]>(generateMockRecords(current.value))
|
|
|
|
|
+
|
|
|
|
|
+function generateMockRecords(d: Date): RecordItem[] {
|
|
|
|
|
+ const y = d.getFullYear()
|
|
|
|
|
+ const m = d.getMonth()
|
|
|
|
|
+ const arr: RecordItem[] = []
|
|
|
|
|
+ // 随机生成 0-6 条数据
|
|
|
|
|
+ const n = Math.floor(Math.random() * 7)
|
|
|
|
|
+ for (let i = 0; i < n; i++) {
|
|
|
|
|
+ const day = Math.max(1, Math.floor(Math.random() * 28) + 1)
|
|
|
|
|
+ const date = new Date(y, m, day)
|
|
|
|
|
+ arr.push({ id: `${y}${m}${i}${Date.now()}`, date: formatDisplayDate(date), height: 150 + Math.floor(Math.random() * 50) })
|
|
|
|
|
+ }
|
|
|
|
|
+ // 按日期排序
|
|
|
|
|
+ return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatDisplayDate(d: Date) {
|
|
|
|
|
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const averageHeight = computed(() => {
|
|
|
|
|
+ if (records.value.length === 0) return '--'
|
|
|
|
|
+ const sum = records.value.reduce((s, r) => s + r.height, 0)
|
|
|
|
|
+ return (sum / records.value.length).toFixed(1)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+function daysInMonth(year: number, month: number) {
|
|
|
|
|
+ return new Date(year, month + 1, 0).getDate()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 生成用于绘制折线图的数据点(坐标)
|
|
|
|
|
+const pointCoords = computed(() => {
|
|
|
|
|
+ const year = current.value.getFullYear()
|
|
|
|
|
+ const month = current.value.getMonth()
|
|
|
|
|
+ const days = daysInMonth(year, month)
|
|
|
|
|
+ // 宽度 340,高度 120,给上下留白
|
|
|
|
|
+ const W = 340
|
|
|
|
|
+ const H = 120
|
|
|
|
|
+ const leftPad = 10
|
|
|
|
|
+ const rightPad = 10
|
|
|
|
|
+ const topPad = 10
|
|
|
|
|
+ const bottomPad = 10
|
|
|
|
|
+ const innerW = W - leftPad - rightPad
|
|
|
|
|
+ const innerH = H - topPad - bottomPad
|
|
|
|
|
+
|
|
|
|
|
+ // 按日填充值(null 表示无数据)
|
|
|
|
|
+ const values: Array<number | null> = Array(days).fill(null)
|
|
|
|
|
+ records.value.forEach(r => {
|
|
|
|
|
+ const parts = r.date.split('-')
|
|
|
|
|
+ if (parts.length >= 3) {
|
|
|
|
|
+ const y = parseInt(parts[0], 10)
|
|
|
|
|
+ const m = parseInt(parts[1], 10) - 1
|
|
|
|
|
+ const d = parseInt(parts[2], 10)
|
|
|
|
|
+ if (y === year && m === month && d >= 1 && d <= days) {
|
|
|
|
|
+ values[d - 1] = r.height
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const numeric = values.filter(v => v !== null) as number[]
|
|
|
|
|
+ const min = numeric.length ? Math.min(...numeric) : 140
|
|
|
|
|
+ const max = numeric.length ? Math.max(...numeric) : 180
|
|
|
|
|
+ const range = Math.max(1, max - min)
|
|
|
|
|
+
|
|
|
|
|
+ // 计算每个点的坐标
|
|
|
|
|
+ const coords = values.map((v, idx) => {
|
|
|
|
|
+ const x = leftPad + (innerW * idx) / Math.max(1, days - 1)
|
|
|
|
|
+ if (v === null) return null
|
|
|
|
|
+ const y = topPad + innerH - ((v - min) / range) * innerH
|
|
|
|
|
+ return { x, y }
|
|
|
|
|
+ })
|
|
|
|
|
+ return coords
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const polylinePoints = computed(() => {
|
|
|
|
|
+ const pts = pointCoords.value
|
|
|
|
|
+ const arr: string[] = []
|
|
|
|
|
+ pts.forEach(p => {
|
|
|
|
|
+ if (p) arr.push(`${p.x},${p.y}`)
|
|
|
|
|
+ })
|
|
|
|
|
+ return arr.length ? arr.join(' ') : ''
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const areaPoints = computed(() => {
|
|
|
|
|
+ const pts = pointCoords.value
|
|
|
|
|
+ if (!pts.length) return ''
|
|
|
|
|
+ const arr: string[] = []
|
|
|
|
|
+ pts.forEach(p => {
|
|
|
|
|
+ if (p) arr.push(`${p.x},${p.y}`)
|
|
|
|
|
+ })
|
|
|
|
|
+ // 加上底部闭合
|
|
|
|
|
+ if (arr.length) {
|
|
|
|
|
+ const firstX = pts.find(p => p)?.x ?? 0
|
|
|
|
|
+ const lastX = pts.slice().reverse().find(p => p)?.x ?? 0
|
|
|
|
|
+ const baseY = 120 - 10
|
|
|
|
|
+ return `${firstX},${baseY} ` + arr.join(' ') + ` ${lastX},${baseY}`
|
|
|
|
|
+ }
|
|
|
|
|
+ return ''
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// Canvas 绘图
|
|
|
|
|
+import { onMounted, watch, nextTick, onBeforeUnmount, ref as vueRef, getCurrentInstance } from 'vue'
|
|
|
|
|
+const chartCanvas = vueRef<HTMLCanvasElement | null>(null)
|
|
|
|
|
+const vm = getCurrentInstance()
|
|
|
|
|
+
|
|
|
|
|
+function getCanvasSize(): Promise<{ width: number; height: number }> {
|
|
|
|
|
+ return new Promise(resolve => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (typeof uni !== 'undefined' && uni.createSelectorQuery) {
|
|
|
|
|
+ const query = uni.createSelectorQuery().in(vm?.proxy)
|
|
|
|
|
+ query.select('#chartCanvas').boundingClientRect((rect: any) => {
|
|
|
|
|
+ if (rect) resolve({ width: rect.width, height: rect.height })
|
|
|
|
|
+ else resolve({ width: 340, height: 120 })
|
|
|
|
|
+ }).exec()
|
|
|
|
|
+ } else if (chartCanvas.value) {
|
|
|
|
|
+ resolve({ width: chartCanvas.value.clientWidth || 340, height: chartCanvas.value.clientHeight || 120 })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ resolve({ width: 340, height: 120 })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ resolve({ width: 340, height: 120 })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function drawChart() {
|
|
|
|
|
+ // If running in H5 and canvas DOM is available, use native canvas
|
|
|
|
|
+ const canvas = chartCanvas.value as HTMLCanvasElement | null
|
|
|
|
|
+ if (canvas && canvas.getContext) {
|
|
|
|
|
+ const ctx = canvas.getContext('2d')
|
|
|
|
|
+ if (!ctx) return
|
|
|
|
|
+ const dpr = (typeof uni !== 'undefined' && uni.getSystemInfoSync) ? (uni.getSystemInfoSync().pixelRatio || (window?.devicePixelRatio || 1)) : (window?.devicePixelRatio || 1)
|
|
|
|
|
+ const cssWidth = canvas.clientWidth || 340
|
|
|
|
|
+ const cssHeight = canvas.clientHeight || 120
|
|
|
|
|
+ canvas.width = Math.round(cssWidth * dpr)
|
|
|
|
|
+ canvas.height = Math.round(cssHeight * dpr)
|
|
|
|
|
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
|
|
|
+ ctx.clearRect(0, 0, cssWidth, cssHeight)
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(0,0,0,0.04)'
|
|
|
|
|
+ ctx.lineWidth = 1
|
|
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
|
|
+ const y = (cssHeight / 4) * i
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo(0, y)
|
|
|
|
|
+ ctx.lineTo(cssWidth, y)
|
|
|
|
|
+ ctx.stroke()
|
|
|
|
|
+ }
|
|
|
|
|
+ const scaleX = cssWidth / 340
|
|
|
|
|
+ const scaleY = cssHeight / 120
|
|
|
|
|
+ const pts = pointCoords.value.map(p => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
|
|
|
|
|
+ const validPts = pts.filter(p => p !== null) as { x: number; y: number }[]
|
|
|
|
|
+
|
|
|
|
|
+ // draw Y axis labels (4 ticks) and X axis day labels
|
|
|
|
|
+ const year = current.value.getFullYear()
|
|
|
|
|
+ const month = current.value.getMonth()
|
|
|
|
|
+ const days = daysInMonth(year, month)
|
|
|
|
|
+ const valuesForTicks: Array<number | null> = Array(days).fill(null)
|
|
|
|
|
+ records.value.forEach(r => {
|
|
|
|
|
+ const parts = r.date.split('-')
|
|
|
|
|
+ if (parts.length >= 3) {
|
|
|
|
|
+ const y = parseInt(parts[0], 10)
|
|
|
|
|
+ const m = parseInt(parts[1], 10) - 1
|
|
|
|
|
+ const d = parseInt(parts[2], 10)
|
|
|
|
|
+ if (y === year && m === month && d >= 1 && d <= days) valuesForTicks[d - 1] = r.height
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ const numeric = valuesForTicks.filter(v => v !== null) as number[]
|
|
|
|
|
+ const minVal = numeric.length ? Math.min(...numeric) : 140
|
|
|
|
|
+ const maxVal = numeric.length ? Math.max(...numeric) : 180
|
|
|
|
|
+ const range = Math.max(1, maxVal - minVal)
|
|
|
|
|
+ const leftPad = 10 * scaleX
|
|
|
|
|
+ const topPad = 10 * scaleY
|
|
|
|
|
+ const innerW = (340 - 10 - 10) * scaleX
|
|
|
|
|
+ const innerH = (120 - 10 - 10) * scaleY
|
|
|
|
|
+ ctx.fillStyle = '#999'
|
|
|
|
|
+ ctx.font = '12px sans-serif'
|
|
|
|
|
+ ctx.textAlign = 'right'
|
|
|
|
|
+ ctx.textBaseline = 'middle'
|
|
|
|
|
+ for (let j = 0; j <= 4; j++) {
|
|
|
|
|
+ const val = Math.round((maxVal - (range * j) / 4) * 10) / 10
|
|
|
|
|
+ const y = topPad + (innerH * j) / 4
|
|
|
|
|
+ ctx.fillText(String(val), leftPad - 6, y)
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo(leftPad - 3, y)
|
|
|
|
|
+ ctx.lineTo(leftPad, y)
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(0,0,0,0.08)'
|
|
|
|
|
+ ctx.stroke()
|
|
|
|
|
+ }
|
|
|
|
|
+ const step = Math.max(1, Math.ceil(days / 6))
|
|
|
|
|
+ ctx.textAlign = 'center'
|
|
|
|
|
+ ctx.textBaseline = 'top'
|
|
|
|
|
+ for (let d = 1; d <= days; d += step) {
|
|
|
|
|
+ const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
|
|
|
|
|
+ const yTick = topPad + innerH
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo(x, yTick)
|
|
|
|
|
+ ctx.lineTo(x, yTick + 6)
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(0,0,0,0.08)'
|
|
|
|
|
+ ctx.stroke()
|
|
|
|
|
+ ctx.fillText(String(d), x, yTick + 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (validPts.length) {
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo(validPts[0].x, validPts[0].y)
|
|
|
|
|
+ validPts.forEach(p => ctx.lineTo(p.x, p.y))
|
|
|
|
|
+ ctx.lineTo(validPts[validPts.length - 1].x, cssHeight)
|
|
|
|
|
+ ctx.lineTo(validPts[0].x, cssHeight)
|
|
|
|
|
+ ctx.closePath()
|
|
|
|
|
+ ctx.fillStyle = 'rgba(255,106,0,0.08)'
|
|
|
|
|
+ ctx.fill()
|
|
|
|
|
+ }
|
|
|
|
|
+ if (validPts.length) {
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo(validPts[0].x, validPts[0].y)
|
|
|
|
|
+ validPts.forEach(p => ctx.lineTo(p.x, p.y))
|
|
|
|
|
+ ctx.strokeStyle = '#ff6a00'
|
|
|
|
|
+ ctx.lineWidth = 2
|
|
|
|
|
+ ctx.lineJoin = 'round'
|
|
|
|
|
+ ctx.lineCap = 'round'
|
|
|
|
|
+ ctx.stroke()
|
|
|
|
|
+ }
|
|
|
|
|
+ validPts.forEach(p => {
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.fillStyle = '#ff6a00'
|
|
|
|
|
+ ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
|
|
|
|
|
+ ctx.fill()
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Otherwise fallback to uni.createCanvasContext for mini-programs / native canvases
|
|
|
|
|
+ try {
|
|
|
|
|
+ const size = await getCanvasSize()
|
|
|
|
|
+ const cssWidth = size.width || 340
|
|
|
|
|
+ const cssHeight = size.height || 120
|
|
|
|
|
+ const ctx = (typeof uni !== 'undefined' && uni.createCanvasContext) ? uni.createCanvasContext('heightChart', vm?.proxy) : null
|
|
|
|
|
+ if (!ctx) return
|
|
|
|
|
+ // clear by drawing a transparent rect
|
|
|
|
|
+ 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
|
|
|
|
|
+ if (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 / 120
|
|
|
|
|
+ const pts = pointCoords.value.map(p => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
|
|
|
|
|
+ const validPts = pts.filter(p => p !== null) as { x: number; y: number }[]
|
|
|
|
|
+ // draw labels (mini-program canvas API)
|
|
|
|
|
+ const year = current.value.getFullYear()
|
|
|
|
|
+ const month = current.value.getMonth()
|
|
|
|
|
+ const days = daysInMonth(year, month)
|
|
|
|
|
+ const valuesForTicks: Array<number | null> = Array(days).fill(null)
|
|
|
|
|
+ records.value.forEach(r => {
|
|
|
|
|
+ const parts = r.date.split('-')
|
|
|
|
|
+ if (parts.length >= 3) {
|
|
|
|
|
+ const y = parseInt(parts[0], 10)
|
|
|
|
|
+ const m = parseInt(parts[1], 10) - 1
|
|
|
|
|
+ const d = parseInt(parts[2], 10)
|
|
|
|
|
+ if (y === year && m === month && d >= 1 && d <= days) valuesForTicks[d - 1] = r.height
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ const numeric = valuesForTicks.filter(v => v !== null) as number[]
|
|
|
|
|
+ const minVal = numeric.length ? Math.min(...numeric) : 140
|
|
|
|
|
+ const maxVal = numeric.length ? Math.max(...numeric) : 180
|
|
|
|
|
+ const range = Math.max(1, maxVal - minVal)
|
|
|
|
|
+ const leftPad = 10 * scaleX
|
|
|
|
|
+ const topPad = 10 * scaleY
|
|
|
|
|
+ const innerW = (340 - 10 - 10) * scaleX
|
|
|
|
|
+ const innerH = (120 - 10 - 10) * scaleY
|
|
|
|
|
+ ctx.setFillStyle && ctx.setFillStyle('#999')
|
|
|
|
|
+ ctx.setFontSize && ctx.setFontSize(12)
|
|
|
|
|
+ // Y axis
|
|
|
|
|
+ for (let j = 0; j <= 4; j++) {
|
|
|
|
|
+ const val = Math.round((maxVal - (range * j) / 4) * 10) / 10
|
|
|
|
|
+ const y = topPad + (innerH * j) / 4
|
|
|
|
|
+ ctx.fillText && ctx.fillText(String(val), leftPad - 6, y)
|
|
|
|
|
+ ctx.beginPath && ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo && ctx.moveTo(leftPad - 3, y)
|
|
|
|
|
+ ctx.lineTo && ctx.lineTo(leftPad, y)
|
|
|
|
|
+ ctx.stroke && ctx.stroke()
|
|
|
|
|
+ }
|
|
|
|
|
+ const step = Math.max(1, Math.ceil(days / 6))
|
|
|
|
|
+ // X axis
|
|
|
|
|
+ for (let d = 1; d <= days; d += step) {
|
|
|
|
|
+ const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
|
|
|
|
|
+ const yTick = topPad + innerH
|
|
|
|
|
+ ctx.moveTo && ctx.moveTo(x, yTick)
|
|
|
|
|
+ ctx.lineTo && ctx.lineTo(x, yTick + 6)
|
|
|
|
|
+ ctx.stroke && ctx.stroke()
|
|
|
|
|
+ ctx.fillText && ctx.fillText(String(d), x, yTick + 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (validPts.length) {
|
|
|
|
|
+ ctx.beginPath && ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
|
|
|
|
|
+ validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
|
|
|
|
|
+ ctx.lineTo && ctx.lineTo(validPts[validPts.length - 1].x, cssHeight)
|
|
|
|
|
+ ctx.lineTo && ctx.lineTo(validPts[0].x, cssHeight)
|
|
|
|
|
+ ctx.closePath && ctx.closePath()
|
|
|
|
|
+ ctx.setFillStyle && ctx.setFillStyle('rgba(255,106,0,0.08)')
|
|
|
|
|
+ ctx.fill && ctx.fill()
|
|
|
|
|
+ }
|
|
|
|
|
+ if (validPts.length) {
|
|
|
|
|
+ ctx.beginPath && ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
|
|
|
|
|
+ validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
|
|
|
|
|
+ ctx.setStrokeStyle && ctx.setStrokeStyle('#ff6a00')
|
|
|
|
|
+ ctx.setLineWidth && ctx.setLineWidth(2)
|
|
|
|
|
+ ctx.stroke && ctx.stroke()
|
|
|
|
|
+ }
|
|
|
|
|
+ validPts.forEach(p => {
|
|
|
|
|
+ ctx.beginPath && ctx.beginPath()
|
|
|
|
|
+ ctx.arc && ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
|
|
|
|
|
+ ctx.setFillStyle && ctx.setFillStyle('#ff6a00')
|
|
|
|
|
+ ctx.fill && ctx.fill()
|
|
|
|
|
+ })
|
|
|
|
|
+ ctx.draw && ctx.draw(true)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ nextTick(() => drawChart())
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+watch([() => records.value, () => current.value], () => {
|
|
|
|
|
+ nextTick(() => drawChart())
|
|
|
|
|
+}, { deep: true })
|
|
|
|
|
+
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ // no special cleanup required
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+function prevMonth() {
|
|
|
|
|
+ const d = new Date(current.value)
|
|
|
|
|
+ d.setMonth(d.getMonth() - 1)
|
|
|
|
|
+ current.value = d
|
|
|
|
|
+ pickerValue.value = formatPickerDate(d)
|
|
|
|
|
+ records.value = generateMockRecords(d)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function nextMonth() {
|
|
|
|
|
+ const d = new Date(current.value)
|
|
|
|
|
+ d.setMonth(d.getMonth() + 1)
|
|
|
|
|
+ current.value = d
|
|
|
|
|
+ pickerValue.value = formatPickerDate(d)
|
|
|
|
|
+ records.value = generateMockRecords(d)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onPickerChange(e: any) {
|
|
|
|
|
+ const val = e?.detail?.value || e
|
|
|
|
|
+ const parts = (val as string).split('-')
|
|
|
|
|
+ if (parts.length >= 2) {
|
|
|
|
|
+ const y = parseInt(parts[0], 10)
|
|
|
|
|
+ const m = parseInt(parts[1], 10) - 1
|
|
|
|
|
+ const d = new Date(y, m, 1)
|
|
|
|
|
+ current.value = d
|
|
|
|
|
+ pickerValue.value = formatPickerDate(d)
|
|
|
|
|
+ records.value = generateMockRecords(d)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 添加逻辑
|
|
|
|
|
+const showAdd = ref(false)
|
|
|
|
|
+const addDate = ref(formatPickerDate(new Date()))
|
|
|
|
|
+const addDateLabel = ref(formatDisplayDate(new Date()))
|
|
|
|
|
+const addHeight = ref<number | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+function openAdd() {
|
|
|
|
|
+ showAdd.value = true
|
|
|
|
|
+}
|
|
|
|
|
+function closeAdd() {
|
|
|
|
|
+ showAdd.value = false
|
|
|
|
|
+ addHeight.value = null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onAddDateChange(e: any) {
|
|
|
|
|
+ const val = e?.detail?.value || e
|
|
|
|
|
+ addDate.value = val
|
|
|
|
|
+ addDateLabel.value = val.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1-$2-$3')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function confirmAdd() {
|
|
|
|
|
+ if (!addHeight.value) {
|
|
|
|
|
+ uni.showToast({ title: '请输入身高', icon: 'none' })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const id = `user-${Date.now()}`
|
|
|
|
|
+ const item: RecordItem = { id, date: addDateLabel.value, height: addHeight.value }
|
|
|
|
|
+ // 如果添加的月份与当前展示月份一致,插入
|
|
|
|
|
+ const parts = addDate.value.split('-')
|
|
|
|
|
+ const addY = parseInt(parts[0], 10)
|
|
|
|
|
+ const addM = parseInt(parts[1], 10) - 1
|
|
|
|
|
+ if (addY === current.value.getFullYear() && addM === current.value.getMonth()) {
|
|
|
|
|
+ records.value = [item, ...records.value]
|
|
|
|
|
+ }
|
|
|
|
|
+ uni.showToast({ title: '已添加', icon: 'success' })
|
|
|
|
|
+ closeAdd()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
-<style>
|
|
|
|
|
-.placeholder { padding: 40rpx; color: #666 }
|
|
|
|
|
-.content { padding-top: calc(var(--status-bar-height) + 44px) }
|
|
|
|
|
|
|
+<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-svg { width: 100%; height: 160rpx; display: block }
|
|
|
|
|
+.summary { padding: 20rpx; color:#666; font-size:28rpx }
|
|
|
|
|
+.list { background:#fff; border-radius:12rpx; padding: 10rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.03) }
|
|
|
|
|
+.empty { padding:40rpx; text-align:center; color:#999 }
|
|
|
|
|
+.list-item { display:flex; justify-content:space-between; padding:20rpx; border-bottom:1rpx solid #f0f0f0 }
|
|
|
|
|
+.list-item .date { color:#666 }
|
|
|
|
|
+.list-item .value { color:#333; font-weight:600 }
|
|
|
|
|
+
|
|
|
|
|
+.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:30rpx }
|
|
|
|
|
+.modal-title { font-size:34rpx; margin-bottom:20rpx }
|
|
|
|
|
+.form-row { display:flex; align-items:center; justify-content:space-between; margin-bottom:18rpx }
|
|
|
|
|
+.input { width:260rpx; text-align:right; padding:8rpx; border-radius:8rpx; border:1rpx solid #eee }
|
|
|
|
|
+.picker-display { color:#333 }
|
|
|
|
|
+.modal-actions { display:flex; justify-content:flex-end; gap:12rpx }
|
|
|
|
|
+.btn-muted { background:#f0f0f0; padding:10rpx 20rpx; border-radius:8rpx }
|
|
|
|
|
+.btn-primary { background:#ff6a00; color:#fff; padding:10rpx 20rpx; border-radius:8rpx }
|
|
|
</style>
|
|
</style>
|