|
@@ -4,21 +4,25 @@
|
|
|
<view class="header">
|
|
<view class="header">
|
|
|
<view class="month-selector">
|
|
<view class="month-selector">
|
|
|
<button class="btn" @click="prevMonth">‹</button>
|
|
<button class="btn" @click="prevMonth">‹</button>
|
|
|
- <picker mode="date" :value="pickerValue" @change="onPickerChange">
|
|
|
|
|
- <view class="month-label">{{ displayYear }} 年 {{ displayMonth }} 月</view>
|
|
|
|
|
- </picker>
|
|
|
|
|
|
|
+ <view class="month-label">{{ displayYear }}年 {{ displayMonth }}月</view>
|
|
|
<button class="btn" @click="nextMonth">›</button>
|
|
<button class="btn" @click="nextMonth">›</button>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
+ <picker mode="date" :value="pickerValue" @change="onPickerChange">
|
|
|
|
|
+ <view class="picker-display">切换月份</view>
|
|
|
|
|
+ </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="chartCanvas" canvas-id="heightChart" class="chart-canvas" width="340"
|
|
|
|
|
- height="120"></canvas>
|
|
|
|
|
|
|
+ <canvas
|
|
|
|
|
+ canvas-id="heightChart"
|
|
|
|
|
+ id="heightChart"
|
|
|
|
|
+ class="chart-canvas"
|
|
|
|
|
+ :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
|
|
|
|
+ ></canvas>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
- <!-- 刻度尺控制(已移入添加模态) -->
|
|
|
|
|
-
|
|
|
|
|
<view class="content">
|
|
<view class="content">
|
|
|
<view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHeight }} cm</view>
|
|
<view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHeight }} cm</view>
|
|
|
|
|
|
|
@@ -41,17 +45,13 @@
|
|
|
<view class="modal" v-if="showAdd">
|
|
<view class="modal" v-if="showAdd">
|
|
|
<view class="modal-backdrop" @click="closeAdd"></view>
|
|
<view class="modal-backdrop" @click="closeAdd"></view>
|
|
|
<view class="modal-panel">
|
|
<view class="modal-panel">
|
|
|
- <!-- Drag handle -->
|
|
|
|
|
- <view class="drag-handle" />
|
|
|
|
|
- <view class="modal-header centered">
|
|
|
|
|
- <view class="modal-title">新增身高记录</view>
|
|
|
|
|
- </view>
|
|
|
|
|
|
|
+ <view class="drag-handle"></view>
|
|
|
|
|
+ <view class="modal-header"><text class="modal-title">添加身高</text></view>
|
|
|
|
|
|
|
|
- <!-- 将表单与按钮限制在中间的窄容器,便于阅读和操作 -->
|
|
|
|
|
- <view class="modal-inner">
|
|
|
|
|
|
|
+ <view class="modal-inner">
|
|
|
<view class="form-row">
|
|
<view class="form-row">
|
|
|
<text class="label">日期</text>
|
|
<text class="label">日期</text>
|
|
|
- <picker mode="date" :value="pickerValue" @change="onAddDateChange">
|
|
|
|
|
|
|
+ <picker mode="date" :value="addDate" @change="onAddDateChange">
|
|
|
<view class="picker-display">{{ addDateLabel }}</view>
|
|
<view class="picker-display">{{ addDateLabel }}</view>
|
|
|
</picker>
|
|
</picker>
|
|
|
</view>
|
|
</view>
|
|
@@ -60,15 +60,12 @@
|
|
|
<text class="label">身高 (cm)</text>
|
|
<text class="label">身高 (cm)</text>
|
|
|
<input type="number" v-model.number="addHeight" class="input" placeholder="请输入身高" />
|
|
<input type="number" v-model.number="addHeight" class="input" placeholder="请输入身高" />
|
|
|
</view>
|
|
</view>
|
|
|
-
|
|
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
- <!-- 刻度尺:独立于 .modal-inner,保持屏幕全宽以便手指操作 -->
|
|
|
|
|
<view class="ruler-wrap">
|
|
<view class="ruler-wrap">
|
|
|
- <ScaleRuler v-if="showAdd" :min="120" :max="220" :step="1" :gutter="16" :initialValue="addHeight ?? 170"
|
|
|
|
|
- @change="onAddRulerChange" @update:value="onAddRulerUpdate" />
|
|
|
|
|
|
|
+ <ScaleRuler v-if="showAdd" :min="120" :max="220" :step="1" :gutter="16" :initialValue="addHeight ?? 170" @update="onAddRulerUpdate" @change="onAddRulerChange" />
|
|
|
</view>
|
|
</view>
|
|
|
- <!-- 固定底部的大保存按钮(距离屏幕底部至少 100rpx) -->
|
|
|
|
|
|
|
+
|
|
|
<view class="fixed-footer">
|
|
<view class="fixed-footer">
|
|
|
<button class="btn-primary btn-full" @click="confirmAdd">保存</button>
|
|
<button class="btn-primary btn-full" @click="confirmAdd">保存</button>
|
|
|
</view>
|
|
</view>
|
|
@@ -80,20 +77,34 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed } 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; height: number }
|
|
type RecordItem = { id: string; date: string; height: number }
|
|
|
|
|
|
|
|
-// 当前展示的年月(以 JS Date 管理)
|
|
|
|
|
|
|
+// 当前展示年月
|
|
|
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) {
|
|
|
- // 返回 yyyy-MM-dd,用于 picker 的 value
|
|
|
|
|
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')
|
|
|
const day = String(d.getDate()).padStart(2, '0')
|
|
const day = String(d.getDate()).padStart(2, '0')
|
|
@@ -103,24 +114,44 @@ function formatPickerDate(d: Date) {
|
|
|
const displayYear = computed(() => current.value.getFullYear())
|
|
const displayYear = computed(() => current.value.getFullYear())
|
|
|
const displayMonth = computed(() => current.value.getMonth() + 1)
|
|
const displayMonth = computed(() => current.value.getMonth() + 1)
|
|
|
|
|
|
|
|
-// 模拟数据:默认展示本月数据
|
|
|
|
|
const records = ref<RecordItem[]>(generateMockRecords(current.value))
|
|
const records = ref<RecordItem[]>(generateMockRecords(current.value))
|
|
|
|
|
|
|
|
function generateMockRecords(d: Date): RecordItem[] {
|
|
function generateMockRecords(d: Date): RecordItem[] {
|
|
|
const y = d.getFullYear()
|
|
const y = d.getFullYear()
|
|
|
const m = d.getMonth()
|
|
const m = d.getMonth()
|
|
|
const arr: RecordItem[] = []
|
|
const arr: RecordItem[] = []
|
|
|
- // 随机生成 0-6 条数据
|
|
|
|
|
const n = Math.floor(Math.random() * 7)
|
|
const n = Math.floor(Math.random() * 7)
|
|
|
for (let i = 0; i < n; i++) {
|
|
for (let i = 0; i < n; i++) {
|
|
|
const day = Math.max(1, Math.floor(Math.random() * 28) + 1)
|
|
const day = Math.max(1, Math.floor(Math.random() * 28) + 1)
|
|
|
const date = new Date(y, m, day)
|
|
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) })
|
|
|
|
|
|
|
+ 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))
|
|
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,330 +166,457 @@ 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)
|
|
|
- // 宽度 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 => {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 生成合理的categories - 只显示关键日期
|
|
|
|
|
+ const categories: string[] = []
|
|
|
|
|
+ const showLabelDays = []
|
|
|
|
|
+
|
|
|
|
|
+ // 选择要显示的标签:1号、中间几天、最后一天
|
|
|
|
|
+ if (days > 0) {
|
|
|
|
|
+ showLabelDays.push(1) // 第1天
|
|
|
|
|
+ if (days > 1) showLabelDays.push(days) // 最后一天
|
|
|
|
|
+ // 中间添加2-3个关键点
|
|
|
|
|
+ if (days > 7) showLabelDays.push(Math.ceil(days / 3))
|
|
|
|
|
+ if (days > 14) showLabelDays.push(Math.ceil(days * 2 / 3))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (let d = 1; d <= days; d++) {
|
|
|
|
|
+ if (showLabelDays.includes(d)) {
|
|
|
|
|
+ categories.push(`${d}日`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ categories.push('')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 只为有记录的天生成categories和data,避免将无数据天设为0
|
|
|
|
|
+ const data: number[] = []
|
|
|
|
|
+ const filteredCategories: string[] = []
|
|
|
|
|
+ // 使用Map按日聚合,保留最新记录(records 数组头部为最新)
|
|
|
|
|
+ const dayMap = new Map<number, RecordItem>()
|
|
|
|
|
+ for (const r of records.value) {
|
|
|
const parts = r.date.split('-')
|
|
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) {
|
|
|
|
|
- values[d - 1] = r.height
|
|
|
|
|
|
|
+ if (y === year && m === month) {
|
|
|
|
|
+ // 以最后遍历到的(数组顺序保证头部为最新)作为最终值
|
|
|
|
|
+ dayMap.set(d, r)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- })
|
|
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const numeric = values.filter(v => v !== null) as number[]
|
|
|
|
|
- const min = numeric.length ? Math.min(...numeric) : 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 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)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
-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(' ') : ''
|
|
|
|
|
-})
|
|
|
|
|
|
|
+ // 如果没有任何数据,则回退为整月空数据(避免 uCharts 抛错)
|
|
|
|
|
+ const categoriesToUse = filteredCategories.length ? filteredCategories : categories
|
|
|
|
|
|
|
|
-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 ''
|
|
|
|
|
-})
|
|
|
|
|
|
|
+ // 计算合理的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
|
|
|
|
|
|
|
|
-// Canvas 绘图
|
|
|
|
|
-import { onMounted, watch, nextTick, onBeforeUnmount, ref as vueRef, getCurrentInstance } from 'vue'
|
|
|
|
|
-const chartCanvas = vueRef<HTMLCanvasElement | null>(null)
|
|
|
|
|
-const vm = getCurrentInstance()
|
|
|
|
|
|
|
+ const series = [{
|
|
|
|
|
+ name: '身高',
|
|
|
|
|
+ data: data,
|
|
|
|
|
+ color: '#ff6a00'
|
|
|
|
|
+ }]
|
|
|
|
|
|
|
|
-function getCanvasSize(): Promise<{ width: number; height: number }> {
|
|
|
|
|
- return new Promise(resolve => {
|
|
|
|
|
|
|
+ // 获取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 {
|
|
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 })
|
|
|
|
|
|
|
+ // 重试逻辑:初次可能未渲染到 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) {
|
|
} catch (e) {
|
|
|
- resolve({ width: 340, height: 120 })
|
|
|
|
|
|
|
+ ctx = null
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!ctx) {
|
|
|
|
|
+ console.warn('Unable to obtain canvas context for uCharts. Ensure canvas-id matches and vm proxy is available on mini-program.')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log('Canvas config:', {
|
|
|
|
|
+ width: cssWidth,
|
|
|
|
|
+ height: cssHeight,
|
|
|
|
|
+ pixelRatio,
|
|
|
|
|
+ categoriesLength: categories.length,
|
|
|
|
|
+ dataPoints: data.length
|
|
|
})
|
|
})
|
|
|
-}
|
|
|
|
|
|
|
|
|
|
-async function drawChart() {
|
|
|
|
|
- // 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
|
|
|
|
|
|
|
+ // 简化的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
|
|
|
}
|
|
}
|
|
|
- })
|
|
|
|
|
- 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()
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 在创建新实例前确保销毁旧实例
|
|
|
|
|
+ if (chartInstance.value && chartInstance.value.destroy) {
|
|
|
|
|
+ try { chartInstance.value.destroy() } catch (e) { console.warn('destroy before init failed', e) }
|
|
|
|
|
+ chartInstance.value = null
|
|
|
|
|
+ chartInitialized = false
|
|
|
}
|
|
}
|
|
|
- if (validPts.length) {
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.moveTo(validPts[0].x, validPts[0].y)
|
|
|
|
|
- validPts.forEach(p => ctx.lineTo(p.x, p.y))
|
|
|
|
|
- ctx.strokeStyle = '#ff6a00'
|
|
|
|
|
- ctx.lineWidth = 2
|
|
|
|
|
- ctx.lineJoin = 'round'
|
|
|
|
|
- ctx.lineCap = 'round'
|
|
|
|
|
- ctx.stroke()
|
|
|
|
|
|
|
+
|
|
|
|
|
+ chartInstance.value = new uCharts(config)
|
|
|
|
|
+ chartInitialized = true
|
|
|
|
|
+ console.log('uCharts initialized successfully')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('uCharts init error:', error)
|
|
|
|
|
+ chartInitialized = false
|
|
|
|
|
+ }
|
|
|
|
|
+ chartBusy = false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新数据而不重新初始化
|
|
|
|
|
+async function updateChartData() {
|
|
|
|
|
+ if (chartBusy) return
|
|
|
|
|
+ chartBusy = true
|
|
|
|
|
+
|
|
|
|
|
+ if (!chartInstance.value || !chartInitialized) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await drawChart()
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ chartBusy = false
|
|
|
}
|
|
}
|
|
|
- validPts.forEach(p => {
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.fillStyle = '#ff6a00'
|
|
|
|
|
- ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
|
|
|
|
|
- ctx.fill()
|
|
|
|
|
- })
|
|
|
|
|
return
|
|
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 year = current.value.getFullYear()
|
|
|
|
|
+ const month = current.value.getMonth()
|
|
|
|
|
+ const days = daysInMonth(year, month)
|
|
|
|
|
+
|
|
|
|
|
+ const categories: string[] = []
|
|
|
|
|
+ const showLabelDays = []
|
|
|
|
|
+
|
|
|
|
|
+ if (days > 0) {
|
|
|
|
|
+ showLabelDays.push(1)
|
|
|
|
|
+ if (days > 1) showLabelDays.push(days)
|
|
|
|
|
+ if (days > 7) showLabelDays.push(Math.ceil(days / 3))
|
|
|
|
|
+ if (days > 14) showLabelDays.push(Math.ceil(days * 2 / 3))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (let d = 1; d <= days; d++) {
|
|
|
|
|
+ if (showLabelDays.includes(d)) {
|
|
|
|
|
+ categories.push(`${d}日`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ categories.push('')
|
|
|
}
|
|
}
|
|
|
- const scaleX = cssWidth / 340
|
|
|
|
|
- const scaleY = cssHeight / 120
|
|
|
|
|
- const 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
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 只为有记录的天生成categories和data
|
|
|
|
|
+ const data: number[] = []
|
|
|
|
|
+ const filteredCategories: string[] = []
|
|
|
|
|
+ const dayMap = new Map<number, RecordItem>()
|
|
|
|
|
+ for (const r of records.value) {
|
|
|
|
|
+ const parts = r.date.split('-')
|
|
|
|
|
+ if (parts.length >= 3) {
|
|
|
|
|
+ const y = parseInt(parts[0], 10)
|
|
|
|
|
+ const m = parseInt(parts[1], 10) - 1
|
|
|
|
|
+ const d = parseInt(parts[2], 10)
|
|
|
|
|
+ if (y === year && m === month) {
|
|
|
|
|
+ dayMap.set(d, r)
|
|
|
}
|
|
}
|
|
|
- })
|
|
|
|
|
- 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()
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ // 忽略尺寸更新错误
|
|
|
}
|
|
}
|
|
|
- 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()
|
|
|
|
|
|
|
+
|
|
|
|
|
+ chartInstance.value.updateData({
|
|
|
|
|
+ categories: categoriesToUse,
|
|
|
|
|
+ series: [{
|
|
|
|
|
+ name: '身高',
|
|
|
|
|
+ data: data,
|
|
|
|
|
+ color: '#ff6a00'
|
|
|
|
|
+ }]
|
|
|
})
|
|
})
|
|
|
- ctx.draw && ctx.draw(true)
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- // ignore
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 更新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(() => {
|
|
onMounted(() => {
|
|
|
- nextTick(() => drawChart())
|
|
|
|
|
|
|
+ // 延迟确保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([() => current.value], async () => {
|
|
|
|
|
+ setTimeout(async () => {
|
|
|
|
|
+ await updateChartData()
|
|
|
|
|
+ }, 100)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-watch([() => records.value, () => current.value], () => {
|
|
|
|
|
- nextTick(() => drawChart())
|
|
|
|
|
|
|
+watch([() => records.value], async () => {
|
|
|
|
|
+ setTimeout(async () => {
|
|
|
|
|
+ await updateChartData()
|
|
|
|
|
+ }, 100)
|
|
|
}, { deep: true })
|
|
}, { deep: true })
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
- // no special cleanup required
|
|
|
|
|
|
|
+ if (chartInstance.value && chartInstance.value.destroy) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ chartInstance.value.destroy()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.warn('uCharts destroy error:', e)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ chartInstance.value = null
|
|
|
|
|
+ chartInitialized = false
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-function prevMonth() {
|
|
|
|
|
|
|
+// 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
|
|
|
|
|
+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) {
|
|
@@ -468,31 +626,29 @@ 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()))
|
|
|
const addHeight = ref<number | null>(null)
|
|
const addHeight = ref<number | null>(null)
|
|
|
|
|
|
|
|
-// 刻度尺(添加模态使用)的联动处理
|
|
|
|
|
function onAddRulerUpdate(val: number) {
|
|
function onAddRulerUpdate(val: number) {
|
|
|
- addHeight.value = val
|
|
|
|
|
- // 实时打印用户滑动时的刻度(update 在滚动过程中会频繁触发)
|
|
|
|
|
- console.log('[ScaleRuler] add update:value ->', val)
|
|
|
|
|
|
|
+ addHeight.value = Number(val.toFixed(1))
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
function onAddRulerChange(val: number) {
|
|
function onAddRulerChange(val: number) {
|
|
|
- addHeight.value = val
|
|
|
|
|
- console.log('[ScaleRuler] add change ->', val)
|
|
|
|
|
|
|
+ addHeight.value = Number(val.toFixed(1))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function openAdd() {
|
|
function openAdd() {
|
|
|
showAdd.value = true
|
|
showAdd.value = true
|
|
|
- // 打开添加模态时,初始化刻度尺/输入默认值(若之前无值则设为 170)
|
|
|
|
|
if (!addHeight.value) addHeight.value = 170
|
|
if (!addHeight.value) addHeight.value = 170
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
function closeAdd() {
|
|
function closeAdd() {
|
|
|
showAdd.value = false
|
|
showAdd.value = false
|
|
|
addHeight.value = null
|
|
addHeight.value = null
|
|
@@ -501,282 +657,293 @@ function closeAdd() {
|
|
|
function onAddDateChange(e: any) {
|
|
function onAddDateChange(e: any) {
|
|
|
const val = e?.detail?.value || e
|
|
const val = e?.detail?.value || e
|
|
|
addDate.value = val
|
|
addDate.value = val
|
|
|
- addDateLabel.value = val.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1-$2-$3')
|
|
|
|
|
|
|
+ addDateLabel.value = val.replace(/^(.{10}).*$/, '$1')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function confirmAdd() {
|
|
|
|
|
|
|
+async function confirmAdd() {
|
|
|
if (!addHeight.value) {
|
|
if (!addHeight.value) {
|
|
|
- uni.showToast({ title: '请输入身高', icon: 'none' })
|
|
|
|
|
|
|
+ uni.showToast && uni.showToast({ title: '请输入身高', icon: 'none' })
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
const id = `user-${Date.now()}`
|
|
const id = `user-${Date.now()}`
|
|
|
- const item: RecordItem = { id, date: addDateLabel.value, height: addHeight.value }
|
|
|
|
|
- // 如果添加的月份与当前展示月份一致,插入
|
|
|
|
|
|
|
+ const item: RecordItem = { id, date: addDateLabel.value, height: Number(Number(addHeight.value).toFixed(1)) }
|
|
|
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()) {
|
|
if (addY === current.value.getFullYear() && addM === current.value.getMonth()) {
|
|
|
records.value = [item, ...records.value]
|
|
records.value = [item, ...records.value]
|
|
|
}
|
|
}
|
|
|
- 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)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 删除记录逻辑:弹出确认对话框,确认后从 records 中移除
|
|
|
|
|
-function confirmDeleteRecord(id: string) {
|
|
|
|
|
|
|
+async function confirmDeleteRecord(id: string) {
|
|
|
if (typeof uni !== 'undefined' && uni.showModal) {
|
|
if (typeof uni !== 'undefined' && uni.showModal) {
|
|
|
- uni.showModal({
|
|
|
|
|
- title: '删除记录',
|
|
|
|
|
- content: '确认要删除该条身高记录吗?此操作无法撤销。',
|
|
|
|
|
- confirmText: '删除',
|
|
|
|
|
- cancelText: '取消',
|
|
|
|
|
- success: (res: any) => {
|
|
|
|
|
|
|
+ uni.showModal({
|
|
|
|
|
+ title: '删除',
|
|
|
|
|
+ content: '确认删除该条记录吗?',
|
|
|
|
|
+ success: async (res: any) => {
|
|
|
if (res.confirm) {
|
|
if (res.confirm) {
|
|
|
records.value = records.value.filter(r => r.id !== id)
|
|
records.value = records.value.filter(r => r.id !== id)
|
|
|
- uni.showToast({ title: '已删除', icon: 'success' })
|
|
|
|
|
|
|
+ try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
} else {
|
|
} else {
|
|
|
- // Fallback:直接删除
|
|
|
|
|
records.value = records.value.filter(r => r.id !== id)
|
|
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
|
|
|
|
|
|
|
+.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
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.header {
|
|
|
|
|
- padding: 20rpx 40rpx
|
|
|
|
|
|
|
+.month-label {
|
|
|
|
|
+ font-size: 34rpx;
|
|
|
|
|
+ color: #333
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.month-selector {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: center;
|
|
|
|
|
- gap: 12rpx
|
|
|
|
|
|
|
+.btn {
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ font-size: 36rpx;
|
|
|
|
|
+ color: #666
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.month-label {
|
|
|
|
|
- font-size: 34rpx;
|
|
|
|
|
- color: #333
|
|
|
|
|
|
|
+.content {
|
|
|
|
|
+ padding: 20rpx 24rpx 100rpx 24rpx
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.btn {
|
|
|
|
|
- background: transparent;
|
|
|
|
|
- border: none;
|
|
|
|
|
- font-size: 36rpx;
|
|
|
|
|
- color: #666
|
|
|
|
|
|
|
+.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)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.content {
|
|
|
|
|
- padding: 20rpx 24rpx 100rpx 24rpx
|
|
|
|
|
|
|
+.chart-header {
|
|
|
|
|
+ font-size: 32rpx;
|
|
|
|
|
+ color: #333;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+ font-weight: 600
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.chart-wrap {
|
|
|
|
|
- padding: 12rpx 24rpx
|
|
|
|
|
|
|
+/* 关键修复:确保canvas样式正确,参考微信小程序示例 */
|
|
|
|
|
+.chart-canvas {
|
|
|
|
|
+ width: 750rpx;
|
|
|
|
|
+ height: 240rpx;
|
|
|
|
|
+ background-color: #FFFFFF;
|
|
|
|
|
+ display: block;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.chart-header {
|
|
|
|
|
- font-size: 28rpx;
|
|
|
|
|
- color: #666;
|
|
|
|
|
- margin-bottom: 10rpx
|
|
|
|
|
|
|
+.summary {
|
|
|
|
|
+ padding: 20rpx;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+ font-size: 28rpx
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.chart-svg {
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- height: 160rpx;
|
|
|
|
|
- display: block
|
|
|
|
|
|
|
+.list {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ padding: 10rpx;
|
|
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.summary {
|
|
|
|
|
- padding: 20rpx;
|
|
|
|
|
- color: #666;
|
|
|
|
|
- font-size: 28rpx
|
|
|
|
|
|
|
+.empty {
|
|
|
|
|
+ padding: 40rpx;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: #999
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.list {
|
|
|
|
|
- background: #fff;
|
|
|
|
|
- border-radius: 12rpx;
|
|
|
|
|
- padding: 10rpx;
|
|
|
|
|
- box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
|
|
|
|
|
|
|
+.list-item {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 20rpx;
|
|
|
|
|
+ border-bottom: 1rpx solid #f0f0f0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.empty {
|
|
|
|
|
- padding: 40rpx;
|
|
|
|
|
- text-align: center;
|
|
|
|
|
- color: #999
|
|
|
|
|
|
|
+.list-item .date {
|
|
|
|
|
+ color: #666
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.list-item {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- padding: 20rpx;
|
|
|
|
|
- border-bottom: 1rpx solid #f0f0f0;
|
|
|
|
|
|
|
+.list-item .value {
|
|
|
|
|
+ color: #333;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ text-align: right
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.list-item .date {
|
|
|
|
|
- color: #666
|
|
|
|
|
|
|
+.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
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.list-item .value {
|
|
|
|
|
- color: #333;
|
|
|
|
|
- font-weight: 600;
|
|
|
|
|
- flex: 1; /* allow value to take remaining space */
|
|
|
|
|
- text-align: right; /* keep value left aligned so delete button on right */
|
|
|
|
|
|
|
+.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
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.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-inner {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ font-size: 56rpx;
|
|
|
|
|
+ line-height: 56rpx
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.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
|
|
|
|
|
|
|
+.modal {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: flex-end;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ z-index: 1300
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.fab-inner {
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- font-size: 56rpx;
|
|
|
|
|
- line-height: 56rpx
|
|
|
|
|
|
|
+.modal-backdrop {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.4)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.modal {
|
|
|
|
|
- position: fixed;
|
|
|
|
|
- left: 0;
|
|
|
|
|
- right: 0;
|
|
|
|
|
- top: 0;
|
|
|
|
|
- bottom: 0;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: flex-end;
|
|
|
|
|
- justify-content: center;
|
|
|
|
|
- z-index: 1300
|
|
|
|
|
|
|
+.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-backdrop {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- left: 0;
|
|
|
|
|
- right: 0;
|
|
|
|
|
- top: 0;
|
|
|
|
|
- bottom: 0;
|
|
|
|
|
- background: rgba(0, 0, 0, 0.4)
|
|
|
|
|
|
|
+.modal-title {
|
|
|
|
|
+ font-size: 56rpx;
|
|
|
|
|
+ margin-block: 60rpx;
|
|
|
|
|
+ color: #222;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ letter-spacing: 1rpx
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.modal-panel {
|
|
|
|
|
- position: relative;
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- background: #fff;
|
|
|
|
|
- border-top-left-radius: 18rpx;
|
|
|
|
|
- border-top-right-radius: 18rpx;
|
|
|
|
|
- padding: 28rpx 24rpx 140rpx 24rpx; /* leave space for fixed footer */
|
|
|
|
|
- box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12);
|
|
|
|
|
-}
|
|
|
|
|
|
|
+.modal-inner {
|
|
|
|
|
+ max-width: 70%;
|
|
|
|
|
+ margin: 0 auto
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-.modal-title {
|
|
|
|
|
- font-size: 56rpx;
|
|
|
|
|
- margin-block: 60rpx;
|
|
|
|
|
- color: #222;
|
|
|
|
|
- font-weight: 700;
|
|
|
|
|
- letter-spacing: 1rpx;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+.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)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-.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
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-/* Drag handle and header */
|
|
|
|
|
-.drag-handle {
|
|
|
|
|
- width: 64rpx;
|
|
|
|
|
- height: 6rpx;
|
|
|
|
|
- background: rgba(0,0,0,0.08);
|
|
|
|
|
- border-radius: 999px;
|
|
|
|
|
- margin: 10rpx auto 14rpx auto;
|
|
|
|
|
|
|
+.label {
|
|
|
|
|
+ color: #666
|
|
|
}
|
|
}
|
|
|
-.modal-header {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: center;
|
|
|
|
|
- gap: 12rpx;
|
|
|
|
|
- margin-bottom: 6rpx;
|
|
|
|
|
|
|
+
|
|
|
|
|
+.ruler-wrap {
|
|
|
|
|
+ margin: 12rpx 0
|
|
|
}
|
|
}
|
|
|
-.label {
|
|
|
|
|
- color: #666;
|
|
|
|
|
|
|
+
|
|
|
|
|
+.fixed-footer {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ bottom: 40rpx;
|
|
|
|
|
+ padding: 0 24rpx
|
|
|
}
|
|
}
|
|
|
-.ruler-wrap {
|
|
|
|
|
- margin: 12rpx 0;
|
|
|
|
|
- /* keep full width so ScaleRuler spans screen width */
|
|
|
|
|
|
|
+
|
|
|
|
|
+.btn-full {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ padding: 18rpx;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|