height.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. <template>
  2. <CustomNav title="身高" leftType="back" />
  3. <view class="page">
  4. <view class="header">
  5. <view class="month-selector">
  6. <button class="btn" @click="prevMonth">‹</button>
  7. <picker mode="date" :value="pickerValue" @change="onPickerChange">
  8. <view class="month-label">{{ displayYear }} 年 {{ displayMonth }} 月</view>
  9. </picker>
  10. <button class="btn" @click="nextMonth">›</button>
  11. </view>
  12. </view>
  13. <!-- 趋势图:放在月份选择下面 -->
  14. <view class="chart-wrap">
  15. <view class="chart-header">本月趋势</view>
  16. <canvas ref="chartCanvas" id="chartCanvas" canvas-id="heightChart" class="chart-canvas" width="340"
  17. height="120"></canvas>
  18. </view>
  19. <!-- 刻度尺控制(已移入添加模态) -->
  20. <view class="content">
  21. <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHeight }} cm</view>
  22. <view class="list">
  23. <view v-if="records.length === 0" class="empty">本月暂无身高记录,点击右下角 + 添加</view>
  24. <view v-for="(r, idx) in records" :key="r.id" class="list-item">
  25. <view class="date">{{ r.date }}</view>
  26. <view class="value">{{ r.height }} cm</view>
  27. <button class="btn-delete" @click.stop.prevent="confirmDeleteRecord(r.id)">✕</button>
  28. </view>
  29. </view>
  30. </view>
  31. <!-- 悬浮添加按钮 -->
  32. <view class="fab" @click="openAdd">
  33. <view class="fab-inner">+</view>
  34. </view>
  35. <!-- 添加模态(包含刻度尺) -->
  36. <view class="modal" v-if="showAdd">
  37. <view class="modal-backdrop" @click="closeAdd"></view>
  38. <view class="modal-panel">
  39. <!-- Drag handle -->
  40. <view class="drag-handle" />
  41. <view class="modal-header centered">
  42. <view class="modal-title">新增身高记录</view>
  43. </view>
  44. <!-- 将表单与按钮限制在中间的窄容器,便于阅读和操作 -->
  45. <view class="modal-inner">
  46. <view class="form-row">
  47. <text class="label">日期</text>
  48. <picker mode="date" :value="pickerValue" @change="onAddDateChange">
  49. <view class="picker-display">{{ addDateLabel }}</view>
  50. </picker>
  51. </view>
  52. <view class="form-row">
  53. <text class="label">身高 (cm)</text>
  54. <input type="number" v-model.number="addHeight" class="input" placeholder="请输入身高" />
  55. </view>
  56. </view>
  57. <!-- 刻度尺:独立于 .modal-inner,保持屏幕全宽以便手指操作 -->
  58. <view class="ruler-wrap">
  59. <ScaleRuler v-if="showAdd" :min="120" :max="220" :step="1" :gutter="16" :initialValue="addHeight ?? 170"
  60. @change="onAddRulerChange" @update:value="onAddRulerUpdate" />
  61. </view>
  62. <!-- 固定底部的大保存按钮(距离屏幕底部至少 100rpx) -->
  63. <view class="fixed-footer">
  64. <button class="btn-primary btn-full" @click="confirmAdd">保存</button>
  65. </view>
  66. </view>
  67. </view>
  68. </view>
  69. <TabBar />
  70. </template>
  71. <script setup lang="ts">
  72. import { ref, computed } from 'vue'
  73. import CustomNav from '@/components/CustomNav.vue'
  74. import TabBar from '@/components/TabBar.vue'
  75. import ScaleRuler from '@/components/scale-ruler.vue'
  76. type RecordItem = { id: string; date: string; height: number }
  77. // 当前展示的年月(以 JS Date 管理)
  78. const current = ref(new Date())
  79. const pickerValue = ref(formatPickerDate(current.value))
  80. function formatPickerDate(d: Date) {
  81. // 返回 yyyy-MM-dd,用于 picker 的 value
  82. const y = d.getFullYear()
  83. const m = String(d.getMonth() + 1).padStart(2, '0')
  84. const day = String(d.getDate()).padStart(2, '0')
  85. return `${y}-${m}-${day}`
  86. }
  87. const displayYear = computed(() => current.value.getFullYear())
  88. const displayMonth = computed(() => current.value.getMonth() + 1)
  89. // 模拟数据:默认展示本月数据
  90. const records = ref<RecordItem[]>(generateMockRecords(current.value))
  91. function generateMockRecords(d: Date): RecordItem[] {
  92. const y = d.getFullYear()
  93. const m = d.getMonth()
  94. const arr: RecordItem[] = []
  95. // 随机生成 0-6 条数据
  96. const n = Math.floor(Math.random() * 7)
  97. for (let i = 0; i < n; i++) {
  98. const day = Math.max(1, Math.floor(Math.random() * 28) + 1)
  99. const date = new Date(y, m, day)
  100. arr.push({ id: `${y}${m}${i}${Date.now()}`, date: formatDisplayDate(date), height: 150 + Math.floor(Math.random() * 50) })
  101. }
  102. // 按日期排序
  103. return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
  104. }
  105. function formatDisplayDate(d: Date) {
  106. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  107. }
  108. const averageHeight = computed(() => {
  109. if (records.value.length === 0) return '--'
  110. const sum = records.value.reduce((s, r) => s + r.height, 0)
  111. return (sum / records.value.length).toFixed(1)
  112. })
  113. function daysInMonth(year: number, month: number) {
  114. return new Date(year, month + 1, 0).getDate()
  115. }
  116. // 生成用于绘制折线图的数据点(坐标)
  117. const pointCoords = computed(() => {
  118. const year = current.value.getFullYear()
  119. const month = current.value.getMonth()
  120. const days = daysInMonth(year, month)
  121. // 宽度 340,高度 120,给上下留白
  122. const W = 340
  123. const H = 120
  124. const leftPad = 10
  125. const rightPad = 10
  126. const topPad = 10
  127. const bottomPad = 10
  128. const innerW = W - leftPad - rightPad
  129. const innerH = H - topPad - bottomPad
  130. // 按日填充值(null 表示无数据)
  131. const values: Array<number | null> = Array(days).fill(null)
  132. records.value.forEach(r => {
  133. const parts = r.date.split('-')
  134. if (parts.length >= 3) {
  135. const y = parseInt(parts[0], 10)
  136. const m = parseInt(parts[1], 10) - 1
  137. const d = parseInt(parts[2], 10)
  138. if (y === year && m === month && d >= 1 && d <= days) {
  139. values[d - 1] = r.height
  140. }
  141. }
  142. })
  143. const numeric = values.filter(v => v !== null) as number[]
  144. const min = numeric.length ? Math.min(...numeric) : 140
  145. const max = numeric.length ? Math.max(...numeric) : 180
  146. const range = Math.max(1, max - min)
  147. // 计算每个点的坐标
  148. const coords = values.map((v, idx) => {
  149. const x = leftPad + (innerW * idx) / Math.max(1, days - 1)
  150. if (v === null) return null
  151. const y = topPad + innerH - ((v - min) / range) * innerH
  152. return { x, y }
  153. })
  154. return coords
  155. })
  156. const polylinePoints = computed(() => {
  157. const pts = pointCoords.value
  158. const arr: string[] = []
  159. pts.forEach(p => {
  160. if (p) arr.push(`${p.x},${p.y}`)
  161. })
  162. return arr.length ? arr.join(' ') : ''
  163. })
  164. const areaPoints = computed(() => {
  165. const pts = pointCoords.value
  166. if (!pts.length) return ''
  167. const arr: string[] = []
  168. pts.forEach(p => {
  169. if (p) arr.push(`${p.x},${p.y}`)
  170. })
  171. // 加上底部闭合
  172. if (arr.length) {
  173. const firstX = pts.find(p => p)?.x ?? 0
  174. const lastX = pts.slice().reverse().find(p => p)?.x ?? 0
  175. const baseY = 120 - 10
  176. return `${firstX},${baseY} ` + arr.join(' ') + ` ${lastX},${baseY}`
  177. }
  178. return ''
  179. })
  180. // Canvas 绘图
  181. import { onMounted, watch, nextTick, onBeforeUnmount, ref as vueRef, getCurrentInstance } from 'vue'
  182. const chartCanvas = vueRef<HTMLCanvasElement | null>(null)
  183. const vm = getCurrentInstance()
  184. function getCanvasSize(): Promise<{ width: number; height: number }> {
  185. return new Promise(resolve => {
  186. try {
  187. if (typeof uni !== 'undefined' && uni.createSelectorQuery) {
  188. const query = uni.createSelectorQuery().in(vm?.proxy)
  189. query.select('#chartCanvas').boundingClientRect((rect: any) => {
  190. if (rect) resolve({ width: rect.width, height: rect.height })
  191. else resolve({ width: 340, height: 120 })
  192. }).exec()
  193. } else if (chartCanvas.value) {
  194. resolve({ width: chartCanvas.value.clientWidth || 340, height: chartCanvas.value.clientHeight || 120 })
  195. } else {
  196. resolve({ width: 340, height: 120 })
  197. }
  198. } catch (e) {
  199. resolve({ width: 340, height: 120 })
  200. }
  201. })
  202. }
  203. async function drawChart() {
  204. // If running in H5 and canvas DOM is available, use native canvas
  205. const canvas = chartCanvas.value as HTMLCanvasElement | null
  206. if (canvas && canvas.getContext) {
  207. const ctx = canvas.getContext('2d')
  208. if (!ctx) return
  209. const dpr = (typeof uni !== 'undefined' && uni.getSystemInfoSync) ? (uni.getSystemInfoSync().pixelRatio || (window?.devicePixelRatio || 1)) : (window?.devicePixelRatio || 1)
  210. const cssWidth = canvas.clientWidth || 340
  211. const cssHeight = canvas.clientHeight || 120
  212. canvas.width = Math.round(cssWidth * dpr)
  213. canvas.height = Math.round(cssHeight * dpr)
  214. ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  215. ctx.clearRect(0, 0, cssWidth, cssHeight)
  216. ctx.strokeStyle = 'rgba(0,0,0,0.04)'
  217. ctx.lineWidth = 1
  218. for (let i = 0; i <= 4; i++) {
  219. const y = (cssHeight / 4) * i
  220. ctx.beginPath()
  221. ctx.moveTo(0, y)
  222. ctx.lineTo(cssWidth, y)
  223. ctx.stroke()
  224. }
  225. const scaleX = cssWidth / 340
  226. const scaleY = cssHeight / 120
  227. const pts = pointCoords.value.map(p => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
  228. const validPts = pts.filter(p => p !== null) as { x: number; y: number }[]
  229. // draw Y axis labels (4 ticks) and X axis day labels
  230. const year = current.value.getFullYear()
  231. const month = current.value.getMonth()
  232. const days = daysInMonth(year, month)
  233. const valuesForTicks: Array<number | null> = Array(days).fill(null)
  234. records.value.forEach(r => {
  235. const parts = r.date.split('-')
  236. if (parts.length >= 3) {
  237. const y = parseInt(parts[0], 10)
  238. const m = parseInt(parts[1], 10) - 1
  239. const d = parseInt(parts[2], 10)
  240. if (y === year && m === month && d >= 1 && d <= days) valuesForTicks[d - 1] = r.height
  241. }
  242. })
  243. const numeric = valuesForTicks.filter(v => v !== null) as number[]
  244. const minVal = numeric.length ? Math.min(...numeric) : 140
  245. const maxVal = numeric.length ? Math.max(...numeric) : 180
  246. const range = Math.max(1, maxVal - minVal)
  247. const leftPad = 10 * scaleX
  248. const topPad = 10 * scaleY
  249. const innerW = (340 - 10 - 10) * scaleX
  250. const innerH = (120 - 10 - 10) * scaleY
  251. ctx.fillStyle = '#999'
  252. ctx.font = '12px sans-serif'
  253. ctx.textAlign = 'right'
  254. ctx.textBaseline = 'middle'
  255. for (let j = 0; j <= 4; j++) {
  256. const val = Math.round((maxVal - (range * j) / 4) * 10) / 10
  257. const y = topPad + (innerH * j) / 4
  258. ctx.fillText(String(val), leftPad - 6, y)
  259. ctx.beginPath()
  260. ctx.moveTo(leftPad - 3, y)
  261. ctx.lineTo(leftPad, y)
  262. ctx.strokeStyle = 'rgba(0,0,0,0.08)'
  263. ctx.stroke()
  264. }
  265. const step = Math.max(1, Math.ceil(days / 6))
  266. ctx.textAlign = 'center'
  267. ctx.textBaseline = 'top'
  268. for (let d = 1; d <= days; d += step) {
  269. const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
  270. const yTick = topPad + innerH
  271. ctx.beginPath()
  272. ctx.moveTo(x, yTick)
  273. ctx.lineTo(x, yTick + 6)
  274. ctx.strokeStyle = 'rgba(0,0,0,0.08)'
  275. ctx.stroke()
  276. ctx.fillText(String(d), x, yTick + 8)
  277. }
  278. if (validPts.length) {
  279. ctx.beginPath()
  280. ctx.moveTo(validPts[0].x, validPts[0].y)
  281. validPts.forEach(p => ctx.lineTo(p.x, p.y))
  282. ctx.lineTo(validPts[validPts.length - 1].x, cssHeight)
  283. ctx.lineTo(validPts[0].x, cssHeight)
  284. ctx.closePath()
  285. ctx.fillStyle = 'rgba(255,106,0,0.08)'
  286. ctx.fill()
  287. }
  288. if (validPts.length) {
  289. ctx.beginPath()
  290. ctx.moveTo(validPts[0].x, validPts[0].y)
  291. validPts.forEach(p => ctx.lineTo(p.x, p.y))
  292. ctx.strokeStyle = '#ff6a00'
  293. ctx.lineWidth = 2
  294. ctx.lineJoin = 'round'
  295. ctx.lineCap = 'round'
  296. ctx.stroke()
  297. }
  298. validPts.forEach(p => {
  299. ctx.beginPath()
  300. ctx.fillStyle = '#ff6a00'
  301. ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
  302. ctx.fill()
  303. })
  304. return
  305. }
  306. // Otherwise fallback to uni.createCanvasContext for mini-programs / native canvases
  307. try {
  308. const size = await getCanvasSize()
  309. const cssWidth = size.width || 340
  310. const cssHeight = size.height || 120
  311. const ctx = (typeof uni !== 'undefined' && uni.createCanvasContext) ? uni.createCanvasContext('heightChart', vm?.proxy) : null
  312. if (!ctx) return
  313. // clear by drawing a transparent rect
  314. ctx.clearRect && ctx.clearRect(0, 0, cssWidth, cssHeight)
  315. ctx.setStrokeStyle && ctx.setStrokeStyle('rgba(0,0,0,0.04)')
  316. for (let i = 0; i <= 4; i++) {
  317. const y = (cssHeight / 4) * i
  318. if (ctx.beginPath) ctx.beginPath()
  319. ctx.moveTo && ctx.moveTo(0, y)
  320. ctx.lineTo && ctx.lineTo(cssWidth, y)
  321. ctx.stroke && ctx.stroke()
  322. }
  323. const scaleX = cssWidth / 340
  324. const scaleY = cssHeight / 120
  325. const pts = pointCoords.value.map(p => p ? { x: p.x * scaleX, y: p.y * scaleY } : null)
  326. const validPts = pts.filter(p => p !== null) as { x: number; y: number }[]
  327. // draw labels (mini-program canvas API)
  328. const year = current.value.getFullYear()
  329. const month = current.value.getMonth()
  330. const days = daysInMonth(year, month)
  331. const valuesForTicks: Array<number | null> = Array(days).fill(null)
  332. records.value.forEach(r => {
  333. const parts = r.date.split('-')
  334. if (parts.length >= 3) {
  335. const y = parseInt(parts[0], 10)
  336. const m = parseInt(parts[1], 10) - 1
  337. const d = parseInt(parts[2], 10)
  338. if (y === year && m === month && d >= 1 && d <= days) valuesForTicks[d - 1] = r.height
  339. }
  340. })
  341. const numeric = valuesForTicks.filter(v => v !== null) as number[]
  342. const minVal = numeric.length ? Math.min(...numeric) : 140
  343. const maxVal = numeric.length ? Math.max(...numeric) : 180
  344. const range = Math.max(1, maxVal - minVal)
  345. const leftPad = 10 * scaleX
  346. const topPad = 10 * scaleY
  347. const innerW = (340 - 10 - 10) * scaleX
  348. const innerH = (120 - 10 - 10) * scaleY
  349. ctx.setFillStyle && ctx.setFillStyle('#999')
  350. ctx.setFontSize && ctx.setFontSize(12)
  351. // Y axis
  352. for (let j = 0; j <= 4; j++) {
  353. const val = Math.round((maxVal - (range * j) / 4) * 10) / 10
  354. const y = topPad + (innerH * j) / 4
  355. ctx.fillText && ctx.fillText(String(val), leftPad - 6, y)
  356. ctx.beginPath && ctx.beginPath()
  357. ctx.moveTo && ctx.moveTo(leftPad - 3, y)
  358. ctx.lineTo && ctx.lineTo(leftPad, y)
  359. ctx.stroke && ctx.stroke()
  360. }
  361. const step = Math.max(1, Math.ceil(days / 6))
  362. // X axis
  363. for (let d = 1; d <= days; d += step) {
  364. const x = leftPad + (innerW * (d - 1)) / Math.max(1, days - 1)
  365. const yTick = topPad + innerH
  366. ctx.moveTo && ctx.moveTo(x, yTick)
  367. ctx.lineTo && ctx.lineTo(x, yTick + 6)
  368. ctx.stroke && ctx.stroke()
  369. ctx.fillText && ctx.fillText(String(d), x, yTick + 8)
  370. }
  371. if (validPts.length) {
  372. ctx.beginPath && ctx.beginPath()
  373. ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
  374. validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
  375. ctx.lineTo && ctx.lineTo(validPts[validPts.length - 1].x, cssHeight)
  376. ctx.lineTo && ctx.lineTo(validPts[0].x, cssHeight)
  377. ctx.closePath && ctx.closePath()
  378. ctx.setFillStyle && ctx.setFillStyle('rgba(255,106,0,0.08)')
  379. ctx.fill && ctx.fill()
  380. }
  381. if (validPts.length) {
  382. ctx.beginPath && ctx.beginPath()
  383. ctx.moveTo && ctx.moveTo(validPts[0].x, validPts[0].y)
  384. validPts.forEach(p => ctx.lineTo && ctx.lineTo(p.x, p.y))
  385. ctx.setStrokeStyle && ctx.setStrokeStyle('#ff6a00')
  386. ctx.setLineWidth && ctx.setLineWidth(2)
  387. ctx.stroke && ctx.stroke()
  388. }
  389. validPts.forEach(p => {
  390. ctx.beginPath && ctx.beginPath()
  391. ctx.arc && ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
  392. ctx.setFillStyle && ctx.setFillStyle('#ff6a00')
  393. ctx.fill && ctx.fill()
  394. })
  395. ctx.draw && ctx.draw(true)
  396. } catch (err) {
  397. // ignore
  398. }
  399. }
  400. onMounted(() => {
  401. nextTick(() => drawChart())
  402. })
  403. watch([() => records.value, () => current.value], () => {
  404. nextTick(() => drawChart())
  405. }, { deep: true })
  406. onBeforeUnmount(() => {
  407. // no special cleanup required
  408. })
  409. function prevMonth() {
  410. const d = new Date(current.value)
  411. d.setMonth(d.getMonth() - 1)
  412. current.value = d
  413. pickerValue.value = formatPickerDate(d)
  414. records.value = generateMockRecords(d)
  415. }
  416. function nextMonth() {
  417. const d = new Date(current.value)
  418. d.setMonth(d.getMonth() + 1)
  419. current.value = d
  420. pickerValue.value = formatPickerDate(d)
  421. records.value = generateMockRecords(d)
  422. }
  423. function onPickerChange(e: any) {
  424. const val = e?.detail?.value || e
  425. const parts = (val as string).split('-')
  426. if (parts.length >= 2) {
  427. const y = parseInt(parts[0], 10)
  428. const m = parseInt(parts[1], 10) - 1
  429. const d = new Date(y, m, 1)
  430. current.value = d
  431. pickerValue.value = formatPickerDate(d)
  432. records.value = generateMockRecords(d)
  433. }
  434. }
  435. // 添加逻辑
  436. const showAdd = ref(false)
  437. const addDate = ref(formatPickerDate(new Date()))
  438. const addDateLabel = ref(formatDisplayDate(new Date()))
  439. const addHeight = ref<number | null>(null)
  440. // 刻度尺(添加模态使用)的联动处理
  441. function onAddRulerUpdate(val: number) {
  442. addHeight.value = val
  443. // 实时打印用户滑动时的刻度(update 在滚动过程中会频繁触发)
  444. console.log('[ScaleRuler] add update:value ->', val)
  445. }
  446. function onAddRulerChange(val: number) {
  447. addHeight.value = val
  448. console.log('[ScaleRuler] add change ->', val)
  449. }
  450. function openAdd() {
  451. showAdd.value = true
  452. // 打开添加模态时,初始化刻度尺/输入默认值(若之前无值则设为 170)
  453. if (!addHeight.value) addHeight.value = 170
  454. }
  455. function closeAdd() {
  456. showAdd.value = false
  457. addHeight.value = null
  458. }
  459. function onAddDateChange(e: any) {
  460. const val = e?.detail?.value || e
  461. addDate.value = val
  462. addDateLabel.value = val.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1-$2-$3')
  463. }
  464. function confirmAdd() {
  465. if (!addHeight.value) {
  466. uni.showToast({ title: '请输入身高', icon: 'none' })
  467. return
  468. }
  469. const id = `user-${Date.now()}`
  470. const item: RecordItem = { id, date: addDateLabel.value, height: addHeight.value }
  471. // 如果添加的月份与当前展示月份一致,插入
  472. const parts = addDate.value.split('-')
  473. const addY = parseInt(parts[0], 10)
  474. const addM = parseInt(parts[1], 10) - 1
  475. if (addY === current.value.getFullYear() && addM === current.value.getMonth()) {
  476. records.value = [item, ...records.value]
  477. }
  478. uni.showToast({ title: '已添加', icon: 'success' })
  479. closeAdd()
  480. }
  481. // 删除记录逻辑:弹出确认对话框,确认后从 records 中移除
  482. function confirmDeleteRecord(id: string) {
  483. if (typeof uni !== 'undefined' && uni.showModal) {
  484. uni.showModal({
  485. title: '删除记录',
  486. content: '确认要删除该条身高记录吗?此操作无法撤销。',
  487. confirmText: '删除',
  488. cancelText: '取消',
  489. success: (res: any) => {
  490. if (res.confirm) {
  491. records.value = records.value.filter(r => r.id !== id)
  492. uni.showToast({ title: '已删除', icon: 'success' })
  493. }
  494. }
  495. })
  496. } else {
  497. // Fallback:直接删除
  498. records.value = records.value.filter(r => r.id !== id)
  499. }
  500. }
  501. </script>
  502. <style scoped>
  503. .page {
  504. min-height: calc(100vh);
  505. padding-top: calc(var(--status-bar-height) + 44px);
  506. background: #f5f6f8;
  507. box-sizing: border-box
  508. }
  509. .header {
  510. padding: 20rpx 40rpx
  511. }
  512. .month-selector {
  513. display: flex;
  514. align-items: center;
  515. justify-content: center;
  516. gap: 12rpx
  517. }
  518. .month-label {
  519. font-size: 34rpx;
  520. color: #333
  521. }
  522. .btn {
  523. background: transparent;
  524. border: none;
  525. font-size: 36rpx;
  526. color: #666
  527. }
  528. .content {
  529. padding: 20rpx 24rpx 100rpx 24rpx
  530. }
  531. .chart-wrap {
  532. padding: 12rpx 24rpx
  533. }
  534. .chart-header {
  535. font-size: 28rpx;
  536. color: #666;
  537. margin-bottom: 10rpx
  538. }
  539. .chart-svg {
  540. width: 100%;
  541. height: 160rpx;
  542. display: block
  543. }
  544. .summary {
  545. padding: 20rpx;
  546. color: #666;
  547. font-size: 28rpx
  548. }
  549. .list {
  550. background: #fff;
  551. border-radius: 12rpx;
  552. padding: 10rpx;
  553. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
  554. }
  555. .empty {
  556. padding: 40rpx;
  557. text-align: center;
  558. color: #999
  559. }
  560. .list-item {
  561. display: flex;
  562. align-items: center;
  563. padding: 20rpx;
  564. border-bottom: 1rpx solid #f0f0f0;
  565. }
  566. .list-item .date {
  567. color: #666
  568. }
  569. .list-item .value {
  570. color: #333;
  571. font-weight: 600;
  572. flex: 1; /* allow value to take remaining space */
  573. text-align: right; /* keep value left aligned so delete button on right */
  574. }
  575. .btn-delete {
  576. width: 80rpx;
  577. height: 60rpx;
  578. min-width: 60rpx;
  579. min-height: 60rpx;
  580. display: inline-flex;
  581. align-items: center;
  582. justify-content: center;
  583. background: #fff0f0;
  584. color: #d9534f;
  585. border: 1rpx solid rgba(217,83,79,0.15);
  586. border-radius: 8rpx;
  587. margin-left: 30rpx;
  588. }
  589. .fab {
  590. position: fixed;
  591. right: 28rpx;
  592. bottom: 160rpx;
  593. width: 110rpx;
  594. height: 110rpx;
  595. border-radius: 999px;
  596. background: linear-gradient(180deg, #ff7a00, #ff4a00);
  597. display: flex;
  598. align-items: center;
  599. justify-content: center;
  600. box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2);
  601. z-index: 1200
  602. }
  603. .fab-inner {
  604. color: #fff;
  605. font-size: 56rpx;
  606. line-height: 56rpx
  607. }
  608. .modal {
  609. position: fixed;
  610. left: 0;
  611. right: 0;
  612. top: 0;
  613. bottom: 0;
  614. display: flex;
  615. align-items: flex-end;
  616. justify-content: center;
  617. z-index: 1300
  618. }
  619. .modal-backdrop {
  620. position: absolute;
  621. left: 0;
  622. right: 0;
  623. top: 0;
  624. bottom: 0;
  625. background: rgba(0, 0, 0, 0.4)
  626. }
  627. .modal-panel {
  628. position: relative;
  629. width: 100%;
  630. background: #fff;
  631. border-top-left-radius: 18rpx;
  632. border-top-right-radius: 18rpx;
  633. padding: 28rpx 24rpx 140rpx 24rpx; /* leave space for fixed footer */
  634. box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12);
  635. }
  636. .modal-title {
  637. font-size: 56rpx;
  638. margin-block: 60rpx;
  639. color: #222;
  640. font-weight: 700;
  641. letter-spacing: 1rpx;
  642. }
  643. .modal-inner {
  644. max-width: 70%;
  645. margin: 0 auto;
  646. }
  647. .form-row {
  648. display: flex;
  649. align-items: center;
  650. justify-content: space-between;
  651. margin-bottom: 34rpx;
  652. padding: 14rpx 0;
  653. font-size: 32rpx;
  654. }
  655. .input {
  656. width: 240rpx;
  657. text-align: right;
  658. padding: 16rpx;
  659. border-radius: 14rpx;
  660. border: 1rpx solid #eee;
  661. background: #fff7f0;
  662. }
  663. .picker-display {
  664. color: #333
  665. }
  666. .modal-actions {
  667. display: flex;
  668. justify-content: center;
  669. gap: 12rpx;
  670. margin-top: 18rpx;
  671. }
  672. .modal-actions.full {
  673. padding: 6rpx 0 24rpx 0;
  674. }
  675. .btn-primary {
  676. background: #ff6a00;
  677. color: #fff;
  678. padding: 18rpx 22rpx;
  679. border-radius: 16rpx;
  680. text-align: center;
  681. width: 50%;
  682. box-shadow: 0 10rpx 28rpx rgba(255,106,0,0.18);
  683. }
  684. /* Drag handle and header */
  685. .drag-handle {
  686. width: 64rpx;
  687. height: 6rpx;
  688. background: rgba(0,0,0,0.08);
  689. border-radius: 999px;
  690. margin: 10rpx auto 14rpx auto;
  691. }
  692. .modal-header {
  693. display: flex;
  694. align-items: center;
  695. justify-content: center;
  696. gap: 12rpx;
  697. margin-bottom: 6rpx;
  698. }
  699. .label {
  700. color: #666;
  701. }
  702. .ruler-wrap {
  703. margin: 12rpx 0;
  704. /* keep full width so ScaleRuler spans screen width */
  705. }
  706. </style>