heart-rate.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  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="prevPeriod">‹</button>
  7. <view class="period-controls">
  8. <picker mode="multiSelector" :value="pickerValue" :range="pickerRange" @change="onPickerChange">
  9. <view class="month-label">{{ displayPeriod }}</view>
  10. </picker>
  11. <view class="view-toggle">
  12. <button :class="['toggle-btn', { active: viewMode === 'month' }]" @click="setViewMode('month')">月</button>
  13. <button :class="['toggle-btn', { active: viewMode === 'week' }]" @click="setViewMode('week')">周</button>
  14. </view>
  15. </view>
  16. <button class="btn" @click="nextPeriod">›</button>
  17. </view>
  18. </view>
  19. <!-- 趋势图 - 简化canvas设置 -->
  20. <view class="chart-wrap">
  21. <view class="chart-header">本月趋势</view>
  22. <canvas
  23. canvas-id="hrChart"
  24. id="hrChart"
  25. class="chart-canvas"
  26. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  27. ></canvas>
  28. </view>
  29. <view class="content">
  30. <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageHR }} bpm</view>
  31. <view class="list">
  32. <view v-if="records.length === 0" class="empty">暂无记录,点击右下角 + 添加</view>
  33. <view v-for="item in records" :key="item.id" class="list-item">
  34. <view class="date">{{ item.date }}</view>
  35. <view class="value">{{ item.hr }} bpm</view>
  36. <button class="btn-delete" @click="confirmDeleteRecord(item.id)">✕</button>
  37. </view>
  38. </view>
  39. </view>
  40. <view class="fab" @click="openAdd">
  41. <view class="fab-inner">+</view>
  42. </view>
  43. <view class="modal" v-if="showAdd">
  44. <view class="modal-backdrop" @click="closeAdd"></view>
  45. <view class="modal-panel">
  46. <view class="drag-handle"></view>
  47. <view class="modal-header"><text class="modal-title">添加心率</text></view>
  48. <view class="modal-inner">
  49. <view class="form-row">
  50. <text class="label">日期</text>
  51. <picker mode="date" :value="addDate" @change="onAddDateChange">
  52. <view class="picker-display">{{ addDateLabel }}</view>
  53. </picker>
  54. </view>
  55. <view class="form-row">
  56. <text class="label">心率 (bpm)</text>
  57. <input type="number" v-model.number="addHR" class="input" placeholder="请输入心率" />
  58. </view>
  59. </view>
  60. <view class="ruler-wrap">
  61. <ScaleRuler v-if="showAdd" :min="30" :max="200" :step="1" :gutter="16" :initialValue="addHR ?? 72" @update="onAddRulerUpdate" @change="onAddRulerChange" />
  62. </view>
  63. <view class="fixed-footer">
  64. <button class="btn-primary btn-full" @click="confirmAdd">保存</button>
  65. </view>
  66. </view>
  67. </view>
  68. </view>
  69. </template>
  70. <script setup lang="ts">
  71. import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
  72. import { createUChart } from '@/composables/useUChart'
  73. import CustomNav from '@/components/custom-nav.vue'
  74. import ScaleRuler from '@/components/scale-ruler.vue'
  75. import { getWeekStart, getWeekEnd, formatDisplayDate, formatPickerDate, daysInMonth, getTodayStart, isAfterTodayDate, isMonthAfterToday, isWeekAfterToday } from '@/utils/date'
  76. import { getWindowWidth } from '@/utils/platform'
  77. type RecordItem = { id: string; date: string; hr: number }
  78. // 当前展示年月
  79. const current = ref(new Date())
  80. // picker 使用 multiSelector,两列:年份偏移(2000起),月份(0-11)
  81. const pickerValue = ref([current.value.getFullYear() - 2000, current.value.getMonth()])
  82. // 视图模式:'month' 或 'week'
  83. const viewMode = ref<'month' | 'week'>('month')
  84. // 年月选择器的选项范围
  85. const pickerRange = ref([
  86. Array.from({ length: 50 }, (_, i) => `${2000 + i}年`),
  87. Array.from({ length: 12 }, (_, i) => `${i + 1}月`)
  88. ])
  89. // 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
  90. const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
  91. const canvasHeight = ref(280)
  92. // 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
  93. function getCanvasSize(): Promise<{ width: number; height: number }> {
  94. return new Promise(async (resolve) => {
  95. const width = await getWindowWidth().catch(() => 375)
  96. const height = Math.round((280 / 750) * width)
  97. resolve({ width, height })
  98. })
  99. }
  100. // 使用共享日期工具 (src/utils/date.ts)
  101. const displayYear = computed(() => current.value.getFullYear())
  102. const displayMonth = computed(() => current.value.getMonth() + 1)
  103. const displayPeriod = computed(() => {
  104. if (viewMode.value === 'month') {
  105. return `${displayYear.value}年 ${displayMonth.value}月`
  106. } else {
  107. const weekStart = getWeekStart(current.value)
  108. const weekEnd = getWeekEnd(current.value)
  109. return `${formatDisplayDate(weekStart)} - ${formatDisplayDate(weekEnd)}`
  110. }
  111. })
  112. const records = ref<RecordItem[]>(generateMockRecords(current.value))
  113. function generateMockRecords(d: Date): RecordItem[] {
  114. const arr: RecordItem[] = []
  115. if (viewMode.value === 'month') {
  116. const y = d.getFullYear()
  117. const m = d.getMonth()
  118. const days = daysInMonth(y, m)
  119. const n = Math.floor(Math.random() * Math.min(days, 7))
  120. for (let i = 0; i < n; i++) {
  121. const day = Math.max(1, Math.floor(Math.random() * days) + 1)
  122. const date = new Date(y, m, day)
  123. arr.push({ id: `${date.getTime()}${i}${Date.now()}`, date: formatDisplayDate(date), hr: Math.floor(50 + Math.random() * 70) })
  124. }
  125. } else {
  126. const weekStart = getWeekStart(d)
  127. const n = Math.floor(Math.random() * 7)
  128. for (let i = 0; i < n; i++) {
  129. const dayOffset = Math.floor(Math.random() * 7)
  130. const date = new Date(weekStart)
  131. date.setDate(weekStart.getDate() + dayOffset)
  132. arr.push({ id: `${date.getTime()}${i}${Date.now()}`, date: formatDisplayDate(date), hr: Math.floor(50 + Math.random() * 70) })
  133. }
  134. }
  135. return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
  136. }
  137. // 获取指定日期所在周的开始日期(星期一),并规范化到本地午夜
  138. // 使用共享日期工具 (src/utils/date.ts)
  139. // 将 records 聚合为每天一个点(取最新记录)
  140. function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
  141. const map = new Map<number, RecordItem>()
  142. for (const r of recordsArr) {
  143. const parts = r.date.split('-')
  144. if (parts.length >= 3) {
  145. const y = parseInt(parts[0], 10)
  146. const m = parseInt(parts[1], 10) - 1
  147. const d = parseInt(parts[2], 10)
  148. if (y === year && m === month) {
  149. // 覆盖同一天,保留最新的(数组头部为最新)
  150. map.set(d, r)
  151. }
  152. }
  153. }
  154. // 返回按日索引的数组
  155. return map
  156. }
  157. // 使用 formatDisplayDate 从 src/utils/date.ts
  158. const averageHR = computed(() => {
  159. if (records.value.length === 0) return '--'
  160. const sum = records.value.reduce((s, r) => s + r.hr, 0)
  161. return (sum / records.value.length).toFixed(0)
  162. })
  163. // 使用 daysInMonth 从 src/utils/date.ts
  164. // 使用可复用的 chart composable(单序列)
  165. const vm = getCurrentInstance()
  166. const hrChart = createUChart({
  167. canvasId: 'hrChart',
  168. vm,
  169. getCanvasSize,
  170. seriesNames: '心率',
  171. valueAccessors: (r: RecordItem) => r.hr,
  172. colors: '#ff6a00'
  173. })
  174. onMounted(() => {
  175. // 延迟确保DOM渲染完成并设置canvas尺寸
  176. setTimeout(async () => {
  177. await nextTick()
  178. try {
  179. const size = await getCanvasSize()
  180. canvasWidth.value = size.width
  181. canvasHeight.value = size.height
  182. } catch (e) {
  183. console.warn('getCanvasSize failed on mounted', e)
  184. }
  185. await hrChart.draw(records, current, viewMode)
  186. }, 500)
  187. })
  188. // 监听并更新图表(轻微去抖)
  189. watch([() => current.value], async () => {
  190. setTimeout(async () => {
  191. await hrChart.update(records, current, viewMode)
  192. }, 100)
  193. })
  194. watch([() => records.value], async () => {
  195. setTimeout(async () => {
  196. await hrChart.update(records, current, viewMode)
  197. }, 100)
  198. }, { deep: true })
  199. onBeforeUnmount(() => {
  200. try { hrChart.destroy() } catch (e) { console.warn('hrChart destroy error', e) }
  201. })
  202. // 强制重建图表(用于切换月份时彻底刷新)
  203. async function rebuildChart() {
  204. try { await hrChart.rebuild(records, current, viewMode) } catch (e) { console.warn('rebuildChart failed', e) }
  205. }
  206. // 使用共享日期工具(在 src/utils/date.ts 中定义)
  207. // 周/月周期切换与 picker 处理
  208. async function prevPeriod() {
  209. const d = new Date(current.value)
  210. if (viewMode.value === 'month') d.setMonth(d.getMonth() - 1)
  211. else d.setDate(d.getDate() - 7)
  212. current.value = d
  213. pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
  214. records.value = generateMockRecords(d)
  215. await rebuildChart()
  216. }
  217. async function nextPeriod() {
  218. const d = new Date(current.value)
  219. if (viewMode.value === 'month') {
  220. d.setMonth(d.getMonth() + 1)
  221. if (isMonthAfterToday(d)) {
  222. uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
  223. return
  224. }
  225. } else {
  226. d.setDate(d.getDate() + 7)
  227. if (isWeekAfterToday(d)) {
  228. uni.showToast && uni.showToast({ title: '不能查看未来的日期', icon: 'none' })
  229. return
  230. }
  231. }
  232. current.value = d
  233. pickerValue.value = [d.getFullYear() - 2000, d.getMonth()]
  234. records.value = generateMockRecords(d)
  235. await rebuildChart()
  236. }
  237. async function setViewMode(mode: 'month' | 'week') {
  238. if (viewMode.value !== mode) {
  239. viewMode.value = mode
  240. records.value = generateMockRecords(current.value)
  241. await rebuildChart()
  242. }
  243. }
  244. async function onPickerChange(e: any) {
  245. const val = e?.detail?.value || e
  246. if (Array.isArray(val) && val.length >= 2) {
  247. const y = 2000 + Number(val[0])
  248. const m = Number(val[1])
  249. let d = new Date(y, m, 1)
  250. if (isMonthAfterToday(d)) {
  251. const today = getTodayStart()
  252. uni.showToast && uni.showToast({ title: '不能选择未来的月份,已切换到当前月份', icon: 'none' })
  253. d = new Date(today.getFullYear(), today.getMonth(), 1)
  254. pickerValue.value = [today.getFullYear() - 2000, today.getMonth()]
  255. } else {
  256. pickerValue.value = [Number(val[0]), Number(val[1])]
  257. }
  258. current.value = d
  259. records.value = generateMockRecords(d)
  260. await rebuildChart()
  261. }
  262. }
  263. // 添加逻辑保持不变
  264. const showAdd = ref(false)
  265. const addDate = ref(formatPickerDate(new Date()))
  266. const addDateLabel = ref(formatDisplayDate(new Date()))
  267. const addHR = ref<number | null>(null)
  268. function onAddRulerUpdate(val: number) {
  269. addHR.value = Math.round(val)
  270. }
  271. function onAddRulerChange(val: number) {
  272. addHR.value = Math.round(val)
  273. }
  274. function openAdd() {
  275. showAdd.value = true
  276. if (!addHR.value) addHR.value = 72
  277. }
  278. function closeAdd() {
  279. showAdd.value = false
  280. addHR.value = null
  281. }
  282. function onAddDateChange(e: any) {
  283. const val = e?.detail?.value || e
  284. const parts = (val || '').split('-')
  285. const y = parseInt(parts[0] || '', 10)
  286. const m = parseInt(parts[1] || '1', 10) - 1
  287. const d = parseInt(parts[2] || '1', 10)
  288. const sel = new Date(y, m, d)
  289. if (isAfterTodayDate(sel)) {
  290. const today = getTodayStart()
  291. uni.showToast && uni.showToast({ title: '不能选择未来的日期,已切换到今天', icon: 'none' })
  292. addDate.value = formatPickerDate(today)
  293. addDateLabel.value = formatDisplayDate(today)
  294. return
  295. }
  296. addDate.value = val
  297. addDateLabel.value = val.replace(/^(.{10}).*$/, '$1')
  298. }
  299. async function confirmAdd() {
  300. if (!addHR.value) {
  301. uni.showToast && uni.showToast({ title: '请输入心率', icon: 'none' })
  302. return
  303. }
  304. const id = `user-${Date.now()}`
  305. const item: RecordItem = { id, date: addDateLabel.value, hr: addHR.value }
  306. const parts = addDate.value.split('-')
  307. const addY = parseInt(parts[0], 10)
  308. const addM = parseInt(parts[1], 10) - 1
  309. const addD = parseInt(parts[2], 10)
  310. const addDateObj = new Date(addY, addM, addD)
  311. if (isAfterTodayDate(addDateObj)) {
  312. uni.showToast && uni.showToast({ title: '不能添加未来日期的数据', icon: 'none' })
  313. return
  314. }
  315. let isInCurrentPeriod = false
  316. if (viewMode.value === 'month') {
  317. isInCurrentPeriod = addY === current.value.getFullYear() && addM === current.value.getMonth()
  318. } else {
  319. const weekStart = getWeekStart(current.value)
  320. const recordWeekStart = getWeekStart(addDateObj)
  321. isInCurrentPeriod = weekStart.getTime() === recordWeekStart.getTime()
  322. }
  323. if (isInCurrentPeriod) records.value = [item, ...records.value]
  324. uni.showToast && uni.showToast({ title: '已添加', icon: 'success' })
  325. closeAdd()
  326. // 新增记录后彻底重建图表,确保像退出再进入一样刷新
  327. try {
  328. await rebuildChart()
  329. } catch (e) {
  330. console.warn('rebuildChart after add failed', e)
  331. }
  332. }
  333. async function confirmDeleteRecord(id: string) {
  334. if (typeof uni !== 'undefined' && uni.showModal) {
  335. uni.showModal({
  336. title: '删除',
  337. content: '确认删除该条记录吗?',
  338. success: async (res: any) => {
  339. if (res.confirm) {
  340. records.value = records.value.filter(r => r.id !== id)
  341. try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
  342. }
  343. }
  344. })
  345. } else {
  346. records.value = records.value.filter(r => r.id !== id)
  347. try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
  348. }
  349. }
  350. </script>
  351. <style scoped>
  352. .page {
  353. min-height: calc(100vh);
  354. padding-top: calc(var(--status-bar-height) + 44px);
  355. background: #f5f6f8;
  356. box-sizing: border-box
  357. }
  358. .header {
  359. padding: 20rpx 40rpx
  360. }
  361. .month-selector {
  362. display: flex;
  363. align-items: center;
  364. justify-content: center;
  365. gap: 12rpx
  366. }
  367. .period-controls {
  368. display: flex;
  369. flex-direction: column;
  370. align-items: center;
  371. gap: 8rpx;
  372. }
  373. .view-toggle {
  374. display: flex;
  375. gap: 4rpx;
  376. }
  377. .toggle-btn {
  378. padding: 4rpx 12rpx;
  379. border: 1rpx solid #ddd;
  380. background: #f5f5f5;
  381. color: #666;
  382. border-radius: 6rpx;
  383. font-size: 24rpx;
  384. min-width: 60rpx;
  385. text-align: center;
  386. }
  387. .toggle-btn.active {
  388. background: #ff6a00;
  389. color: #fff;
  390. border-color: #ff6a00;
  391. }
  392. .month-label {
  393. font-size: 34rpx;
  394. color: #333
  395. }
  396. .btn {
  397. background: transparent;
  398. border: none;
  399. font-size: 36rpx;
  400. color: #666
  401. }
  402. .content {
  403. padding: 20rpx 24rpx 100rpx 24rpx
  404. }
  405. .chart-wrap {
  406. height: 340rpx;
  407. overflow: hidden; /* 隐藏溢出内容 */
  408. background: #fff;
  409. border-radius: 12rpx;
  410. padding: 24rpx;
  411. margin: 0 24rpx 20rpx 24rpx;
  412. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
  413. }
  414. .chart-header {
  415. font-size: 32rpx;
  416. color: #333;
  417. margin-bottom: 20rpx;
  418. font-weight: 600
  419. }
  420. /* 关键修复:确保canvas样式正确,参考微信小程序示例 */
  421. .chart-canvas { margin-left: -10rpx;
  422. height: 280rpx;
  423. background-color: #FFFFFF;
  424. display: block;
  425. }
  426. .summary {
  427. padding: 20rpx;
  428. color: #666;
  429. font-size: 28rpx
  430. }
  431. .list {
  432. background: #fff;
  433. border-radius: 12rpx;
  434. padding: 10rpx;
  435. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
  436. }
  437. .empty {
  438. padding: 40rpx;
  439. text-align: center;
  440. color: #999
  441. }
  442. .list-item {
  443. display: flex;
  444. align-items: center;
  445. padding: 20rpx;
  446. border-bottom: 1rpx solid #f0f0f0
  447. }
  448. .list-item .date {
  449. color: #666
  450. }
  451. .list-item .value {
  452. color: #333;
  453. font-weight: 600;
  454. flex: 1;
  455. text-align: right
  456. }
  457. .btn-delete {
  458. width: 80rpx;
  459. height: 60rpx;
  460. min-width: 60rpx;
  461. min-height: 60rpx;
  462. display: inline-flex;
  463. align-items: center;
  464. justify-content: center;
  465. background: #fff0f0;
  466. color: #d9534f;
  467. border: 1rpx solid rgba(217,83,79,0.15);
  468. border-radius: 8rpx;
  469. margin-left: 30rpx
  470. }
  471. .fab {
  472. position: fixed;
  473. right: 28rpx;
  474. bottom: 160rpx;
  475. width: 110rpx;
  476. height: 110rpx;
  477. border-radius: 999px;
  478. background: linear-gradient(180deg, #ff7a00, #ff4a00);
  479. display: flex;
  480. align-items: center;
  481. justify-content: center;
  482. box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2);
  483. z-index: 1200
  484. }
  485. .fab-inner {
  486. color: #fff;
  487. font-size: 56rpx;
  488. line-height: 56rpx
  489. }
  490. .modal {
  491. position: fixed;
  492. left: 0;
  493. right: 0;
  494. top: 0;
  495. bottom: 0;
  496. display: flex;
  497. align-items: flex-end;
  498. justify-content: center;
  499. z-index: 1300
  500. }
  501. .modal-backdrop {
  502. position: absolute;
  503. left: 0;
  504. right: 0;
  505. top: 0;
  506. bottom: 0;
  507. background: rgba(0, 0, 0, 0.4)
  508. }
  509. .modal-panel {
  510. position: relative;
  511. width: 100%;
  512. background: #fff;
  513. border-top-left-radius: 18rpx;
  514. border-top-right-radius: 18rpx;
  515. padding: 28rpx 24rpx 140rpx 24rpx;
  516. box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12)
  517. }
  518. .modal-title {
  519. font-size: 56rpx;
  520. margin-block: 60rpx;
  521. color: #222;
  522. font-weight: 700;
  523. letter-spacing: 1rpx
  524. }
  525. .modal-inner {
  526. max-width: 70%;
  527. margin: 0 auto
  528. }
  529. .form-row {
  530. display: flex;
  531. align-items: center;
  532. justify-content: space-between;
  533. margin-bottom: 34rpx;
  534. padding: 14rpx 0;
  535. font-size: 32rpx
  536. }
  537. .input {
  538. width: 150rpx;
  539. text-align: right;
  540. padding: 16rpx;
  541. border-radius: 14rpx;
  542. border: 1rpx solid #eee;
  543. background: #fff7f0
  544. }
  545. .picker-display {
  546. color: #333
  547. }
  548. .btn-primary {
  549. background: #ff6a00;
  550. color: #fff;
  551. padding: 18rpx 22rpx;
  552. border-radius: 16rpx;
  553. text-align: center;
  554. width: 50%;
  555. box-shadow: 0 10rpx 28rpx rgba(255,106,0,0.18)
  556. }
  557. .drag-handle {
  558. width: 64rpx;
  559. height: 6rpx;
  560. background: rgba(0,0,0,0.08);
  561. border-radius: 999px;
  562. margin: 10rpx auto 14rpx auto
  563. }
  564. .modal-header {
  565. display: flex;
  566. align-items: center;
  567. justify-content: center;
  568. gap: 12rpx;
  569. margin-bottom: 6rpx
  570. }
  571. .label {
  572. color: #666
  573. }
  574. .ruler-wrap {
  575. margin: 12rpx 0
  576. }
  577. .fixed-footer {
  578. position: absolute;
  579. left: 0;
  580. right: 0;
  581. bottom: 40rpx;
  582. padding: 0 24rpx
  583. }
  584. .btn-full {
  585. width: 100%;
  586. padding: 18rpx;
  587. border-radius: 12rpx;
  588. }
  589. </style>