blood-glucose.vue 27 KB

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