blood-pressure.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  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. <view class="month-label">{{ displayYear }}年 {{ displayMonth }}月</view>
  8. <button class="btn" @click="nextMonth">›</button>
  9. </view>
  10. <picker mode="date" :value="pickerValue" @change="onPickerChange">
  11. <view class="picker-display">切换月份</view>
  12. </picker>
  13. </view>
  14. <!-- 趋势图 - 简化canvas设置 -->
  15. <view class="chart-wrap">
  16. <view class="chart-header">本月趋势</view>
  17. <canvas
  18. canvas-id="bpChart"
  19. id="bpChart"
  20. class="chart-canvas"
  21. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  22. ></canvas>
  23. </view>
  24. <view class="content">
  25. <view class="summary">共 {{ records.length }} 条记录,本月平均:{{ averageSystolic }}/{{ averageDiastolic }} mmHg</view>
  26. <view class="list">
  27. <view v-if="records.length === 0" class="empty">暂无记录,点击右下角 + 添加</view>
  28. <view v-for="item in records" :key="item.id" class="list-item">
  29. <view class="date">{{ item.date }}</view>
  30. <view class="value">{{ item.s }}/{{ item.d }} mmHg</view>
  31. <button class="btn-delete" @click="confirmDeleteRecord(item.id)">✕</button>
  32. </view>
  33. </view>
  34. </view>
  35. <view class="fab" @click="openAdd">
  36. <view class="fab-inner">+</view>
  37. </view>
  38. <view class="modal" v-if="showAdd">
  39. <view class="modal-backdrop" @click="closeAdd"></view>
  40. <view class="modal-panel">
  41. <view class="drag-handle"></view>
  42. <view class="modal-header"><text class="modal-title">添加血压</text></view>
  43. <view class="modal-inner">
  44. <view class="form-row">
  45. <text class="label">日期</text>
  46. <picker mode="date" :value="addDate" @change="onAddDateChange">
  47. <view class="picker-display">{{ addDateLabel }}</view>
  48. </picker>
  49. </view>
  50. <view class="form-row">
  51. <text class="label">收缩压 (S)</text>
  52. <input type="number" v-model.number="addSystolic" class="input" placeholder="收缩压" />
  53. </view>
  54. <view class="form-row">
  55. <text class="label">舒张压 (D)</text>
  56. <input type="number" v-model.number="addDiastolic" class="input" placeholder="舒张压" />
  57. </view>
  58. </view>
  59. <view class="ruler-wrap">
  60. <view class="ruler-row">
  61. <ScaleRuler v-if="showAdd" :min="80" :max="220" :step="1" :gutter="16" :initialValue="addSystolic ?? 120" @update="onSUpdate" @change="onSChange" />
  62. </view>
  63. <view class="ruler-row">
  64. <ScaleRuler v-if="showAdd" :min="40" :max="140" :step="1" :gutter="16" :initialValue="addDiastolic ?? 80" @update="onDUpdate" @change="onDChange" />
  65. </view>
  66. </view>
  67. <view class="fixed-footer">
  68. <button class="btn-primary btn-full" @click="confirmAdd">保存</button>
  69. </view>
  70. </view>
  71. </view>
  72. </view>
  73. <TabBar />
  74. </template>
  75. <script setup lang="ts">
  76. import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount, getCurrentInstance } from 'vue'
  77. import uCharts from '@qiun/ucharts'
  78. import CustomNav from '@/components/custom-nav.vue'
  79. import TabBar from '@/components/tab-bar.vue'
  80. import ScaleRuler from '@/components/scale-ruler.vue'
  81. type RecordItem = { id: string; date: string; s: number; d: number }
  82. // 当前展示年月
  83. const current = ref(new Date())
  84. const pickerValue = ref(formatPickerDate(current.value))
  85. // 明确的canvas尺寸(将由 getCanvasSize 初始化以匹配设备宽度)
  86. const canvasWidth = ref(700) // 初始值,会在 mounted 时覆盖
  87. const canvasHeight = ref(240)
  88. // 获取Canvas实际尺寸的函数 - 参考微信小程序示例使用固定尺寸
  89. function getCanvasSize(): Promise<{ width: number; height: number }> {
  90. return new Promise((resolve) => {
  91. // 使用固定尺寸,参考微信小程序示例
  92. const windowWidth = uni.getSystemInfoSync().windowWidth;
  93. const width = windowWidth; // 占满屏幕宽度
  94. const height = 240 / 750 * windowWidth; // 240rpx转换为px,与CSS高度匹配
  95. resolve({ width, height });
  96. });
  97. }
  98. function formatPickerDate(d: Date) {
  99. const y = d.getFullYear()
  100. const m = String(d.getMonth() + 1).padStart(2, '0')
  101. const day = String(d.getDate()).padStart(2, '0')
  102. return `${y}-${m}-${day}`
  103. }
  104. const displayYear = computed(() => current.value.getFullYear())
  105. const displayMonth = computed(() => current.value.getMonth() + 1)
  106. const records = ref<RecordItem[]>(generateMockRecords(current.value))
  107. function generateMockRecords(d: Date): RecordItem[] {
  108. const y = d.getFullYear()
  109. const m = d.getMonth()
  110. const arr: RecordItem[] = []
  111. const n = Math.floor(Math.random() * 7)
  112. for (let i = 0; i < n; i++) {
  113. const day = Math.max(1, Math.floor(Math.random() * 28) + 1)
  114. const date = new Date(y, m, day)
  115. const s = 100 + Math.floor(Math.random() * 40)
  116. const dval = 60 + Math.floor(Math.random() * 30)
  117. arr.push({
  118. id: `${y}${m}${i}${Date.now()}`,
  119. date: formatDisplayDate(date),
  120. s,
  121. d: dval
  122. })
  123. }
  124. return arr.sort((a, b) => (a.date < b.date ? 1 : -1))
  125. }
  126. // 将 records 聚合为每天一个点(取最新记录)
  127. function aggregateDaily(recordsArr: RecordItem[], year: number, month: number) {
  128. const map = new Map<number, RecordItem>()
  129. for (const r of recordsArr) {
  130. const parts = r.date.split('-')
  131. if (parts.length >= 3) {
  132. const y = parseInt(parts[0], 10)
  133. const m = parseInt(parts[1], 10) - 1
  134. const d = parseInt(parts[2], 10)
  135. if (y === year && m === month) {
  136. // 覆盖同一天,保留最新的(数组头部为最新)
  137. map.set(d, r)
  138. }
  139. }
  140. }
  141. // 返回按日索引的数组
  142. return map
  143. }
  144. function formatDisplayDate(d: Date) {
  145. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  146. }
  147. const averageSystolic = computed(() => {
  148. if (records.value.length === 0) return '--'
  149. const sum = records.value.reduce((s, r) => s + r.s, 0)
  150. return Math.round(sum / records.value.length)
  151. })
  152. const averageDiastolic = computed(() => {
  153. if (records.value.length === 0) return '--'
  154. const sum = records.value.reduce((s, r) => s + r.d, 0)
  155. return Math.round(sum / records.value.length)
  156. })
  157. function daysInMonth(year: number, month: number) {
  158. return new Date(year, month + 1, 0).getDate()
  159. }
  160. // Canvas / uCharts 绘图 - 修复版本
  161. const chartInstance = ref<any>(null)
  162. const vm = getCurrentInstance()
  163. let chartInitialized = false
  164. let chartBusy = false // 绘图锁,防止并发初始化/更新
  165. // 简化的图表绘制函数
  166. async function drawChart() {
  167. // 防止并发调用
  168. if (chartBusy) return
  169. chartBusy = true
  170. // 防止重复初始化(已初始化则更新数据)
  171. if (chartInitialized && chartInstance.value) {
  172. try {
  173. await updateChartData()
  174. } finally {
  175. chartBusy = false
  176. }
  177. return
  178. }
  179. // 清理旧实例
  180. if (chartInstance.value) {
  181. try {
  182. if (chartInstance.value.destroy) {
  183. chartInstance.value.destroy()
  184. }
  185. } catch (e) {
  186. console.warn('Destroy chart error:', e)
  187. }
  188. chartInstance.value = null
  189. }
  190. if (typeof uCharts === 'undefined') {
  191. console.warn('uCharts not available')
  192. return
  193. }
  194. // 动态获取 Canvas 容器的宽高 (单位: px)
  195. const size = await getCanvasSize();
  196. const cssWidth = size.width;
  197. const cssHeight = size.height;
  198. // 获取可靠的设备像素比 - 固定为1避免高分辨率设备上元素过大
  199. const pixelRatio = 1; // 关键修复:固定pixelRatio为1
  200. // 为避免 X 轴标签或绘图区域右侧溢出,保留右侧间距,让绘图区域略窄于 canvas
  201. const rightGap = Math.max(24, Math.round(cssWidth * 0.04)) // 最小 24px 或 4% 屏宽
  202. const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
  203. console.log('Canvas 尺寸与像素比:', { cssWidth, cssHeight, pixelRatio });
  204. const year = current.value.getFullYear()
  205. const month = current.value.getMonth()
  206. const days = daysInMonth(year, month)
  207. // 生成合理的categories - 只显示关键日期
  208. const categories: string[] = []
  209. const showLabelDays = []
  210. // 选择要显示的标签:1号、中间几天、最后一天
  211. if (days > 0) {
  212. showLabelDays.push(1) // 第1天
  213. if (days > 1) showLabelDays.push(days) // 最后一天
  214. // 中间添加2-3个关键点
  215. if (days > 7) showLabelDays.push(Math.ceil(days / 3))
  216. if (days > 14) showLabelDays.push(Math.ceil(days * 2 / 3))
  217. }
  218. for (let d = 1; d <= days; d++) {
  219. if (showLabelDays.includes(d)) {
  220. categories.push(`${d}日`)
  221. } else {
  222. categories.push('')
  223. }
  224. }
  225. // 只为有记录的天生成categories和data,避免将无数据天设为0
  226. const sData: number[] = []
  227. const dData: number[] = []
  228. const filteredCategories: string[] = []
  229. // 使用Map按日聚合,保留最新记录(records 数组头部为最新)
  230. const dayMap = new Map<number, RecordItem>()
  231. for (const r of records.value) {
  232. const parts = r.date.split('-')
  233. if (parts.length >= 3) {
  234. const y = parseInt(parts[0], 10)
  235. const m = parseInt(parts[1], 10) - 1
  236. const d = parseInt(parts[2], 10)
  237. if (y === year && m === month) {
  238. // 以最后遍历到的(数组顺序保证头部为最新)作为最终值
  239. dayMap.set(d, r)
  240. }
  241. }
  242. }
  243. // 将有数据的日期按日顺序输出
  244. const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b)
  245. for (const day of sortedDays) {
  246. const rec = dayMap.get(day)!
  247. filteredCategories.push(`${day}日`)
  248. sData.push(rec.s)
  249. dData.push(rec.d)
  250. }
  251. // 如果没有任何数据,则回退为整月空数据(避免 uCharts 抛错)
  252. const categoriesToUse = filteredCategories.length ? filteredCategories : categories
  253. // 计算合理的Y轴范围
  254. const allData = [...sData, ...dData]
  255. const validData = allData.filter(v => v > 0)
  256. const minVal = validData.length ? Math.floor(Math.min(...validData)) - 5 : 60
  257. const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 5 : 180
  258. const series = [
  259. {
  260. name: '收缩压',
  261. data: sData,
  262. color: '#ff6a00'
  263. },
  264. {
  265. name: '舒张压',
  266. data: dData,
  267. color: '#007aff'
  268. }
  269. ]
  270. // 获取canvas上下文
  271. let ctx: any = null
  272. try {
  273. if (typeof uni !== 'undefined' && typeof uni.createCanvasContext === 'function') {
  274. // 小程序环境:优先尝试传入组件实例
  275. try {
  276. ctx = vm?.proxy ? uni.createCanvasContext('bpChart', vm.proxy) : uni.createCanvasContext('bpChart')
  277. } catch (e) {
  278. // 再尝试不传vm
  279. try { ctx = uni.createCanvasContext('bpChart') } catch (err) { ctx = null }
  280. }
  281. }
  282. } catch (e) {
  283. ctx = null
  284. }
  285. // H5环境尝试使用DOM获取2D上下文
  286. if (!ctx && typeof document !== 'undefined') {
  287. try {
  288. // 重试逻辑:初次可能未渲染到 DOM
  289. let el: HTMLCanvasElement | null = null
  290. for (let attempt = 0; attempt < 3; attempt++) {
  291. el = document.getElementById('bpChart') as HTMLCanvasElement | null
  292. if (el) break
  293. // 短延迟后重试(非阻塞)
  294. await new Promise(r => setTimeout(r, 50))
  295. }
  296. if (el && el.getContext) {
  297. // Ensure canvas actual pixel size matches cssWidth * pixelRatio
  298. try {
  299. const physicalW = Math.floor(cssWidth * pixelRatio)
  300. const physicalH = Math.floor(cssHeight * pixelRatio)
  301. if (el.width !== physicalW || el.height !== physicalH) {
  302. el.width = physicalW
  303. el.height = physicalH
  304. // also adjust style to keep layout consistent
  305. el.style.width = cssWidth + 'px'
  306. el.style.height = cssHeight + 'px'
  307. }
  308. } catch (e) {
  309. console.warn('Set canvas physical size failed', e)
  310. }
  311. ctx = el.getContext('2d')
  312. }
  313. } catch (e) {
  314. ctx = null
  315. }
  316. }
  317. if (!ctx) {
  318. console.warn('Unable to obtain canvas context for uCharts. Ensure canvas-id matches and vm proxy is available on mini-program.')
  319. return
  320. }
  321. console.log('Canvas config:', {
  322. width: cssWidth,
  323. height: cssHeight,
  324. pixelRatio,
  325. categoriesLength: categories.length,
  326. sDataPoints: sData.length,
  327. dDataPoints: dData.length
  328. })
  329. // 简化的uCharts配置 - 关闭所有可能产生重叠的选项
  330. const config = {
  331. $this: vm?.proxy,
  332. canvasId: 'bpChart',
  333. context: ctx,
  334. type: 'line',
  335. fontSize: 10, // 全局字体大小,参考微信小程序示例
  336. categories: categoriesToUse,
  337. series: series,
  338. // 使用比 canvas 略窄的绘图宽度并设置 padding 来避免右边溢出
  339. width: chartWidth,
  340. padding: [10, rightGap + 8, 18, 10],
  341. height: cssHeight,
  342. pixelRatio: pixelRatio,
  343. background: 'transparent',
  344. animation: false, // 关闭动画避免干扰
  345. enableScroll: false,
  346. dataLabel: false, // 关键:关闭数据点标签
  347. legend: {
  348. show: true,
  349. position: 'top',
  350. float: 'right',
  351. fontSize: 10,
  352. spacing: 0
  353. },
  354. xAxis: {
  355. disableGrid: true, // 简化网格
  356. axisLine: true,
  357. axisLineColor: '#e0e0e0',
  358. fontColor: '#666666',
  359. fontSize: 10, // 进一步调小X轴字体
  360. boundaryGap: 'justify'
  361. },
  362. yAxis: {
  363. disableGrid: false,
  364. gridColor: '#f5f5f5',
  365. splitNumber: 4, // 减少分割数
  366. min: minVal,
  367. max: maxVal,
  368. axisLine: true,
  369. axisLineColor: '#e0e0e0',
  370. fontColor: '#666666',
  371. fontSize: 10, // 进一步调小Y轴字体
  372. format: (val: number) => val % 1 === 0 ? `${val}mmHg` : '' // 只显示整数值
  373. },
  374. extra: {
  375. line: {
  376. type: 'curve',
  377. width: 1, // 进一步调细线宽
  378. activeType: 'point', // 简化点样式
  379. point: {
  380. radius: 0.5, // 进一步调小数据点半径
  381. strokeWidth: 0.5 // 调小边框宽度
  382. }
  383. },
  384. tooltip: {
  385. showBox: false, // 关闭提示框避免重叠
  386. showCategory: false
  387. }
  388. }
  389. }
  390. try {
  391. // 在创建新实例前确保销毁旧实例
  392. if (chartInstance.value && chartInstance.value.destroy) {
  393. try { chartInstance.value.destroy() } catch (e) { console.warn('destroy before init failed', e) }
  394. chartInstance.value = null
  395. chartInitialized = false
  396. }
  397. chartInstance.value = new uCharts(config)
  398. chartInitialized = true
  399. console.log('uCharts initialized successfully')
  400. } catch (error) {
  401. console.error('uCharts init error:', error)
  402. chartInitialized = false
  403. }
  404. chartBusy = false
  405. }
  406. // 更新数据而不重新初始化
  407. async function updateChartData() {
  408. if (chartBusy) return
  409. chartBusy = true
  410. if (!chartInstance.value || !chartInitialized) {
  411. try {
  412. await drawChart()
  413. } finally {
  414. chartBusy = false
  415. }
  416. return
  417. }
  418. const year = current.value.getFullYear()
  419. const month = current.value.getMonth()
  420. const days = daysInMonth(year, month)
  421. const categories: string[] = []
  422. const showLabelDays = []
  423. if (days > 0) {
  424. showLabelDays.push(1)
  425. if (days > 1) showLabelDays.push(days)
  426. if (days > 7) showLabelDays.push(Math.ceil(days / 3))
  427. if (days > 14) showLabelDays.push(Math.ceil(days * 2 / 3))
  428. }
  429. for (let d = 1; d <= days; d++) {
  430. if (showLabelDays.includes(d)) {
  431. categories.push(`${d}日`)
  432. } else {
  433. categories.push('')
  434. }
  435. }
  436. // 只为有记录的天生成categories和data
  437. const sData: number[] = []
  438. const dData: number[] = []
  439. const filteredCategories: string[] = []
  440. const dayMap = new Map<number, RecordItem>()
  441. for (const r of records.value) {
  442. const parts = r.date.split('-')
  443. if (parts.length >= 3) {
  444. const y = parseInt(parts[0], 10)
  445. const m = parseInt(parts[1], 10) - 1
  446. const d = parseInt(parts[2], 10)
  447. if (y === year && m === month) {
  448. dayMap.set(d, r)
  449. }
  450. }
  451. }
  452. const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b)
  453. for (const day of sortedDays) {
  454. const rec = dayMap.get(day)!
  455. filteredCategories.push(`${day}日`)
  456. sData.push(rec.s)
  457. dData.push(rec.d)
  458. }
  459. const categoriesToUse = filteredCategories.length ? filteredCategories : categories
  460. const allData = [...sData, ...dData]
  461. const validData = allData.filter(v => v > 0)
  462. const minVal = validData.length ? Math.floor(Math.min(...validData)) - 5 : 60
  463. const maxVal = validData.length ? Math.ceil(Math.max(...validData)) + 5 : 180
  464. try {
  465. // 使用uCharts的更新方法,仅更新有数据的分类和序列
  466. // 若实例存在,先更新宽度/padding(如果有配置)以避免右侧溢出
  467. try {
  468. const size = await getCanvasSize()
  469. const cssWidth = size.width
  470. const rightGap = Math.max(24, Math.round(cssWidth * 0.04))
  471. const chartWidth = Math.max(cssWidth - rightGap, Math.round(cssWidth * 0.85))
  472. if (chartInstance.value.opts) {
  473. chartInstance.value.opts.width = chartWidth
  474. chartInstance.value.opts.padding = [10, rightGap + 8, 18, 10]
  475. }
  476. } catch (e) {
  477. // 忽略尺寸更新错误
  478. }
  479. chartInstance.value.updateData({
  480. categories: categoriesToUse,
  481. series: [
  482. {
  483. name: '收缩压',
  484. data: sData,
  485. color: '#ff6a00'
  486. },
  487. {
  488. name: '舒张压',
  489. data: dData,
  490. color: '#007aff'
  491. }
  492. ]
  493. })
  494. // 更新Y轴范围
  495. chartInstance.value.opts.yAxis.min = minVal
  496. chartInstance.value.opts.yAxis.max = maxVal
  497. } catch (error) {
  498. console.error('Update chart error:', error)
  499. // 如果更新失败,重新销毁并重建实例
  500. try {
  501. if (chartInstance.value && chartInstance.value.destroy) chartInstance.value.destroy()
  502. } catch (e) { console.warn('destroy on update failure failed', e) }
  503. chartInstance.value = null
  504. chartInitialized = false
  505. try {
  506. await drawChart()
  507. } catch (e) { console.error('re-init after update failure also failed', e) }
  508. }
  509. chartBusy = false
  510. }
  511. onMounted(() => {
  512. // 延迟确保DOM渲染完成
  513. setTimeout(async () => {
  514. await nextTick()
  515. // 由 getCanvasSize 计算并覆盖 canvasWidth/canvasHeight(确保模板样式和绘图一致)
  516. try {
  517. const size = await getCanvasSize()
  518. canvasWidth.value = size.width
  519. canvasHeight.value = size.height
  520. } catch (e) {
  521. console.warn('getCanvasSize failed on mounted', e)
  522. }
  523. await drawChart()
  524. }, 500)
  525. })
  526. // 简化监听,避免频繁重绘
  527. watch([() => current.value], async () => {
  528. setTimeout(async () => {
  529. await updateChartData()
  530. }, 100)
  531. })
  532. watch([() => records.value], async () => {
  533. setTimeout(async () => {
  534. await updateChartData()
  535. }, 100)
  536. }, { deep: true })
  537. onBeforeUnmount(() => {
  538. if (chartInstance.value && chartInstance.value.destroy) {
  539. try {
  540. chartInstance.value.destroy()
  541. } catch (e) {
  542. console.warn('uCharts destroy error:', e)
  543. }
  544. }
  545. chartInstance.value = null
  546. chartInitialized = false
  547. })
  548. // 强制重建图表(用于切换月份时彻底刷新,如同退出页面再进入)
  549. async function rebuildChart() {
  550. // 如果正在绘制,等一小会儿再销毁
  551. if (chartBusy) {
  552. // 等待最大 300ms,避免长时间阻塞
  553. await new Promise(r => setTimeout(r, 50))
  554. }
  555. try {
  556. if (chartInstance.value && chartInstance.value.destroy) {
  557. try { chartInstance.value.destroy() } catch (e) { console.warn('destroy in rebuildChart failed', e) }
  558. }
  559. } catch (e) {
  560. console.warn('rebuildChart destroy error', e)
  561. }
  562. chartInstance.value = null
  563. chartInitialized = false
  564. // 等待 DOM/Tick 稳定
  565. await nextTick()
  566. // 重新初始化
  567. try {
  568. await drawChart()
  569. } catch (e) {
  570. console.error('rebuildChart drawChart failed', e)
  571. }
  572. }
  573. // 其他函数保持不变
  574. async function prevMonth() {
  575. const d = new Date(current.value)
  576. d.setMonth(d.getMonth() - 1)
  577. current.value = d
  578. pickerValue.value = formatPickerDate(d)
  579. records.value = generateMockRecords(d)
  580. await rebuildChart()
  581. }
  582. async function nextMonth() {
  583. const d = new Date(current.value)
  584. d.setMonth(d.getMonth() + 1)
  585. current.value = d
  586. pickerValue.value = formatPickerDate(d)
  587. records.value = generateMockRecords(d)
  588. await rebuildChart()
  589. }
  590. async function onPickerChange(e: any) {
  591. const val = e?.detail?.value || e
  592. const parts = (val as string).split('-')
  593. if (parts.length >= 2) {
  594. const y = parseInt(parts[0], 10)
  595. const m = parseInt(parts[1], 10) - 1
  596. const d = new Date(y, m, 1)
  597. current.value = d
  598. pickerValue.value = formatPickerDate(d)
  599. records.value = generateMockRecords(d)
  600. await rebuildChart()
  601. }
  602. }
  603. // 添加逻辑保持不变
  604. const showAdd = ref(false)
  605. const addDate = ref(formatPickerDate(new Date()))
  606. const addDateLabel = ref(formatDisplayDate(new Date()))
  607. const addSystolic = ref<number | null>(null)
  608. const addDiastolic = ref<number | null>(null)
  609. function onSUpdate(v: number) { addSystolic.value = Math.round(v) }
  610. function onSChange(v: number) { addSystolic.value = Math.round(v) }
  611. function onDUpdate(v: number) { addDiastolic.value = Math.round(v) }
  612. function onDChange(v: number) { addDiastolic.value = Math.round(v) }
  613. function openAdd() {
  614. showAdd.value = true;
  615. if (!addSystolic.value) addSystolic.value = 120;
  616. if (!addDiastolic.value) addDiastolic.value = 80
  617. }
  618. function closeAdd() {
  619. showAdd.value = false;
  620. addSystolic.value = null;
  621. addDiastolic.value = null
  622. }
  623. function onAddDateChange(e: any) {
  624. const val = e?.detail?.value || e;
  625. addDate.value = val;
  626. addDateLabel.value = val.replace(/^(.{10}).*$/, '$1')
  627. }
  628. async function confirmAdd() {
  629. if (!addSystolic.value || !addDiastolic.value) {
  630. uni.showToast && uni.showToast({ title: '请输入血压值', icon: 'none' });
  631. return
  632. }
  633. const id = `user-${Date.now()}`
  634. const item: RecordItem = {
  635. id,
  636. date: addDateLabel.value,
  637. s: Math.round(addSystolic.value),
  638. d: Math.round(addDiastolic.value)
  639. }
  640. const parts = addDate.value.split('-')
  641. const addY = parseInt(parts[0], 10)
  642. const addM = parseInt(parts[1], 10) - 1
  643. if (addY === current.value.getFullYear() && addM === current.value.getMonth()) {
  644. records.value = [item, ...records.value]
  645. }
  646. uni.showToast && uni.showToast({ title: '已添加', icon: 'success' })
  647. closeAdd()
  648. // 新增记录后彻底重建图表,确保像退出再进入一样刷新
  649. try {
  650. await rebuildChart()
  651. } catch (e) {
  652. console.warn('rebuildChart after add failed', e)
  653. }
  654. }
  655. async function confirmDeleteRecord(id: string) {
  656. if (typeof uni !== 'undefined' && uni.showModal) {
  657. uni.showModal({
  658. title: '删除',
  659. content: '确认删除该条记录吗?',
  660. success: async (res: any) => {
  661. if (res.confirm) {
  662. records.value = records.value.filter(r => r.id !== id)
  663. try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
  664. }
  665. }
  666. })
  667. } else {
  668. records.value = records.value.filter(r => r.id !== id)
  669. try { await rebuildChart() } catch (e) { console.warn('rebuildChart after delete failed', e) }
  670. }
  671. }
  672. </script>
  673. <style scoped>
  674. .page {
  675. min-height: calc(100vh);
  676. padding-top: calc(var(--status-bar-height) + 44px);
  677. background: #f5f6f8;
  678. box-sizing: border-box
  679. }
  680. .header {
  681. padding: 20rpx 40rpx
  682. }
  683. .month-selector {
  684. display: flex;
  685. align-items: center;
  686. justify-content: center;
  687. gap: 12rpx
  688. }
  689. .month-label {
  690. font-size: 34rpx;
  691. color: #333
  692. }
  693. .btn {
  694. background: transparent;
  695. border: none;
  696. font-size: 36rpx;
  697. color: #666
  698. }
  699. .content {
  700. padding: 20rpx 24rpx 100rpx 24rpx
  701. }
  702. .chart-wrap {
  703. background: #fff;
  704. border-radius: 12rpx;
  705. padding: 24rpx;
  706. margin: 0 24rpx 20rpx 24rpx;
  707. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
  708. }
  709. .chart-header {
  710. font-size: 32rpx;
  711. color: #333;
  712. margin-bottom: 20rpx;
  713. font-weight: 600
  714. }
  715. /* 关键修复:确保canvas样式正确,参考微信小程序示例 */
  716. .chart-canvas {
  717. width: 750rpx;
  718. height: 240rpx;
  719. background-color: #FFFFFF;
  720. display: block;
  721. }
  722. .summary {
  723. padding: 20rpx;
  724. color: #666;
  725. font-size: 28rpx
  726. }
  727. .list {
  728. background: #fff;
  729. border-radius: 12rpx;
  730. padding: 10rpx;
  731. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03)
  732. }
  733. .empty {
  734. padding: 40rpx;
  735. text-align: center;
  736. color: #999
  737. }
  738. .list-item {
  739. display: flex;
  740. align-items: center;
  741. padding: 20rpx;
  742. border-bottom: 1rpx solid #f0f0f0
  743. }
  744. .list-item .date {
  745. color: #666
  746. }
  747. .list-item .value {
  748. color: #333;
  749. font-weight: 600;
  750. flex: 1;
  751. text-align: right
  752. }
  753. .btn-delete {
  754. width: 80rpx;
  755. height: 60rpx;
  756. min-width: 60rpx;
  757. min-height: 60rpx;
  758. display: inline-flex;
  759. align-items: center;
  760. justify-content: center;
  761. background: #fff0f0;
  762. color: #d9534f;
  763. border: 1rpx solid rgba(217,83,79,0.15);
  764. border-radius: 8rpx;
  765. margin-left: 30rpx
  766. }
  767. .fab {
  768. position: fixed;
  769. right: 28rpx;
  770. bottom: 160rpx;
  771. width: 110rpx;
  772. height: 110rpx;
  773. border-radius: 999px;
  774. background: linear-gradient(180deg, #ff7a00, #ff4a00);
  775. display: flex;
  776. align-items: center;
  777. justify-content: center;
  778. box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.2);
  779. z-index: 1200
  780. }
  781. .fab-inner {
  782. color: #fff;
  783. font-size: 56rpx;
  784. line-height: 56rpx
  785. }
  786. .modal {
  787. position: fixed;
  788. left: 0;
  789. right: 0;
  790. top: 0;
  791. bottom: 0;
  792. display: flex;
  793. align-items: flex-end;
  794. justify-content: center;
  795. z-index: 1300
  796. }
  797. .modal-backdrop {
  798. position: absolute;
  799. left: 0;
  800. right: 0;
  801. top: 0;
  802. bottom: 0;
  803. background: rgba(0, 0, 0, 0.4)
  804. }
  805. .modal-panel {
  806. position: relative;
  807. width: 100%;
  808. background: #fff;
  809. border-top-left-radius: 18rpx;
  810. border-top-right-radius: 18rpx;
  811. padding: 28rpx 24rpx 140rpx 24rpx;
  812. box-shadow: 0 -8rpx 30rpx rgba(0,0,0,0.12)
  813. }
  814. .modal-title {
  815. font-size: 56rpx;
  816. margin-block: 60rpx;
  817. color: #222;
  818. font-weight: 700;
  819. letter-spacing: 1rpx
  820. }
  821. .modal-inner {
  822. max-width: 70%;
  823. margin: 0 auto
  824. }
  825. .form-row {
  826. display: flex;
  827. align-items: center;
  828. justify-content: space-between;
  829. margin-bottom: 34rpx;
  830. padding: 14rpx 0;
  831. font-size: 32rpx
  832. }
  833. .input {
  834. width: 240rpx;
  835. text-align: right;
  836. padding: 16rpx;
  837. border-radius: 14rpx;
  838. border: 1rpx solid #eee;
  839. background: #fff7f0
  840. }
  841. .picker-display {
  842. color: #333
  843. }
  844. .btn-primary {
  845. background: #ff6a00;
  846. color: #fff;
  847. padding: 18rpx 22rpx;
  848. border-radius: 16rpx;
  849. text-align: center;
  850. width: 50%;
  851. box-shadow: 0 10rpx 28rpx rgba(255,106,0,0.18)
  852. }
  853. .drag-handle {
  854. width: 64rpx;
  855. height: 6rpx;
  856. background: rgba(0,0,0,0.08);
  857. border-radius: 999px;
  858. margin: 10rpx auto 14rpx auto
  859. }
  860. .modal-header {
  861. display: flex;
  862. align-items: center;
  863. justify-content: center;
  864. gap: 12rpx;
  865. margin-bottom: 6rpx
  866. }
  867. .label {
  868. color: #666
  869. }
  870. .ruler-wrap {
  871. margin: 12rpx 0
  872. }
  873. .ruler-row {
  874. margin-bottom: 8rpx
  875. }
  876. .fixed-footer {
  877. position: absolute;
  878. left: 0;
  879. right: 0;
  880. bottom: 40rpx;
  881. padding: 0 24rpx
  882. }
  883. .btn-full {
  884. width: 100%;
  885. padding: 18rpx;
  886. border-radius: 12rpx;
  887. }
  888. </style>