Jelajahi Sumber

feat(dan): DAN 报告详情页适配多维度(B4 等),后端补 danLevel

后端:
- getDetail result 补 danLevel 字段(B4 智维度 overallScore/danLevel 只在 result 表,
  parsed_items 无 overall 项,原前端拿不到等级)

前端 report-result.vue:
- 量纲自适应:bigFive=5分制、百分位/cognitive/overall=100制、其余(selfConcept/selfDriving等)=10制
  从 result.structuredAnalysis 构建 name->category 映射(blocks item 已丢 category)
- B4 无 score 块时用 result.overallScore 兜底为综合得分,无百分位则不显示百分位条
- Hero 增加 DAN 等级徽章(A/B/C)
- 告警仅对百分位制能力项(<50)生效,避免人格/自我概念误报
Xiaogang Liao 1 Minggu lalu
induk
melakukan
fed8fb48dc

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -671,6 +671,7 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
                 rm.put("childName", r.getChildName());
                 rm.put("assessmentDate", r.getAssessmentDate());
                 rm.put("overallScore", r.getOverallScore());
+                rm.put("danLevel", r.getDanLevel());
                 rm.put("attentionScore", r.getAttentionScore());
                 rm.put("focusScore", r.getFocusScore());
                 rm.put("memoryScore", r.getMemoryScore());

+ 114 - 59
cfc-frontend/pages/dan-assessment/report-result.vue

@@ -9,6 +9,7 @@
         <text class="hero-name">{{ childName }}</text>
         <view class="hero-dim-row">
           <text class="hero-dim-badge">{{ dimensionLabel }}</text>
+          <text class="hero-level-badge" v-if="level">等级 {{ level }}</text>
           <text class="hero-date">{{ dateText }}</text>
         </view>
       </view>
@@ -17,17 +18,19 @@
       <view class="hero-score-wrap" v-if="totalScore !== null">
         <view class="hero-score-col">
           <text class="hero-score-num">{{ totalScore }}</text>
-          <text class="hero-score-label">总得分</text>
-        </view>
-        <view class="hero-score-divider"></view>
-        <view class="hero-score-col">
-          <text class="hero-score-num hero-score-num-pct">{{ percentile }}<text class="hero-score-pct-unit">%</text></text>
-          <text class="hero-score-label">百分位</text>
+          <text class="hero-score-label">{{ percentile !== null ? '总得分' : '综合得分' }}</text>
         </view>
+        <template v-if="percentile !== null">
+          <view class="hero-score-divider"></view>
+          <view class="hero-score-col">
+            <text class="hero-score-num hero-score-num-pct">{{ percentile }}<text class="hero-score-pct-unit">%</text></text>
+            <text class="hero-score-label">百分位</text>
+          </view>
+        </template>
       </view>
 
       <!-- 百分位进度条:位置标记 -->
-      <view class="hero-pct-bar-wrap" v-if="totalScore !== null">
+      <view class="hero-pct-bar-wrap" v-if="percentile !== null">
         <view class="hero-pct-track">
           <view class="hero-pct-fill" :style="'width:' + percentile + '%'"></view>
           <view class="hero-pct-marker" :style="'left:' + percentile + '%'"></view>
@@ -44,7 +47,7 @@
       </view>
 
       <template v-else-if="hasContent">
-        <!-- ==================== 核心能力(认知类指标) ==================== -->
+        <!-- ==================== 核心能力(认知/综合能力类,百分位制) ==================== -->
         <view class="card" v-if="abilityBlock">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
@@ -61,13 +64,12 @@
             <view class="metric-track">
               <view class="metric-fill" :class="m.warn ? 'metric-fill-warn' : 'metric-fill-main'"
                     :style="'width:' + m.pct + '%'"></view>
-              <!-- 50% 参考线 -->
               <view class="metric-ref-line" style="left:50%"></view>
             </view>
           </view>
         </view>
 
-        <!-- ==================== 大五人格 ==================== -->
+        <!-- ==================== 大五人格(1-5 分制) ==================== -->
         <view class="card" v-if="bigFiveBlock">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
@@ -81,14 +83,13 @@
             </view>
             <view class="bf-track">
               <view class="bf-fill" :class="m.tone" :style="'width:' + m.pct + '%'"></view>
-              <!-- 3分平均线 -->
               <view class="bf-avg-line"></view>
             </view>
             <text class="bf-hint" v-if="m.hint">{{ m.hint }}</text>
           </view>
         </view>
 
-        <!-- ==================== 情绪状态 ==================== -->
+        <!-- ==================== 情绪状态(0-10 分制) ==================== -->
         <view class="card" v-if="emotionBlock">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
@@ -105,33 +106,37 @@
           </view>
         </view>
 
-        <!-- ==================== 其他指标(身高体重等,过滤噪音) ==================== -->
-        <view class="card" v-if="otherMetrics.length > 0">
+        <!-- ==================== 通用指标块(自我概念/自驱力等,量纲自适应) ==================== -->
+        <view class="card" v-for="(blk, bi) in genericBlocks" :key="'g' + bi">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
-            <text class="card-title">基本信息</text>
+            <text class="card-title">{{ blk.title }}</text>
           </view>
-          <view class="info-grid">
-            <view class="info-cell" v-for="(m, mi) in otherMetrics" :key="mi">
-              <text class="info-value">{{ m.value }}<text class="info-unit" v-if="m.unit">{{ m.unit }}</text></text>
-              <text class="info-name">{{ m.name }}</text>
+          <view class="metric-row" v-for="(m, mi) in blk.metrics" :key="mi">
+            <view class="metric-head">
+              <text class="metric-name">{{ m.name }}</text>
+              <view class="metric-value-wrap">
+                <text class="metric-warn-badge" v-if="m.warn">需关注</text>
+                <text class="metric-value" :class="m.warn ? 'metric-value-warn' : ''">{{ m.value }}</text>
+              </view>
+            </view>
+            <view class="metric-track">
+              <view class="metric-fill" :class="m.warn ? 'metric-fill-warn' : 'metric-fill-main'"
+                    :style="'width:' + m.pct + '%'"></view>
             </view>
           </view>
         </view>
 
-        <!-- ==================== 通用指标块(其他未分类维度,如执行功能/学习动机等) ==================== -->
-        <view class="card" v-for="(blk, bi) in genericBlocks" :key="'g' + bi">
+        <!-- ==================== 基本信息(身高体重等,过滤噪音) ==================== -->
+        <view class="card" v-if="otherMetrics.length > 0">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
-            <text class="card-title">{{ blk.title }}</text>
+            <text class="card-title">基本信息</text>
           </view>
-          <view class="metric-row" v-for="(m, mi) in blk.metrics" :key="mi">
-            <view class="metric-head">
-              <text class="metric-name">{{ m.name }}</text>
-              <text class="metric-value">{{ m.value }}</text>
-            </view>
-            <view class="metric-track" v-if="m.pct !== null">
-              <view class="metric-fill metric-fill-main" :style="'width:' + m.pct + '%'"></view>
+          <view class="info-grid">
+            <view class="info-cell" v-for="(m, mi) in otherMetrics" :key="mi">
+              <text class="info-value">{{ m.value }}<text class="info-unit" v-if="m.unit">{{ m.unit }}</text></text>
+              <text class="info-name">{{ m.name }}</text>
             </view>
           </view>
         </view>
@@ -176,6 +181,8 @@ import { getDanReportDetail } from '../../utils/api.js'
 
 // 噪音指标过滤(OCR 带出的非测评数据)
 var NOISE_NAME_KEYS = ['联系电话', '报告编码', '测试时间', '档案', '具体情况', '页', '公众号']
+// 百分位制 category(0-100)
+var PERCENT_CATEGORIES = ['overall', 'cognitive']
 
 export default {
   data() {
@@ -186,9 +193,11 @@ export default {
       dateText: '',
       dimension: '',
       reportType: '',
+      level: '',
       totalScore: null,
-      percentile: 0,
+      percentile: null,
       blocks: [],
+      catMap: {},
       adviceExpanded: false,
       adviceSourceTitle: '',
       adviceSourceText: '',
@@ -214,16 +223,17 @@ export default {
       if (this.reportType) base += ' · ' + this.reportType
       return base || 'DAN 测评'
     },
-    // 认知能力/核心能力块(标题含"能力"或"认知")
+    // 核心能力块(优先"认知能力",其次含"能力"的块)
     abilityBlock() {
+      var fallback = null
       for (var i = 0; i < this.blocks.length; i++) {
         var b = this.blocks[i]
-        if (b.type === 'indicator' && b.items && b.items.length &&
-            (String(b.title).indexOf('认知') >= 0 || String(b.title).indexOf('能力') >= 0)) {
-          return b
-        }
+        if (b.type !== 'indicator' || !b.items || !b.items.length) continue
+        var t = String(b.title || '')
+        if (t.indexOf('认知') >= 0) return b
+        if (t.indexOf('能力') >= 0 && !fallback) fallback = b
       }
-      return null
+      return fallback
     },
     abilityMetrics() {
       if (!this.abilityBlock) return []
@@ -232,9 +242,9 @@ export default {
         return !self.isNoise(it.name)
       }).map(function(it) {
         var v = parseFloat(it.value)
-        var pct = isNaN(v) ? 0 : Math.min(100, Math.max(0, v))
+        var pct = isNaN(v) ? 0 : self.clampPct(v)
         return {
-          name: it.name.replace('百分位', ''),
+          name: String(it.name).replace('百分位', ''),
           value: it.value,
           pct: pct,
           warn: !isNaN(v) && v < 50
@@ -262,7 +272,7 @@ export default {
           if (v >= 4) { tone = 'bf-high'; hint = '突出优势' }
           else if (v <= 2.5) { tone = 'bf-low'; hint = '低于同龄平均' }
         }
-        return { name: it.name, value: it.value, pct: pct, tone: tone, hint: hint }
+        return { name: it.name, value: it.value, pct: Math.round(pct), tone: tone, hint: hint }
       })
     },
     // 情绪状态块
@@ -277,15 +287,16 @@ export default {
     },
     emotionMetrics() {
       if (!this.emotionBlock) return []
+      var self = this
       return (this.emotionBlock.items || []).filter(function(it) {
-        return NOISE_NAME_KEYS.every(function(k) { return String(it.name).indexOf(k) < 0 })
+        return !self.isNoise(it.name)
       }).map(function(it) {
         var v = parseFloat(it.value)
         var pct = isNaN(v) ? 0 : Math.min(100, Math.max(0, v * 10))
-        return { name: it.name, value: it.value, pct: pct }
+        return { name: it.name, value: it.value, pct: Math.round(pct) }
       })
     },
-    // 其他指标("其他"类,过滤噪音,提取身高体重等)
+    // 其他指标("其他指标"块,过滤噪音,提取身高体重等)
     otherMetrics() {
       for (var i = 0; i < this.blocks.length; i++) {
         var b = this.blocks[i]
@@ -295,7 +306,7 @@ export default {
             return !self.isNoise(it.name)
           }).map(function(it) {
             var unit = ''
-            var name = it.name
+            var name = String(it.name)
             if (name.indexOf('身高') >= 0) { unit = 'cm' }
             if (name.indexOf('体重') >= 0) { unit = 'kg' }
             return { name: name, value: it.value, unit: unit }
@@ -304,7 +315,7 @@ export default {
       }
       return []
     },
-    // 通用指标块(除已专项渲染的 score/认知/人格/情绪/其他 之外的 indicator/text)
+    // 通用指标块(自我概念/自驱力/社会关系等未专项渲染的 indicator)
     genericBlocks() {
       var taken = []
       if (this.abilityBlock) taken.push(this.abilityBlock)
@@ -322,15 +333,11 @@ export default {
           metrics: (b.items || []).filter(function(it) {
             return !self.isNoise(it.name)
           }).map(function(it) {
-            var v = parseFloat(it.value)
-            var pct = null
-            if (!isNaN(v) && v >= 0 && v <= 100) pct = v
-            return { name: it.name, value: it.value, pct: pct }
+            return self.toMetric(it)
           })
         }
       }).filter(function(b) { return b.metrics.length > 0 })
     },
-    // 建议/评语(text 块优先,其次 result.growthSuggestions)
     adviceTitle() {
       return this.adviceSourceTitle || '成长建议'
     },
@@ -353,6 +360,28 @@ export default {
       }
       return false
     },
+    clampPct(v) {
+      return Math.round(Math.min(100, Math.max(0, v)))
+    },
+    // 依据 category / 名称推断量纲上限
+    resolveMax(name, category) {
+      if (name && String(name).indexOf('百分位') >= 0) return 100
+      if (category === 'bigFive') return 5
+      if (PERCENT_CATEGORIES.indexOf(category) >= 0) return 100
+      return 10
+    },
+    toMetric(it) {
+      var v = parseFloat(it.value)
+      if (isNaN(v)) {
+        return { name: it.name, value: it.value, pct: 0, warn: false }
+      }
+      var cat = this.catMap[it.name] || ''
+      var max = this.resolveMax(it.name, cat)
+      var pct = Math.min(100, Math.max(0, v / max * 100))
+      // 仅百分位制指标(A2 认知等)低于 50 才告警;人格/自我概念等非能力项不告警
+      var warn = (max === 100) && v < 50
+      return { name: it.name, value: it.value, pct: Math.round(pct), warn: warn }
+    },
     loadData() {
       var self = this
       this.loading = true
@@ -369,26 +398,46 @@ export default {
         self.dateText = dateRaw ? String(dateRaw).substring(0, 10) : ''
         self.dimension = data.dimension || ''
         self.reportType = data.reportType || ''
+        self.level = result.danLevel || ''
         self.blocks = (data.blocks || []).filter(function(b) {
           return b && ((b.items && b.items.length) || b.content)
         })
+
+        // 从 structuredAnalysis 构建 名称→category 映射(blocks item 已丢 category)
+        var catMap = {}
+        if (result.structuredAnalysis) {
+          try {
+            var arr = JSON.parse(result.structuredAnalysis)
+            for (var ai = 0; ai < arr.length; ai++) {
+              if (arr[ai] && arr[ai].name) catMap[arr[ai].name] = arr[ai].category || ''
+            }
+          } catch (e) {}
+        }
+        self.catMap = catMap
+
         // 提取总分/百分位(score 块)
         for (var i = 0; i < self.blocks.length; i++) {
           var b = self.blocks[i]
           if (b.type === 'score' && b.items) {
             for (var j = 0; j < b.items.length; j++) {
               var it = b.items[j]
-              if (String(it.label).indexOf('总') >= 0) {
-                self.totalScore = parseFloat(it.value)
-                if (isNaN(self.totalScore)) self.totalScore = null
-              } else if (String(it.label).indexOf('百分位') >= 0) {
-                var p = parseFloat(it.value)
-                self.percentile = isNaN(p) ? 0 : Math.min(100, Math.max(0, p))
+              var label = String(it.label)
+              if (label.indexOf('总') >= 0) {
+                var tv = parseFloat(it.value)
+                if (!isNaN(tv)) self.totalScore = tv
+              } else if (label.indexOf('百分位') >= 0) {
+                var pv = parseFloat(it.value)
+                if (!isNaN(pv)) self.percentile = Math.min(100, Math.max(0, pv))
               }
             }
           }
         }
-        // score 块本身不再单独渲染(已并入 Hero)
+        // 兜底:无 score 块(如 B4 智维度)时,用 result.overallScore 作综合得分
+        if (self.totalScore === null && result.overallScore !== null && result.overallScore !== undefined) {
+          var ov = parseFloat(result.overallScore)
+          if (!isNaN(ov)) self.totalScore = ov
+        }
+
         // text 块:成长建议优先(非空 content),评语单独
         for (var k = 0; k < self.blocks.length; k++) {
           var tb = self.blocks[k]
@@ -432,12 +481,18 @@ export default {
 
 .hero-top { position: relative; z-index: 1; }
 .hero-name { font-size: 44rpx; font-weight: 700; color: #FFFFFF; display: block; }
-.hero-dim-row { display: flex; align-items: center; margin-top: 14rpx; }
+.hero-dim-row { display: flex; align-items: center; margin-top: 14rpx; flex-wrap: wrap; }
 .hero-dim-badge {
   font-size: 22rpx; color: #FFFFFF;
   background: rgba(255,255,255,0.22);
   border-radius: 24rpx; padding: 6rpx 18rpx;
-  margin-right: 16rpx;
+  margin-right: 14rpx;
+}
+.hero-level-badge {
+  font-size: 22rpx; color: #7C2D12; font-weight: 600;
+  background: linear-gradient(135deg, #FDE68A, #FBBF24);
+  border-radius: 24rpx; padding: 6rpx 18rpx;
+  margin-right: 14rpx;
 }
 .hero-date { font-size: 24rpx; color: rgba(255,255,255,0.75); }
 
@@ -488,7 +543,7 @@ export default {
 .card-title { font-size: 30rpx; font-weight: 600; color: #1F2937; }
 .card-title-sub { font-size: 22rpx; color: #9CA3AF; margin-left: 14rpx; }
 
-/* ==================== 核心能力指标 ==================== */
+/* ==================== 指标条 ==================== */
 .metric-row { margin-bottom: 26rpx; }
 .metric-row:last-child { margin-bottom: 4rpx; }
 .metric-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }