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

fix(frontend): 修复 ArticleBookshelf relatedDimensions 数组拆分崩溃与 switchTab 非 TabBar 路由系统错误

- ArticleBookshelf.vue: relatedDimensions 兼容字符串/JSON字符串/数组三种格式, 修复 split is not a function
- custom-tab-bar/index.vue: 富 tab 指向 wealth/index, tab 顺序对齐原生 tabBar, nativeTabPages 只含原生 tab 页
- login.vue / action / wisdom / index-home: 清理 stale nativeTabs 列表, 避免对非 tabBar 页面调用 switchTab 触发 routeDone 路由错误
- user-edit.vue: 规划师入驻提交后 redirectTo 分包页而非 switchTab
- utils/format.js + DimensionActivities.vue: 新增 iOS 安全 parseDate 统一日期解析
liaoxg 3 месяцев назад
Родитель
Сommit
e0efee105e

+ 2 - 1
AGENTS.md

@@ -42,7 +42,7 @@
 - **认证:** JWT Bearer Token,`Authorization` Header;`JwtInterceptor` 拦截 `/api/**`(8个公开路径除外)
 - **DI:** `@Resource` 替代 `@Autowired`,字段名匹配默认 Bean Name
 - **角色控制:** 控制器内手动检查 `@RequestAttribute("role")`
-- **小程序限制:** 禁止可选链 `?.`(用 `&&` 替代)、禁止 CSS Grid(用 flexbox)、禁止 `:key` 表达式(用方法调用代替)
+- **小程序限制:** 禁止可选链 `?.`(用 `&&` 替代)、禁止 CSS Grid(用 flexbox)、禁止 `:key` 表达式(用方法调用代替)、禁止直接 `new Date(string)`(用 `parseDate()`)
 - **新增Controller/Service:** 检查类名是否与其他包重名(Spring Bean Name 冲突)
 - **新增路由:** 运行 `grep -rn '@Mapping' cfc-backend/src/.../controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u` 检查重复
 
@@ -221,6 +221,7 @@ curl -X POST http://localhost:9082/api/migration/run
 - 禁止可选链 `?.`(用 `&&` 替代)
 - 禁止 CSS Grid(用 flexbox)
 - 禁止 `:key` 表达式(如 `:key="item.id || item.circleId"`,微信小程序模板编译器不支持带运算符的 `:key` 绑定 → 改用方法调用 `:key="getItemKey(item)"`)
+- 禁止直接 `new Date(string)` 解析日期字符串(部分 iOS 的 JavaScriptCore 不支持空格分隔格式如 `"2026-08-22 14:00"` → 统一用 `utils/format.js` 的 `parseDate()`)
 - Vue 2 Options API,禁止 Composition API
 
 ## 子模块详细规范

+ 1 - 0
cfc-frontend/AGENTS.md

@@ -97,6 +97,7 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 - **NEVER** 在 `:class` 绑定中调用方法(如 `:class="getStatusClass(item)"`),微信小程序模板编译器不支持带参数的方法调用 → 改用内联表达式(如 `:class="'status-' + item._cssClass"`)或计算属性
 - **NEVER** 在 CSS 类名、选择器中使用中文(如 `.val-偏高`、`.badge-低风险`),微信小程序 wxss 编译器不支持中文类名 → 使用英文(如 `.val-high`、`.badge-low`),数据中的中文状态通过 `_cssClass` 字段映射
 - **NEVER** 在 `:key` 中使用表达式(如 `:key="item.id || item.circleId"`),微信小程序模板编译器不支持带运算符的 `:key` 绑定 → 改用方法调用(如 `:key="getItemKey(item)"`,在 methods 中定义 `getItemKey(item) { return item.id || item.circleId }`)
+- **NEVER** 直接用 `new Date(string)` 解析日期字符串。部分 iOS(JavaScriptCore)的 `new Date()` 只支持 `yyyy/MM/dd`、`yyyy/MM/dd HH:mm:ss`、`yyyy-MM-dd`、`yyyy-MM-ddTHH:mm:ss`、`yyyy-MM-ddTHH:mm:ss+HH:mm` 格式,后端常见的 `"2026-08-22 14:00"`(空格分隔的 MySQL DATETIME 风格)和 Java 的 `"Sat Aug 22 14:00:00 CST 2026"` 会解析失败返回 Invalid Date → 一律使用 `utils/format.js` 的 `parseDate()`,它会自动归一化上述两种格式并返回 `Date|null`(如 `var d = parseDate(item.startTime); if (!d) return`)
 
 ## UNIQUE FEATURES
 

+ 29 - 12
cfc-frontend/components/ArticleBookshelf.vue

@@ -172,13 +172,33 @@ export default {
     }
   },
   methods: {
-    getBookColor: function(article, idx) {
-      if (article && article.relatedDimensions) {
-        var dims = article.relatedDimensions.split(',')
-        var firstDim = (dims[0] || '').trim().toLowerCase()
-        if (DIMENSION_COLORS[firstDim]) {
-          return DIMENSION_COLORS[firstDim].bg
+    // 提取首个维度 code(兼容字符串 "mind,emotion" / JSON 字符串 / 数组 ['mind','emotion'])
+    getFirstDimension: function(article) {
+      if (!article || !article.relatedDimensions) return ''
+      var raw = article.relatedDimensions
+      var first = ''
+      if (typeof raw === 'string') {
+        var trimmed = raw.trim()
+        if (trimmed.indexOf('[') === 0) {
+          try {
+            var parsed = JSON.parse(trimmed)
+            if (Array.isArray(parsed)) first = parsed[0] || ''
+          } catch (e) {
+            first = ''
+          }
+        }
+        if (!first) {
+          first = trimmed.split(',')[0] || ''
         }
+      } else if (Array.isArray(raw)) {
+        first = raw[0] || ''
+      }
+      return String(first).trim().toLowerCase()
+    },
+    getBookColor: function(article, idx) {
+      var firstDim = this.getFirstDimension(article)
+      if (firstDim && DIMENSION_COLORS[firstDim]) {
+        return DIMENSION_COLORS[firstDim].bg
       }
       if (article && article.dimensionCode && DIMENSION_COLORS[article.dimensionCode]) {
         return DIMENSION_COLORS[article.dimensionCode].bg
@@ -186,12 +206,9 @@ export default {
       return FALLBACK_GRADIENTS[idx % FALLBACK_GRADIENTS.length].bg
     },
     getTextColor: function(article, idx) {
-      if (article && article.relatedDimensions) {
-        var dims = article.relatedDimensions.split(',')
-        var firstDim = (dims[0] || '').trim().toLowerCase()
-        if (DIMENSION_COLORS[firstDim]) {
-          return DIMENSION_COLORS[firstDim].text
-        }
+      var firstDim = this.getFirstDimension(article)
+      if (firstDim && DIMENSION_COLORS[firstDim]) {
+        return DIMENSION_COLORS[firstDim].text
       }
       if (article && article.dimensionCode && DIMENSION_COLORS[article.dimensionCode]) {
         return DIMENSION_COLORS[article.dimensionCode].text

+ 3 - 18
cfc-frontend/components/DimensionActivities.vue

@@ -87,6 +87,7 @@
 
 <script>
 import config from '@/config.js'
+import { parseDate } from '@/utils/format.js'
 
 var DIMENSION_MAP = {
   body: { name: '身', color: '#FF8C42', icon: '🌏' },
@@ -157,30 +158,14 @@ export default {
     },
     formatTime: function(timeStr) {
       if (!timeStr) return '待定'
-      var d = this._parseDate(timeStr)
-      if (!d || isNaN(d.getTime())) return timeStr
+      var d = parseDate(timeStr)
+      if (!d) return timeStr
       var month = d.getMonth() + 1
       var day = d.getDate()
       var hour = d.getHours()
       var minute = d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes()
       return month + '月' + day + '日 ' + hour + ':' + minute
     },
-    // iOS 不兼容 Java "EEE MMM dd HH:mm:ss zzz yyyy" 格式,需提前归一化
-    _parseDate: function(timeStr) {
-      if (!timeStr) return null
-      // Java 格式: "Sat Aug 22 14:00:00 CST 2026" — iOS 不支持,必须先于 new Date() 处理
-      var javaRe = /^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\w{3}\s+(\d{4})$/
-      var m = timeStr.match(javaRe)
-      if (m) {
-        var months = {Jan:'01',Feb:'02',Mar:'03',Apr:'04',May:'05',Jun:'06',Jul:'07',Aug:'08',Sep:'09',Oct:'10',Nov:'11',Dec:'12'}
-        var iso = m[4] + '/' + (months[m[1]] || '01') + '/' + (m[2].length === 1 ? '0' + m[2] : m[2]) + ' ' + m[3]
-        return new Date(iso)
-      }
-      // 标准格式尝试(iOS 支持 yyyy/MM/dd、yyyy-MM-dd、ISO 8601 等)
-      var d = new Date(timeStr)
-      if (!isNaN(d.getTime())) return d
-      return null
-    },
     formatPriceWithSymbol: function(price) {
       if (price == null) return '免费'
       return '¥' + (Number(price) / 100).toFixed(2)

+ 4 - 3
cfc-frontend/custom-tab-bar/index.vue

@@ -45,10 +45,10 @@ export default {
       currentIndex: 0,
       normalTabs: [
         { text: '行', pagePath: '/pages/index-home/index', icon: '/static/tab-action.png', selectedIcon: '/static/tab-action-active.png' },
+        { text: '心', pagePath: '/pages/mind/index', icon: '/static/tab-mind.png', selectedIcon: '/static/tab-mind-active.png' },
         { text: '身', pagePath: '/pages/body/index', icon: '/static/tab-body.png', selectedIcon: '/static/tab-body-active.png' },
         { text: '智', pagePath: '/pages/wisdom/index', icon: '/static/tab-wisdom.png', selectedIcon: '/static/tab-wisdom-active.png' },
-        { text: '心', pagePath: '/pages/mind/index', icon: '/static/tab-mind.png', selectedIcon: '/static/tab-mind-active.png' },
-        { text: '富', pagePath: '/pages/profile/profile', icon: '/static/tab-wealth.png', selectedIcon: '/static/tab-wealth-active.png' }
+        { text: '富', pagePath: '/pages/wealth/index', icon: '/static/tab-wealth.png', selectedIcon: '/static/tab-wealth-active.png' }
       ],
       teacherTabs: [
         { text: '首页', pagePath: '/pages/teacher/index', icon: '/static/tab-action.png', selectedIcon: '/static/tab-action-active.png' },
@@ -92,7 +92,8 @@ export default {
     switchTab(item, index) {
       this.currentIndex = index
       // 原生TabBar页使用switchTab,非Tab页使用redirectTo(比reLaunch轻量)
-      const nativeTabPages = ['/pages/index-home/index', '/pages/body/index', '/pages/wisdom/index', '/pages/mind/index', '/pages/profile/profile']
+      // 注意:switchTab 只能跳转 pages.json 中 tabBar.list 注册的页面,否则会触发路由系统错误
+      const nativeTabPages = ['/pages/index-home/index', '/pages/mind/index', '/pages/body/index', '/pages/wisdom/index', '/pages/wealth/index']
       if (nativeTabPages.includes(item.pagePath)) {
         uni.switchTab({ url: item.pagePath })
       } else {

+ 1 - 1
cfc-frontend/pages/action/index.vue

@@ -478,7 +478,7 @@ export default {
         return
       }
       if (item.page) {
-        var nativeTabs = ['/pages/tasks/tasks', '/pages/index-home/index', '/pages/body/index', '/pages/mind/index', '/pages/action/index', '/pages/profile/profile']
+        var nativeTabs = ['/pages/index-home/index', '/pages/body/index', '/pages/mind/index', '/pages/wisdom/index', '/pages/wealth/index']
         if (nativeTabs.indexOf(item.page) !== -1) {
           uni.switchTab({ url: item.page })
         } else {

+ 1 - 1
cfc-frontend/pages/index-home/index.vue

@@ -786,7 +786,7 @@ export default {
       uni.navigateTo({ url: '/pages/article-center/index?dimension=action' })
     },
     onFuncClick: function(item) {
-      var nativeTabs = ['/pages/tasks/tasks', '/pages/index-home/index', '/pages/body/index', '/pages/mind/index', '/pages/wisdom/index', '/pages/wealth/index']
+      var nativeTabs = ['/pages/index-home/index', '/pages/body/index', '/pages/mind/index', '/pages/wisdom/index', '/pages/wealth/index']
       if (nativeTabs.indexOf(item.page) !== -1) {
         uni.switchTab({ url: item.page })
       } else {

+ 2 - 1
cfc-frontend/pages/login/login.vue

@@ -261,7 +261,8 @@ export default {
         const redirect = this.redirectUrl
         this.redirectUrl = ''
         // 注意: redirectTo 不支持跳转 tabBar 页面,必须用 switchTab
-        var tabBarPages = ['/pages/index-home/index', '/pages/body/index', '/pages/wisdom/index', '/pages/mind/index', '/pages/profile/profile']
+        // 只有 pages.json tabBar.list 注册的页面才能用 switchTab
+        var tabBarPages = ['/pages/index-home/index', '/pages/mind/index', '/pages/body/index', '/pages/wisdom/index', '/pages/wealth/index']
         if (tabBarPages.indexOf(redirect) !== -1) {
           uni.switchTab({ url: redirect })
         } else {

+ 2 - 1
cfc-frontend/pages/user-edit/user-edit.vue

@@ -534,7 +534,8 @@ export default {
 				})
 				uni.showToast({ title: '提交成功,请等待审核' })
 				setTimeout(() => {
-					uni.switchTab({
+					// parent-index 是分包页面(非 TabBar 页),switchTab 会触发路由系统错误,改用 redirectTo
+					uni.redirectTo({
 						url: '/pages/home-pages/parent-index'
 					})
 				}, 1500)

+ 1 - 1
cfc-frontend/pages/wisdom/index.vue

@@ -376,7 +376,7 @@ export default {
         return
       }
       if (item.page) {
-        var nativeTabs = ['/pages/tasks/tasks', '/pages/index-home/index', '/pages/body/index', '/pages/mind/index', '/pages/action/index', '/pages/wisdom/index', '/pages/profile/profile']
+        var nativeTabs = ['/pages/index-home/index', '/pages/body/index', '/pages/mind/index', '/pages/wisdom/index', '/pages/wealth/index']
         if (nativeTabs.indexOf(item.page) !== -1) {
           uni.switchTab({ url: item.page })
         } else {

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

@@ -44,3 +44,46 @@ export function formatScore(value, fixed) {
   if (value == null || value === undefined) return '--'
   return (value / 100).toFixed(fixed)
 }
+
+/**
+ * iOS 安全日期解析(微信小程序专用)
+ *
+ * 部分 iOS(JavaScriptCore)的 `new Date(string)` 只支持以下格式:
+ *   yyyy/MM/dd、yyyy/MM/dd HH:mm:ss、yyyy-MM-dd、
+ *   yyyy-MM-ddTHH:mm:ss、yyyy-MM-ddTHH:mm:ss+HH:mm
+ *
+ * 后端常见的 "yyyy-MM-dd HH:mm"(空格分隔,MySQL DATETIME 风格)
+ * 以及 Java 的 "Sat Aug 22 14:00:00 CST 2026" 在 iOS 下会解析失败
+ * 返回 Invalid Date。所有日期字符串解析必须经此函数,禁止直接 new Date(str)。
+ *
+ * @param {string|number|Date} str 日期字符串、时间戳或 Date 对象
+ * @returns {Date|null} 解析失败返回 null
+ */
+export function parseDate(str) {
+  if (str === null || str === undefined || str === '') return null
+  // 已是 Date 对象或时间戳,直接使用
+  if (typeof str === 'number') {
+    var dNum = new Date(str)
+    return isNaN(dNum.getTime()) ? null : dNum
+  }
+  if (typeof str === 'object' && str.getTime) return str
+  // Java 格式: "Sat Aug 22 14:00:00 CST 2026" — iOS 不支持,手动归一化
+  var javaRe = /^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\w{3}\s+(\d{4})$/
+  var m = String(str).match(javaRe)
+  if (m) {
+    var months = {Jan:'01',Feb:'02',Mar:'03',Apr:'04',May:'05',Jun:'06',Jul:'07',Aug:'08',Sep:'09',Oct:'10',Nov:'11',Dec:'12'}
+    var jDay = m[2].length === 1 ? '0' + m[2] : m[2]
+    return new Date(m[4] + '/' + (months[m[1]] || '01') + '/' + jDay + ' ' + m[3])
+  }
+  var s = String(str)
+  // MySQL DATETIME 风格 "yyyy-MM-dd HH:mm[:ss]"(或 "/" 分隔的 "yyyy/MM/dd HH:mm"):
+  // 空格分隔格式 iOS 不支持,统一归一化为 iOS 支持的 "yyyy/MM/dd HH:mm:ss"
+  var spaceDtRe = /^(\d{4})[/-](\d{2})[/-](\d{2}) (\d{2}:\d{2})(:\d{2})?$/
+  var sm = s.match(spaceDtRe)
+  if (sm) {
+    s = sm[1] + '/' + sm[2] + '/' + sm[3] + ' ' + sm[4] + (sm[5] || ':00')
+  }
+  // 其余标准格式原样解析(iOS 支持 yyyy/MM/dd、yyyy-MM-dd、ISO 8601 等)
+  var d = new Date(s)
+  return isNaN(d.getTime()) ? null : d
+}