blood-pressure.vue 28 KB

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