Просмотр исходного кода

fix(growth-main): 今日成长打卡判定优先业务日期,修复 iOS 上 createdAt 带毫秒 ISO 解析失败导致打卡不显示

根因:loadProgressItems 的饮食判定用 createdAt || recordDate(createdAt 优先),
而后端 Jackson 输出的 createdAt 形如 2026-10-30T13:13:32.000+08:00(带毫秒+时区偏移),
iOS JavaScriptCore 只支持不带毫秒的 ISO(parseDate 注释记录),new Date() 返回 Invalid Date
→ formatDateKey 返回空串 → 与 todayDate 不匹配 → 今日成长「饮食」显示未打卡、完成数为 0。

修复:
1. utils/format.js 新增 recordDateOf(record):业务日期字段优先
   (recordDate > startTime > sleepTime > checkinDate > createdAt),返回 yyyy-MM-dd;
   createdAt 仅为兜底且经 parseDate 归一化(去掉 .SSS 毫秒段,iOS 可解析)。
2. parseDate 增加对带毫秒+时区偏移 ISO(Jackson 默认输出格式)的去毫秒处理。
3. growth-main/index.vue 饮食/运动/睡眠/心情 4 处完成判定统一改用 recordDateOf。

测试:__tests__/utils/format.test.js 新增 recordDateOf 5 用例(补打优先/兜底/运动睡眠心情/空值/毫秒ISO),
format.test.js 26 个全过;全量 jest 无新增失败(3 套件 4 用例为既有失败,与本改动无关)。
Xiaogang Liao 1 неделя назад
Родитель
Сommit
cb99934248

+ 50 - 0
cfc-frontend/__tests__/utils/format.test.js

@@ -160,4 +160,54 @@ describe('format.js', () => {
       expect(format.parseDate('2026-13-45')).toBeNull()
     })
   })
+  // --------------- recordDateOf ---------------
+  // 业务日期字段优先:判定"今日是否打卡"应看业务日期(recordDate/startTime/sleepTime/checkinDate),
+  // 而非 createdAt。createdAt 是记录写入时间(补打时=今天,业务日期=补打日),且后端 Jackson 输出的
+  // createdAt 形如 "2026-10-30T13:13:32.000+08:00"(带毫秒+时区偏移),iOS JavaScriptCore 无法解析
+  // (parseDate 注释:只支持不带毫秒的 yyyy-MM-ddTHH:mm:ss+HH:mm)。recordDate/checkinDate 为纯日期,
+  // 所有环境都能解析。
+  describe('recordDateOf', () => {
+    test('prefers recordDate over createdAt (补打:recordDate=昨天, createdAt=今天 → 昨天)', () => {
+      // 补打场景:recordDate 是实际用餐日(昨天),createdAt 是记录创建时间(今天)。
+      // 若 createdAt 优先会误判"今天已打卡";正确应取业务日期 recordDate。
+      expect(format.recordDateOf({
+        recordDate: '2026-10-29',
+        createdAt: '2026-10-30T13:13:32.000+08:00'
+      })).toBe('2026-10-29')
+    })
+
+    test('falls back to createdAt when business date field missing', () => {
+      expect(format.recordDateOf({
+        createdAt: '2026-10-30T13:13:32.000+08:00'
+      })).toBe('2026-10-30')
+    })
+
+    test('prefers startTime/sleepTime/checkinDate (运动/睡眠/心情业务字段优先)', () => {
+      expect(format.recordDateOf({
+        startTime: '2026-10-30T06:00:00.000+08:00',
+        createdAt: '2026-10-31T00:00:00.000+08:00'
+      })).toBe('2026-10-30')
+      expect(format.recordDateOf({
+        sleepTime: '2026-10-30T22:00:00.000+08:00',
+        createdAt: '2026-10-31T00:00:00.000+08:00'
+      })).toBe('2026-10-30')
+      expect(format.recordDateOf({
+        checkinDate: '2026-10-30',
+        createdAt: '2026-10-31T00:00:00.000+08:00'
+      })).toBe('2026-10-30')
+    })
+
+    test('returns empty string for null/empty record', () => {
+      expect(format.recordDateOf(null)).toBe('')
+      expect(format.recordDateOf({})).toBe('')
+    })
+
+    test('parses ISO 8601 with milliseconds + timezone offset (后端 Jackson 默认输出)', () => {
+      // 后端 Date 序列化真实格式:2026-10-30T13:13:32.000+08:00。
+      // recordDateOf 对纯 createdAt 也应能正确解析出日期。
+      expect(format.recordDateOf({
+        createdAt: '2026-10-30T13:13:32.000+08:00'
+      })).toBe('2026-10-30')
+    })
+  })
 })

+ 5 - 5
cfc-frontend/pages/growth-main/index.vue

@@ -274,7 +274,7 @@ import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import { healthCheckinList, getHealthMealList, getGrowthRecommendations, getExerciseRecords, getSleepRecords, getEmotionCheckinList, getCheckinList, getTodayTasks, reportAppEnter } from '../../utils/api.js'
-import { parseDate } from '../../utils/format.js'
+import { parseDate, recordDateOf } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import shareMixin from '../../components/share-mixin.js'
 export default {
@@ -424,7 +424,7 @@ export default {
         for (var di = 0; di < dList.length; di++) {
           var dr = dList[di]
           if (!dr) continue
-          if (self.formatDateKey(dr.createdAt || dr.recordDate) === self.todayDate) dietDone = true
+          if (recordDateOf(dr) === self.todayDate) dietDone = true
         }
       } catch(e) {}
       items.push({ key: 'diet', icon: '🍽️', label: '饮食', done: dietDone, actionText: '补录' })
@@ -436,7 +436,7 @@ export default {
         for (var ei = 0; ei < eList.length; ei++) {
           var er = eList[ei]
           if (!er) continue
-          if (self.formatDateKey(er.startTime || er.createdAt) === self.todayDate) exerciseDone = true
+          if (recordDateOf(er) === self.todayDate) exerciseDone = true
         }
       } catch(e) {}
       items.push({ key: 'exercise', icon: '🏃', label: '运动', done: exerciseDone, actionText: '记录' })
@@ -448,7 +448,7 @@ export default {
         for (var si = 0; si < sList.length; si++) {
           var sr = sList[si]
           if (!sr) continue
-          if (self.formatDateKey(sr.sleepTime || sr.createdAt) === self.todayDate) sleepDone = true
+          if (recordDateOf(sr) === self.todayDate) sleepDone = true
         }
       } catch(e) {}
       items.push({ key: 'sleep', icon: '😴', label: '睡眠', done: sleepDone, actionText: '记录' })
@@ -461,7 +461,7 @@ export default {
         for (var mi = 0; mi < mList.length; mi++) {
           var mr = mList[mi]
           if (!mr) continue
-          if (self.formatDateKey(mr.checkinDate || mr.createdAt) === self.todayDate) emotionDone = true
+          if (recordDateOf(mr) === self.todayDate) emotionDone = true
         }
       } catch(e) {}
       items.push({ key: 'emotion', icon: '😊', label: '心情', done: emotionDone, actionText: '记录' })

+ 36 - 0
cfc-frontend/utils/format.js

@@ -83,6 +83,14 @@ export function parseDate(str) {
   if (sm) {
     s = sm[1] + '/' + sm[2] + '/' + sm[3] + ' ' + sm[4] + (sm[5] || ':00')
   }
+  // ISO 8601 带毫秒+时区偏移:"2026-10-30T13:13:32.000+08:00"(后端 Jackson 默认输出)。
+  // iOS JavaScriptCore 只支持不带毫秒的 yyyy-MM-ddTHH:mm:ss+HH:mm,带 .SSS 的会 Invalid Date,
+  // 故先去掉毫秒段再交给 new Date()。
+  var millisIsoRe = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.\d{1,3}(Z|[+-]\d{2}:\d{2})$/
+  var mm = s.match(millisIsoRe)
+  if (mm) {
+    s = mm[1] + mm[2]
+  }
   // 其余标准格式原样解析(iOS 支持 yyyy/MM/dd、yyyy-MM-dd、ISO 8601 等)
   var d = new Date(s)
   return isNaN(d.getTime()) ? null : d
@@ -115,3 +123,31 @@ export function formatDateTime(dateStr) {
     ('0' + d.getMinutes()).slice(-2) + ':' +
     ('0' + d.getSeconds()).slice(-2)
 }
+
+
+/**
+ * 记录的业务日期(yyyy-MM-dd):判定"今日是否打卡"应看业务日期而非 createdAt。
+ *
+ * 优先级:recordDate(饮食补打)> startTime(运动)> sleepTime(睡眠)> checkinDate(心情/记账)> createdAt。
+ * createdAt 是记录写入时间(补打时=今天,业务日期=补打日),且后端 Jackson 输出的
+ * createdAt 形如 "2026-10-30T13:13:32.000+08:00"(带毫秒+偏移),iOS JavaScriptCore 无法解析;
+ * recordDate/checkinDate 为纯日期(yyyy-MM-dd),所有环境均可解析。
+ *
+ * @param {object|null} record 记录对象(含业务日期字段与 createdAt)
+ * @returns {string} yyyy-MM-dd;无有效日期返回 ''
+ */
+export function recordDateOf(record) {
+  if (!record) return ''
+  var keys = ['recordDate', 'startTime', 'sleepTime', 'checkinDate', 'createdAt']
+  for (var i = 0; i < keys.length; i++) {
+    var v = record[keys[i]]
+    if (v === null || v === undefined || v === '') continue
+    var d = parseDate(v)
+    if (!d) continue
+    var y = d.getFullYear()
+    var mo = ('0' + (d.getMonth() + 1)).slice(-2)
+    var da = ('0' + d.getDate()).slice(-2)
+    return y + '-' + mo + '-' + da
+  }
+  return ''
+}