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

feat(dimension): P1-T1 健康页雷达图改统一维度注册中心配置驱动

- 新增 cfc-frontend/composables/useDimensionRegistry.js:
  封装 GET /api/dimension-registry/tree,模块级缓存+ready 标志,
  提供 getRadarConfig(tabCode) 按 tab→根 映射取 children,
  解析 legacyAlias JSON 首元素为 scoreField,导出 isRegistryReady。
- 改造 health-main/index.vue:currentDimensions/currentScores 改为
  遍历注册中心配置驱动取维度/标签/分数(body7/wisdom7/mind10/action5/wealth4,
  标签与改造前一致),并保留硬编码兜底(过渡,后端未上线/接口异常时页面不崩)。
- 不 demo 块/后端/表结构一律不动。
Agnes Agent 1 неделя назад
Родитель
Сommit
a180546a08
2 измененных файлов с 333 добавлено и 64 удалено
  1. 159 0
      cfc-frontend/composables/useDimensionRegistry.js
  2. 174 64
      cfc-frontend/pages/health-main/index.vue

+ 159 - 0
cfc-frontend/composables/useDimensionRegistry.js

@@ -0,0 +1,159 @@
+import config from '@/config.js'
+
+/**
+ * useDimensionRegistry - 统一维度注册中心(前端只读封装)
+ *
+ * 健康页 5 张雷达图(身体 / 认知 / 心理 / 社会性 / 财富)原前端硬编码维度清单与取分字段,
+ * 现改为从统一维度注册中心 `/api/dimension-registry/tree` 配置驱动取数画图
+ * (落实产品要求「健康页面的雷达图也是按这个配置取数据画图」)。
+ *
+ * 设计要点:
+ * 1. 模块级缓存:首次请求成功后整棵树缓存,tab 切换不再重复请求。
+ * 2. ready 标志:请求成功或失败后均置位,供页面安全兜底(后端未上线 / 接口异常 → 回落硬编码)。
+ * 3. 失败不抛错:ready=false、返回空,页面降级,不阻断渲染。
+ * 4. 兼容 uni-app 硬约束:不使用可选链 `?.`、new Date(string)、CSS Grid。
+ *
+ * 契约(已与后端 DimensionNode / seed 对齐):
+ *   GET /api/dimension-registry/tree → Result<List<DimensionNode>>
+ *   DimensionNode: { id, code, name, parentId, nodeKind, sourceSystem, legacyAlias, wuxingRuleRef, children }
+ *   legacyAlias 是 JSON 数组字符串,如 '["opennessScore"]',取首个元素作为分数字段名 scoreField。
+ */
+
+// 模块级缓存:跨组件 / 跨 tab 复用,避免重复请求
+var _cache = {
+  tree: null, // DimensionNode[](5 个根,各带 children)
+  ready: false, // 首次请求是否已结束(成功或失败均置位)
+  pending: null // 进行中的 Promise(防并发重复请求)
+}
+
+// tab(code) → 注册中心根 code 映射
+// body→body,cognitive→wisdom,mental→mind,relationship→action,wealth→wealth
+var TAB_TO_ROOT = {
+  body: 'body',
+  cognitive: 'wisdom',
+  mental: 'mind',
+  relationship: 'action',
+  wealth: 'wealth'
+}
+
+// 财富子维度在「成员(儿童)」语境下使用更贴近儿童的文案(与改造前字面量一致)
+var WEALTH_CHILD_LABEL = {
+  'wealth.skill': '学业能力',
+  'wealth.income': '积分积累'
+}
+
+/**
+ * 解析 legacyAlias:JSON 数组字符串,取首个元素作为分数字段名 scoreField。
+ * 例:'["opennessScore"]' → 'opennessScore';'["driveAutonomy"]' → 'driveAutonomy'
+ * @param {string} alias
+ * @returns {string}
+ */
+function parseLegacyAlias(alias) {
+  if (!alias || typeof alias !== 'string') return ''
+  var arr = null
+  try {
+    arr = JSON.parse(alias)
+  } catch (e) {
+    return ''
+  }
+  if (!Array.isArray(arr) || arr.length === 0) return ''
+  return typeof arr[0] === 'string' ? arr[0] : ''
+}
+
+/**
+ * 触发(或复用)注册中心树拉取。幂等:已尝试过则直接 resolve,不重复请求。
+ * @returns {Promise<void>}
+ */
+function ensureDimensionRegistry() {
+  if (_cache.ready) {
+    return Promise.resolve()
+  }
+  if (_cache.pending) {
+    return _cache.pending
+  }
+  _cache.pending = new Promise(function(resolve) {
+    var token = uni.getStorageSync('token')
+    var base = (config && config.API_BASE_URL) ? config.API_BASE_URL : ''
+    var url = base + '/api/dimension-registry/tree'
+    uni.request({
+      url: url,
+      method: 'GET',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        try {
+          var data = res && res.data
+          // Result<List<DimensionNode>> → data.data
+          var list = (data && data.code === 200 && data.data) ? data.data : []
+          if (Array.isArray(list) && list.length > 0) {
+            _cache.tree = list
+          }
+        } catch (e) {
+          // 解析异常:保持 tree 为空,回落兜底
+          _cache.tree = null
+        }
+        // 无论成功 / 空数据 / 解析异常,均标记尝试结束(不抛错)
+        _cache.ready = true
+        resolve()
+      },
+      fail: function() {
+        // 网络 / 接口异常:不抛错,回落硬编码兜底
+        _cache.ready = true
+        resolve()
+      }
+    })
+  })
+  // 请求结束后清空进行中引用(tree / ready 仍保留)
+  _cache.pending.then(function() { _cache.pending = null })
+  return _cache.pending
+}
+
+/**
+ * 注册中心树是否就绪(成功取到数据)。
+ * @returns {boolean}
+ */
+function isRegistryReady() {
+  return !!_cache.tree && _cache.tree.length > 0
+}
+
+/**
+ * 取某 tab 的雷达配置:[{ label, code, scoreField }]
+ *  - label:注册中心节点 name(维度中文名)
+ *  - code:节点 code(body 类为 body.growth,用于 bodyData 按 dimension 字段匹配)
+ *  - scoreField:legacyAlias 首元素(取分读取器字段名)
+ * @param {string} tabCode
+ * @returns {Array}
+ */
+function getRadarConfig(tabCode) {
+  var rootCode = TAB_TO_ROOT[tabCode]
+  if (!rootCode || !_cache.tree || !Array.isArray(_cache.tree)) return []
+  var root = null
+  for (var i = 0; i < _cache.tree.length; i++) {
+    var node = _cache.tree[i]
+    if (node && node.code === rootCode) {
+      root = node
+      break
+    }
+  }
+  if (!root || !root.children || !root.children.length) return []
+  var config = []
+  for (var j = 0; j < root.children.length; j++) {
+    var child = root.children[j]
+    if (!child) continue
+    config.push({
+      label: child.name || '',
+      code: child.code || '',
+      scoreField: parseLegacyAlias(child.legacyAlias)
+    })
+  }
+  return config
+}
+
+export {
+  ensureDimensionRegistry,
+  getRadarConfig,
+  isRegistryReady,
+  WEALTH_CHILD_LABEL
+}

+ 174 - 64
cfc-frontend/pages/health-main/index.vue

@@ -262,6 +262,7 @@ import RadarChart from '../../components/RadarChart.vue'
 import ArticleFeed from '../../components/ArticleFeed.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import { parseDate } from '../../utils/format.js'
+import { ensureDimensionRegistry, getRadarConfig, isRegistryReady, WEALTH_CHILD_LABEL } from '../../composables/useDimensionRegistry.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import shareMixin from '../../components/share-mixin.js'
 
@@ -324,7 +325,10 @@ export default {
       childrenList: [],
       relationshipEmpty: false,
       // 登录浮层打开标志(打开期间隐藏演示雷达,避免原生 canvas 盖在浮层之上)
-      loginSheetVisible: false
+      loginSheetVisible: false,
+      // 统一维度注册中心是否就绪(成功取到 tree 后由 ensureDimensionRegistry 置位)
+      // 未就绪时主页雷达回落硬编码兜底,保证后端未上线 / 接口异常时页面不崩
+      registryReady: false
     }
   },
   computed: {
@@ -337,23 +341,23 @@ export default {
       return tab ? tab.color : BrandColors.body
     },
     currentDimensions: function() {
-      if (this.currentTab === 'cognitive') {
-        return [{ label:'记忆力',key:'memory'},{ label:'注意力',key:'attention'},{ label:'逻辑推理',key:'logic'},{ label:'空间想象',key:'spatial'},{ label:'感知能力',key:'perception'},{ label:'专注力',key:'focus'},{ label:'加工速度',key:'processingSpeed' }]
-      }
-      if (this.currentTab === 'mental') {
-        return [{ label:'开放性',key:'openness'},{ label:'尽责性',key:'conscientiousness'},{ label:'外向性',key:'extraversion'},{ label:'宜人性',key:'agreeableness'},{ label:'神经质',key:'neuroticism'},{ label:'自主性',key:'driveAutonomy'},{ label:'胜任感',key:'driveCompetence'},{ label:'归属感',key:'driveRelatedness'},{ label:'情绪稳定性',key:'emotionStability'},{ label:'自我概念',key:'selfConceptOverall' }]
-      }
-      if (this.currentTab === 'relationship') {
-        return [{ label:'亲子信任',key:'parentTrust'},{ label:'亲子亲密度',key:'parentIntimacy'},{ label:'沟通质量',key:'communication'},{ label:'关系健康度',key:'healthStatus'},{ label:'最近互动',key:'lastInteraction' }]
-      }
-      if (this.currentTab === 'wealth') {
+      // 配置驱动:从统一维度注册中心 tree 取该 tab 根节点的 children 作为维度清单
+      var config = getRadarConfig(this.currentTab)
+      if (this.registryReady && config && config.length > 0) {
         var isChild = !!uni.getStorageSync('currentChildId')
-        if (isChild) {
-          return [{ label:'学业能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'积分积累',key:'income'},{ label:'财务素养',key:'fx' }]
-        }
-        return [{ label:'个人能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'CF值积累',key:'income'},{ label:'财务素养',key:'fx' }]
+        var self = this
+        return config.map(function(c) {
+          var label = c.label
+          // 财富子维度在「成员(儿童)」语境下使用更贴近儿童的文案(与改造前字面量一致)
+          if (self.currentTab === 'wealth' && isChild && WEALTH_CHILD_LABEL[c.code]) {
+            label = WEALTH_CHILD_LABEL[c.code]
+          }
+          // label 供 RadarChart 画维度名;key 供(若有)点击透传;code 保留注册中心维度码
+          return { label: label, key: c.code, code: c.code }
+        })
       }
-      return [{ label:'生长发育',key:'growth'},{ label:'睡眠质量',key:'sleep'},{ label:'视力健康',key:'vision'},{ label:'免疫力',key:'immunity'},{ label:'营养均衡',key:'nutrition'},{ label:'肠胃健康',key:'gut'},{ label:'运动活力',key:'exercise' }]
+      // 过渡兜底:注册中心未就绪或某根无子维 → 回落现有硬编码清单(后端未上线 / 接口异常时页面不崩)
+      return this._fallbackDimensions(this.currentTab)
     },
     currentScores: function() {
       if (this.chartIsEmpty) {
@@ -361,55 +365,16 @@ export default {
         for (var i = 0; i < dims.length; i++) scores.push(0)
         return scores
       }
-      if (this.currentTab === 'cognitive') {
-        return [
-          this._cognitiveScore('memoryScore'),
-          this._cognitiveScore('attentionScore'),
-          this._cognitiveScore('logicScore'),
-          this._cognitiveScore('spatialScore'),
-          this._cognitiveScore('perceptionScore'),
-          this._cognitiveScore('focusScore'),
-          this._cognitiveScore('processingSpeedScore')
-        ]
-      }
-      if (this.currentTab === 'mental') {
-        return [
-          this._emiScore('opennessScore'),
-          this._emiScore('conscientiousnessScore'),
-          this._emiScore('extraversionScore'),
-          this._emiScore('agreeablenessScore'),
-          this._emiScore('neuroticismScore'),
-          this._emiScore('driveAutonomy'),
-          this._emiScore('driveCompetence'),
-          this._emiScore('driveRelatedness'),
-          this._emiScore('emotionStability'),
-          this._emiScore('selfConceptOverall')
-        ]
-      }
-      if (this.currentTab === 'relationship') {
-        return [
-          this._relScore('trustScore'),
-          this._relScore('intimacyScore'),
-          this._relScore('communicationScore'),
-          this._relScore('totalScore'),
-          0
-        ]
-      }
-      if (this.currentTab === 'wealth') {
-        var d = this.wealthDetail
-        if (!d) return [0, 0, 0, 0]
-        return [d.wealthSkill || 0, d.wealthAltruism || 0, d.wealthIncome || 0, d.wealthFx || 0]
-      }
-      // body: 从 API 真实维度数据取分
-      if (this.bodyData && this.bodyData.dimensions && this.bodyData.dimensions.length > 0) {
-        return this.bodyData.dimensions.map(function(d) { return Number(d.score) || 0 })
-      }
-      // body: fallback to demo
-      if (this.reports.length > 0) {
-        var latest = this.reports[0]
-        if (latest.overallScore) return [72, 68, 75, 80, 70, 78, 82]
+      // 配置驱动:与 currentDimensions 同源同序遍历注册中心配置,按 scoreField 取分
+      var config = getRadarConfig(this.currentTab)
+      if (this.registryReady && config && config.length > 0) {
+        var self = this
+        return config.map(function(c) {
+          return self._readRegistryScore(self.currentTab, c.code, c.scoreField)
+        })
       }
-      return [72, 68, 75, 80, 70, 78, 82]
+      // 过渡兜底:注册中心未就绪 → 回落现有硬编码取分逻辑(与改造前一致)
+      return this._fallbackScores(this.currentTab)
     },
     currentAvgScores: function() {
       if (this.chartIsEmpty) return []
@@ -470,6 +435,13 @@ export default {
       this.loadHealthData()
       this.loadPlanList()
       this.loadPendingSurveys()
+      // 触发统一维度注册中心树拉取(主页雷达配置驱动取数;失败则回落硬编码兜底)
+      var self = this
+      ensureDimensionRegistry().then(function() {
+        self.registryReady = isRegistryReady()
+      }).catch(function() {
+        self.registryReady = false
+      })
     } else {
       this.pendingSurveys = []
     }
@@ -499,6 +471,144 @@ export default {
       var v = this.energySandbox[key]
       return (typeof v === 'number' && v > 0) ? v : 0
     },
+    /**
+     * 过渡兜底:返回某 tab 的硬编码维度清单(与改造前字面量一致)。
+     * 仅当注册中心未就绪时使用,保证后端未上线 / 接口异常时页面不崩。
+     * @param {string} tab
+     * @returns {Array<{label:string,key:string}>}
+     */
+    _fallbackDimensions: function(tab) {
+      if (tab === 'cognitive') {
+        return [{ label:'记忆力',key:'memory'},{ label:'注意力',key:'attention'},{ label:'逻辑推理',key:'logic'},{ label:'空间想象',key:'spatial'},{ label:'感知能力',key:'perception'},{ label:'专注力',key:'focus'},{ label:'加工速度',key:'processingSpeed' }]
+      }
+      if (tab === 'mental') {
+        return [{ label:'开放性',key:'openness'},{ label:'尽责性',key:'conscientiousness'},{ label:'外向性',key:'extraversion'},{ label:'宜人性',key:'agreeableness'},{ label:'神经质',key:'neuroticism'},{ label:'自主性',key:'driveAutonomy'},{ label:'胜任感',key:'driveCompetence'},{ label:'归属感',key:'driveRelatedness'},{ label:'情绪稳定性',key:'emotionStability'},{ label:'自我概念',key:'selfConceptOverall' }]
+      }
+      if (tab === 'relationship') {
+        return [{ label:'亲子信任',key:'parentTrust'},{ label:'亲子亲密度',key:'parentIntimacy'},{ label:'沟通质量',key:'communication'},{ label:'关系健康度',key:'healthStatus'},{ label:'最近互动',key:'lastInteraction' }]
+      }
+      if (tab === 'wealth') {
+        var isChild = !!uni.getStorageSync('currentChildId')
+        if (isChild) {
+          return [{ label:'学业能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'积分积累',key:'income'},{ label:'财务素养',key:'fx' }]
+        }
+        return [{ label:'个人能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'CF值积累',key:'income'},{ label:'财务素养',key:'fx' }]
+      }
+      return [{ label:'生长发育',key:'growth'},{ label:'睡眠质量',key:'sleep'},{ label:'视力健康',key:'vision'},{ label:'免疫力',key:'immunity'},{ label:'营养均衡',key:'nutrition'},{ label:'肠胃健康',key:'gut'},{ label:'运动活力',key:'exercise' }]
+    },
+    /**
+     * 过渡兜底:返回某 tab 的硬编码取分(与改造前字面量一致)。
+     * @param {string} tab
+     * @returns {Array<number>}
+     */
+    _fallbackScores: function(tab) {
+      if (tab === 'cognitive') {
+        return [
+          this._cognitiveScore('memoryScore'),
+          this._cognitiveScore('attentionScore'),
+          this._cognitiveScore('logicScore'),
+          this._cognitiveScore('spatialScore'),
+          this._cognitiveScore('perceptionScore'),
+          this._cognitiveScore('focusScore'),
+          this._cognitiveScore('processingSpeedScore')
+        ]
+      }
+      if (tab === 'mental') {
+        return [
+          this._emiScore('opennessScore'),
+          this._emiScore('conscientiousnessScore'),
+          this._emiScore('extraversionScore'),
+          this._emiScore('agreeablenessScore'),
+          this._emiScore('neuroticismScore'),
+          this._emiScore('driveAutonomy'),
+          this._emiScore('driveCompetence'),
+          this._emiScore('driveRelatedness'),
+          this._emiScore('emotionStability'),
+          this._emiScore('selfConceptOverall')
+        ]
+      }
+      if (tab === 'relationship') {
+        return [
+          this._relScore('trustScore'),
+          this._relScore('intimacyScore'),
+          this._relScore('communicationScore'),
+          this._relScore('totalScore'),
+          0
+        ]
+      }
+      if (tab === 'wealth') {
+        var d = this.wealthDetail
+        if (!d) return [0, 0, 0, 0]
+        return [d.wealthSkill || 0, d.wealthAltruism || 0, d.wealthIncome || 0, d.wealthFx || 0]
+      }
+      // body: 从 API 真实维度数据取分
+      if (this.bodyData && this.bodyData.dimensions && this.bodyData.dimensions.length > 0) {
+        return this.bodyData.dimensions.map(function(dItem) { return Number(dItem.score) || 0 })
+      }
+      // body: fallback to demo
+      if (this.reports.length > 0) {
+        var latest = this.reports[0]
+        if (latest.overallScore) return [72, 68, 75, 80, 70, 78, 82]
+      }
+      return [72, 68, 75, 80, 70, 78, 82]
+    },
+    /**
+     * 配置驱动取分:按 tab 与注册中心节点的 scoreField / code 从对应分数包取分。
+     * 包缺失或值非数字 → 0(保持现有零填充行为)。与改造前取分口径逐一对齐。
+     * @param {string} tab
+     * @param {string} code 注册中心节点 code(body 类为 body.growth)
+     * @param {string} scoreField legacyAlias 首元素(取分字段名)
+     * @returns {number}
+     */
+    _readRegistryScore: function(tab, code, scoreField) {
+      if (tab === 'body') {
+        // body 维度按 dimension 字段匹配:兼容两种格式——
+        // ① 当前后端 /api/dimension/overview 仍返回 legacy 码(growth/sleep/...,见 DimensionScoreServiceImpl.DIMENSIONS),
+        //    此时与 legacyAlias 首元素(scoreField)匹配;
+        // ② 后续后端迁移为统一码(body.growth/...)后,与注册中心节点 code 匹配。
+        var dims = this.bodyData && this.bodyData.dimensions
+        if (!dims || !dims.length) return 0
+        for (var i = 0; i < dims.length; i++) {
+          var dItem = dims[i]
+          if (dItem && (dItem.dimension === code || dItem.dimension === scoreField)) {
+            var v = Number(dItem.score)
+            return (typeof v === 'number' && !isNaN(v)) ? v : 0
+          }
+        }
+        return 0
+      }
+      if (tab === 'cognitive') {
+        if (!this.cognitiveData) return 0
+        var cv = this.cognitiveData[scoreField]
+        return (typeof cv === 'number' && cv > 0) ? cv : 0
+      }
+      if (tab === 'mental') {
+        if (!this.emiData) return 0
+        var mv = this.emiData[scoreField]
+        return (typeof mv === 'number' && mv > 0) ? mv : 0
+      }
+      if (tab === 'relationship') {
+        // 最近互动是日期字段,恒为 0(与改造前字面量 0 一致)
+        if (scoreField === 'lastInteractionAt') return 0
+        if (!this.relationshipData || this.relationshipData.length === 0) return 0
+        var total = 0, count = 0
+        for (var r = 0; r < this.relationshipData.length; r++) {
+          var item = this.relationshipData[r]
+          if (item && item[scoreField] != null) {
+            total += Number(item[scoreField])
+            count++
+          }
+        }
+        return count > 0 ? Math.round(total / count) : 0
+      }
+      if (tab === 'wealth') {
+        var wd = this.wealthDetail
+        if (!wd) return 0
+        var wv = wd[scoreField]
+        return (typeof wv === 'number' && wv > 0) ? wv : 0
+      }
+      return 0
+    },
     switchTab: function(code) { this.currentTab = code },
     goPage: function(url) { uni.navigateTo({ url: url }) },
     // 登录浮层打开/关闭(隐藏演示雷达,规避原生 canvas 层级高于浮层的问题)