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

feat(T04): 指标多期展示第一段收尾(报告详情对照/趋势入口/bugfix/降级)

D12=B 第一段:报告详情对照 + 趋势入口 + bugfix + 降级
(新时间轴页属第二段,须过 L1/L2/L3 gate 后才可上线)

前端(§6.6 红线:只消费后端 trendCode,禁止按 delta 正负自判改善/恶化):
- report-detail:合并键改 definitionId(definitionId→item + name→definitionId 双索引)、
  徽标由 trendCode+movement 推导、pending 文案调整、分组「较上次 ±N」、指标行「N 期 ›」趋势入口
- IndicatorChangeRow:comparable===false 独立分支(恒灰无箭头)、change==null 中性兜底、
  displayLine 为空时隐藏对照行
- IndicatorIncompleteBanner:visible 判定顺序修正(pc===0&&tc===0 提到 pc===tc 之前)
- indicator-curve:补 IndicatorIncompleteBanner 注册/数据字段/赋值,空态改「暂无该指标数据」
- indicator-timeline:补 getPeriodKey(模板引用但缺失)、headChange 空值防护
- indicator-quicklook:change 缺失时中性兜底「暂无对比」
- body-detail:/api/health/indicators 未下发 trendCode → 颜色恒中性灰、箭头仅表方向;
  缺 definitionId 时 toast 明示原因
- TrendChart(pages/health/components):_buildLayout 布局复用、缺失期/NaN 断线、pointTap 点选
- utils/api.js:getIndicatorTimeline 委托 getIndicatorTrend(同 URL 唯一 request 入口)
- components/TrendChart.vue:孤立「暂无趋势数据」→「暂无该指标数据」(P0 红线)

后端:
- IndicatorChangeService.batchResolveHistory:上一期 + 历史期数一次查询,严禁 N+1
- IndicatorTimelineService.timeline():totalReportCount/olderReportCount 提到观测为空判断之前;
  每个请求 definitionId 都返回 item,无观测 → chartMode='empty'(Q17 保留)

验证:mvn clean compile BUILD SUCCESS;
IndicatorTimelineServiceTest 9 / HealthReportServiceIndicatorsTest 3 /
IndicatorChangeServiceSemanticTest 20 / IndicatorControllerTest 2 / IndicatorTrendServiceTest 9 全通过;
verify-vue-compile 390/390 PASS;audit-mp-antipatterns ERROR 0;audit-duplicate-api-calls 0 error。
Agnes Agent 2 недель назад
Родитель
Сommit
18cd1e87e7

+ 44 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorChangeService.java

@@ -121,6 +121,30 @@ public class IndicatorChangeService {
      */
     public Map<Long, IndicatorValue> batchResolve(Long subjectId, String sourceType,
                                                    java.sql.Date currentReportDate) {
+        Map<Long, List<IndicatorValue>> byDef = batchResolveHistory(subjectId, sourceType, currentReportDate);
+        Map<Long, IndicatorValue> previous = new HashMap<>();
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            List<IndicatorValue> sorted = e.getValue();   // 已按 report_date 升序
+            previous.put(e.getKey(), sorted.get(sorted.size() - 1));
+        }
+        return previous;
+    }
+
+    /**
+     * 批量历史观测(1 次查询,同时支撑「上一期」与「历史有效期数」,严禁 N+1)。
+     *
+     * <p>一次性拉该 (subjectId, sourceType) 下所有「report_date 严格小于本期 且 numericValue 有效」的观测,
+     * 按 definitionId 分组(组内按 report_date 升序)。调用方据此同时得到:</p>
+     * <ul>
+     *   <li>previous —— 组内最后一行(最晚一期),即上一期观测</li>
+     *   <li>历史有效期数 —— 组内 distinct report_date 的个数(报告详情接口的 pointsCount 基数)</li>
+     * </ul>
+     * <p>两个信息来自同一次查询,避免各自再查一次。</p>
+     *
+     * @return definitionId → 历史观测列表(升序);无历史则空 Map
+     */
+    public Map<Long, List<IndicatorValue>> batchResolveHistory(Long subjectId, String sourceType,
+                                                                java.sql.Date currentReportDate) {
         if (subjectId == null || currentReportDate == null) {
             return Collections.emptyMap();
         }
@@ -135,15 +159,29 @@ public class IndicatorChangeService {
         if (all == null || all.isEmpty()) {
             return Collections.emptyMap();
         }
-        Map<Long, List<IndicatorValue>> byDef = all.stream()
+        return all.stream()
                 .filter(v -> v.getDefinitionId() != null)
                 .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
-        Map<Long, IndicatorValue> previous = new HashMap<>();
-        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
-            List<IndicatorValue> sorted = e.getValue();   // 已按 report_date 升序
-            previous.put(e.getKey(), sorted.get(sorted.size() - 1));
+    }
+
+    /**
+     * 批量历史观测(限定指标集合版本)。仍是 1 次查询,内存过滤 defIds。
+     */
+    public Map<Long, List<IndicatorValue>> batchResolveHistory(Long subjectId, String sourceType,
+                                                                Collection<Long> definitionIds,
+                                                                java.sql.Date currentReportDate) {
+        Map<Long, List<IndicatorValue>> all = batchResolveHistory(subjectId, sourceType, currentReportDate);
+        if (definitionIds == null || definitionIds.isEmpty()) {
+            return all;
         }
-        return previous;
+        Map<Long, List<IndicatorValue>> filtered = new HashMap<>();
+        for (Long defId : definitionIds) {
+            List<IndicatorValue> rows = all.get(defId);
+            if (rows != null) {
+                filtered.put(defId, rows);
+            }
+        }
+        return filtered;
     }
 
     /**

+ 45 - 23
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTimelineService.java

@@ -20,6 +20,7 @@ import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -104,9 +105,8 @@ public class IndicatorTimelineService {
             }
         }
         List<IndicatorValue> rows = valueMapper.selectList(qw);
-        if (rows == null || rows.isEmpty()) {
-            data.put("items", items);
-            return data;
+        if (rows == null) {
+            rows = new ArrayList<>();
         }
 
         // 按 definitionId 分组(保留原始顺序)
@@ -114,12 +114,13 @@ public class IndicatorTimelineService {
                 .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
                         LinkedHashMap::new, Collectors.toList()));
 
-        // ---- 2. 获取 active definitions ----
-        List<Long> defIds = new ArrayList<>(byDef.keySet());
-        Map<Long, IndicatorDefinition> defs = changeService.loadActiveDefinitions(defIds);
+        // ---- 2. 获取 active definitions(对请求的全部 definitionIds,而非仅已观测到的)----
+        // Q17:loadActiveDefinitions 只返回 status=1 的定义,status=0/不存在的 definitionId 天然被剔除
+        List<Long> requestedDefIds = new ArrayList<>(new LinkedHashSet<>(definitionIds));
+        Map<Long, IndicatorDefinition> defs = changeService.loadActiveDefinitions(requestedDefIds);
 
-        // ---- 3. 统计 totalReportCount / olderReportCount(一次查询,内存计算两项)----
-        // 设计 §4.2:一份 COUNT 查询同时得 total 与 older,避免两次 COUNT。
+        // ---- 3. 统计 totalReportCount / olderReportCount(与观测解耦:无观测时 total 仍可算)----
+        // 设计 §4.2:一次拉全部报告日期,内存算 total / older,避免两次 COUNT。
         java.sql.Date latestObsDate = null;
         for (IndicatorValue v : rows) {
             if (v.getReportDate() != null) {
@@ -131,32 +132,53 @@ public class IndicatorTimelineService {
         final java.util.Date latestObsDateFinal = latestObsDate;
         Long totalReportCount = null;
         Long olderReportCount = null;
+        // 一次拉全部报告日期,内存算 total / older(总量小,单人单类型 < 100)
+        List<java.util.Date> reportDates = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>()
+                        .eq(HealthReport::getSubjectId, subjectId)
+                        .eq(HealthReport::getStatus, "active")
+                        .isNotNull(HealthReport::getReportDate)
+                        .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                                HealthReport::getReportType, sourceType)
+                        .select(HealthReport::getReportDate))
+                .stream().map(HealthReport::getReportDate).filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        totalReportCount = (long) reportDates.size();
+        // olderReportCount 以「最新一期观测日」为参照;无观测时无参照日 → 恒为 null
         if (latestObsDateFinal != null) {
-            // 一次拉全部报告日期,内存算 total / older(总量小,单人单类型 < 100)
-            List<java.util.Date> reportDates = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>()
-                            .eq(HealthReport::getSubjectId, subjectId)
-                            .eq(HealthReport::getStatus, "active")
-                            .isNotNull(HealthReport::getReportDate)
-                            .eq(sourceType != null && !sourceType.trim().isEmpty(),
-                                    HealthReport::getReportType, sourceType)
-                            .select(HealthReport::getReportDate))
-                    .stream().map(HealthReport::getReportDate).filter(Objects::nonNull)
-                    .collect(Collectors.toList());
-            totalReportCount = (long) reportDates.size();
             olderReportCount = reportDates.stream()
                     .filter(d -> toSqlDate(d).before(latestObsDateFinal)).count();
         }
 
-        // ---- 4. 按 definition 组装 ----
-        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
-            Long defId = e.getKey();
+        // ---- 4. 按 definition 组装(遍历请求的 definitionIds,含无观测项)----
+        for (Long defId : requestedDefIds) {
             IndicatorDefinition def = defs.get(defId);
             if (def == null) {
                 // Q17 强制:拿不到的 active definition 直接剔除,不降级
                 continue;
             }
-            List<IndicatorValue> pointsRaw = e.getValue();
+            List<IndicatorValue> pointsRaw = byDef.get(defId);
             if (pointsRaw == null || pointsRaw.isEmpty()) {
+                // 无观测:仍返回一条 chartMode='empty' 的 item,
+                // 前端据此展示「共 N 份报告,本指标暂无可显示的历史数据」而非孤立空态
+                Map<String, Object> emptyItem = new LinkedHashMap<>();
+                emptyItem.put("definitionId", defId);
+                emptyItem.put("name", def.getName());
+                emptyItem.put("unit", def.getUnit());
+                emptyItem.put("refRange", def.getRefRange());
+                emptyItem.put("refLow", null);
+                emptyItem.put("refHigh", null);
+                emptyItem.put("goodDirection", def.getGoodDirection());
+                emptyItem.put("valueType", def.getValueType());
+                emptyItem.put("pointsCount", 0);
+                emptyItem.put("totalReportCount", totalReportCount);
+                emptyItem.put("olderReportCount", null);
+                emptyItem.put("chartMode", "empty");
+                emptyItem.put("unitMismatch", false);
+                emptyItem.put("latest", null);
+                emptyItem.put("change", null);
+                emptyItem.put("points", new ArrayList<>());
+                emptyItem.put("missingPeriods", new ArrayList<>());
+                items.add(emptyItem);
                 continue;
             }
 

+ 29 - 11
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceIndicatorsTest.java

@@ -146,9 +146,9 @@ class HealthReportServiceIndicatorsTest {
         prev.setUnit("mmol/L");
         prev.setStatus("偏高");
         prevRows.add(prev);
-        Map<Long, IndicatorValue> prevMap = new HashMap<>();
-        prevMap.put(512L, prev);
-        doReturn(prevMap).when(changeService).batchResolve(any(), any(), any(), any());
+        Map<Long, List<IndicatorValue>> historyMap = new HashMap<>();
+        historyMap.put(512L, new ArrayList<>(Arrays.asList(prev)));
+        doReturn(historyMap).when(changeService).batchResolveHistory(any(), any(), any(), any());
 
         // 更早报告 3 份 → olderReportCount=3(白细胞无上一期 → pending_backfill)
         List<HealthReport> older = new ArrayList<>();
@@ -179,6 +179,9 @@ class HealthReportServiceIndicatorsTest {
         assertEquals("改善", ldlChange.get("trendLabel"));
         assertEquals(-0.48, ((BigDecimal) ldlChange.get("delta")).doubleValue(), 1e-9);
         assertEquals(true, ldlChange.get("comparable"));
+        // 真实期数:1(本期)+ 1(历史 distinct report_date)
+        assertEquals(2, ((Number) ldl.get("pointsCount")).intValue());
+        assertEquals(3, ((Number) ldl.get("olderReportCount")).intValue());
 
         // 档 B1/B2 分界:命中字典但无上一期 + 有更早报告 → pending_backfill(诚实降级,不是"首次记录")
         Map<String, Object> wbc = items.get(1);
@@ -190,11 +193,21 @@ class HealthReportServiceIndicatorsTest {
         assertEquals("历史数据整理中,暂无法对比", wbcChange.get("trendLabel"));
         assertEquals(false, wbcChange.get("comparable"));
 
-        // 未命中字典:definitionId / previous / change 全为 null,前端按"无对比"渲染
+        // 未命中字典:definitionId=null,但 change 恒非空(B2 档:olderReportCount>0 → pending_backfill)
         Map<String, Object> unknown = items.get(2);
         assertNull(unknown.get("definitionId"), "未命中字典的指标 definitionId 为 null");
         assertNull(unknown.get("previous"));
-        assertNull(unknown.get("change"), "未命中字典时无对比对象");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> unknownChange = (Map<String, Object>) unknown.get("change");
+        assertNotNull(unknownChange, "未命中字典的 change 也必须是非 null 对象(前端只消费 trendCode)");
+        assertEquals("pending_backfill", unknownChange.get("trendCode"));
+        assertEquals("none", unknownChange.get("movement"));
+        assertEquals(false, unknownChange.get("comparable"));
+        assertNull(unknownChange.get("delta"));
+        assertNull(unknownChange.get("deltaPct"));
+        // 未命中字典项:pointsCount 恒 1(不跨报告匹配),olderReportCount 逐项下发
+        assertEquals(1, ((Number) unknown.get("pointsCount")).intValue());
+        assertEquals(3, ((Number) unknown.get("olderReportCount")).intValue());
 
         // summary
         @SuppressWarnings("unchecked")
@@ -204,8 +217,10 @@ class HealthReportServiceIndicatorsTest {
         assertEquals(2, ((Number) summary.get("abnormalCount")).intValue());
         assertEquals(1, ((Number) summary.get("comparableCount")).intValue());
         assertEquals(0, ((Number) summary.get("firstRecordCount")).intValue());
-        assertEquals(1, ((Number) summary.get("pendingCount")).intValue());
+        assertEquals(2, ((Number) summary.get("pendingCount")).intValue());
         assertEquals(true, summary.get("dataIncomplete"));
+        // abnormalDelta:仅 LDL 有上一期对照(本期异常=1,上期异常=1)→ 0
+        assertEquals(0, ((Number) summary.get("abnormalDelta")).intValue());
     }
 
     @Test
@@ -218,7 +233,7 @@ class HealthReportServiceIndicatorsTest {
         Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
         resolvedMap.put("载脂蛋白 A1", resolved("载脂蛋白 A1", 513L, "载脂蛋白 A1"));
         when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
-        doReturn(new HashMap<Long, IndicatorValue>()).when(changeService).batchResolve(any(), any(), any(), any());
+        doReturn(new HashMap<Long, List<IndicatorValue>>()).when(changeService).batchResolveHistory(any(), any(), any(), any());
         // 无更早报告 → olderReportCount=0 → first_record
         when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
 
@@ -237,6 +252,8 @@ class HealthReportServiceIndicatorsTest {
         assertEquals(1, ((Number) summary.get("firstRecordCount")).intValue());
         assertEquals(0, ((Number) summary.get("pendingCount")).intValue());
         assertEquals(false, summary.get("dataIncomplete"));
+        // 无更早报告 → 无上一期可算 → abnormalDelta 为 null(前端不显示"较上次 ±N")
+        assertNull(summary.get("abnormalDelta"));
     }
 
     // ==================== #6 SQL ≤ 5 ====================
@@ -253,18 +270,19 @@ class HealthReportServiceIndicatorsTest {
             resolvedMap.put(name, resolved(name, 500L + i, name));
         }
         when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
-        doReturn(new HashMap<Long, IndicatorValue>()).when(changeService).batchResolve(any(), any(), any(), any());
+        doReturn(new HashMap<Long, List<IndicatorValue>>()).when(changeService).batchResolveHistory(any(), any(), any(), any());
         when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
 
         Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
 
-        // 服务层(报告与指标行由控制器加载后传入)固定 3 次查询,与指标条数无关:
+        // 服务层(报告与指标行由控制器加载后传入)固定 4 次查询,与指标条数无关:
         //   ① health_reports(olderReportCount)
         //   ② indicator_mapping_rules + indicator_definitions 联合查询(resolveBatch)
-        //   ③ indicator_values(batchResolve 上一期)
+        //   ③ indicator_values(batchResolveHistory:上一期 + 历史有效期数一次带出)
+        //   abnormalDelta 由内存计算,零额外查询
         verify(healthReportMapper, times(1)).selectList(any());
         verify(mappingService, times(1)).resolveBatch(anyString(), anyCollection());
-        verify(changeService, times(1)).batchResolve(any(), any(), any(), any());
+        verify(changeService, times(1)).batchResolveHistory(any(), any(), any(), any());
         // 服务层自身绝不碰 indicator_values 表:全部观测访问必须经由 changeService 批量入口
         verify(valueMapper, never()).selectList(any());
 

+ 15 - 4
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTimelineServiceTest.java

@@ -145,13 +145,24 @@ class IndicatorTimelineServiceTest {
 
     @Test
     void chartMode_n0_empty() {
-        // 无观测 → items 为空
+        // 无观测 → 仍返回一条 chartMode='empty' 的 item(前端据此显示残缺横幅,而非孤立空态)
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
         when(valueMapper.selectList(any())).thenReturn(new ArrayList<>());
-        mockReports();
+        mockDefs(d);
+        mockReports("2026-08-12", "2026-10-08");
         Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
                 null, null, "raw", "all");
-        List<?> items = (List<?>) data.get("items");
-        assertTrue(items.isEmpty());
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        Map<String, Object> item = items.get(0);
+        assertEquals("empty", item.get("chartMode"));
+        assertEquals(0, ((Number) item.get("pointsCount")).intValue());
+        assertEquals(2, ((Number) item.get("totalReportCount")).intValue());
+        assertNull(item.get("olderReportCount"), "无观测时无参照日 → olderReportCount 为 null");
+        assertNull(item.get("latest"));
+        assertNull(item.get("change"));
+        assertTrue(((List<?>) item.get("points")).isEmpty());
+        assertTrue(((List<?>) item.get("missingPeriods")).isEmpty());
     }
 
     @Test

+ 2 - 1
cfc-frontend/components/TrendChart.vue

@@ -149,7 +149,8 @@ export default {
         ctx.fillStyle = BrandColors.appTextSubtle
         ctx.font = '12px sans-serif'
         ctx.textAlign = 'center'
-        ctx.fillText('暂无趋势数据', w / 2, h / 2)
+        // P0 红线:禁止孤立「暂无趋势数据」文案,统一为「暂无该指标数据」
+        ctx.fillText('暂无该指标数据', w / 2, h / 2)
         return
       }
       var ranges = this._computeRanges(seriesList)

+ 22 - 6
cfc-frontend/components/indicator/IndicatorChangeRow.vue

@@ -1,7 +1,7 @@
 <template>
   <view v-if="rowVisible" class="change-row" :class="sizeClass">
-    <!-- 第一行:较 MM-DD + 旧值 → 新值 + Δ (Δ%) + 趋势词 -->
-    <view class="change-main">
+    <!-- 第一行:较 MM-DD + 旧值 → 新值 + Δ (Δ%) + 趋势词(无对照/不可比时整行隐藏) -->
+    <view class="change-main" v-if="displayLine">
       <text class="change-date">{{ compareDate }}</text>
       <text class="change-body">{{ displayLine }}</text>
     </view>
@@ -56,13 +56,27 @@ export default {
       if (this.previous) return this.previous
       return (this.change && this.change.previous) || null
     },
+    /**
+     * 趋势展示(design §6.2 五档 + §6.6 单一事实来源)
+     *   change == null                → 中性兜底(灰,无箭头)
+     *   change.comparable == false    → 档 D 不可比(灰,无箭头)
+     *     · 后端 trendLabel 优先:非数值「有变化/无变化」、B1「首次记录 · 暂无对比」、
+     *       B2「历史数据整理中,暂无法对比」均不被覆盖;后端未下发时兜底「无法比较」
+     *   其余                           → resolveChange(trendCode, movement, trendLabel)
+     */
     trendInfo: function() {
       var ch = this.change
-      if (!ch || ch.trendCode === 'first_record' || ch.trendCode === 'pending_backfill' || !ch.comparable) {
-        var tc = (ch && ch.trendCode) || 'first_record'
-        return resolveChange(tc, ch && ch.movement, ch && ch.trendLabel)
+      if (!ch) return resolveChange('first_record', 'none', '')
+      if (ch.comparable === false) {
+        return {
+          trendLabel: ch.trendLabel || '无法比较',
+          cssClass: 'degrade',
+          arrow: '',
+          colorClass: 'gray-text',
+          showArrow: false
+        }
       }
-      return resolveChange(ch.trendCode, ch.movement, ch.trendLabel)
+      return resolveChange(ch.trendCode || 'flat', ch.movement, ch.trendLabel)
     },
     trendLabel: function() {
       return this.trendInfo.trendLabel
@@ -88,6 +102,8 @@ export default {
       var ch = this.change
       var prev = this.prevData
       if (!ch || !prev) return ''
+      // 不可比 / 无对比对象:不渲染数值对照行(只留下方趋势文案行)
+      if (ch.comparable === false) return ''
       var oldVal = prev.value !== undefined && prev.value !== null && prev.value !== '' ? prev.value : '--'
       var newVal = this.current && (this.current.numericValue !== undefined && this.current.numericValue !== null)
         ? this.current.numericValue : (this.current.value || '--')

+ 3 - 0
cfc-frontend/components/indicator/IndicatorIncompleteBanner.vue

@@ -30,6 +30,9 @@ export default {
     visible: function() {
       var pc = this.pointsCount || 0
       var tc = this.totalReportCount || 0
+      // 无任何报告 → 显示灰横幅「该成员暂无报告数据」
+      // ★ 必须先于 pc === tc 判定:否则 0 === 0 先命中「数据完整隐藏」,第三条件永不可达
+      if (pc === 0 && tc === 0) return true
       // 数据完整 → 隐藏
       if (pc === tc) return false
       // 有报告但无期次 → 显示「暂无可显示的历史数据」

+ 34 - 12
cfc-frontend/pages/body-detail/index.vue

@@ -58,8 +58,8 @@
             <text class="overview-placeholder" v-else>暂无数据</text>
           </view>
           <view v-if="ind.trend" class="overview-trend">
-            <text class="trend-icon" :class="ind.trend === 'up' ? 'trend-up' : ind.trend === 'down' ? 'trend-down' : 'trend-stable'">
-              {{ ind.trendIcon }}
+            <text class="trend-icon" :class="ind._trendColorClass">
+              {{ ind._trendArrow }}
             </text>
             <text class="trend-desc" v-if="ind.trendDesc">{{ ind.trendDesc }}</text>
           </view>
@@ -144,7 +144,7 @@
               </text>
               <text class="indicator-placeholder" v-else>暂无数据</text>
               <text class="indicator-delta" v-if="ind._compareText"
-                    :class="ind.trend === 'up' ? 'trend-up' : ind.trend === 'down' ? 'trend-down' : 'trend-stable'">
+                    :class="ind._trendColorClass">
                 {{ ind._compareText }}
               </text>
             </view>
@@ -303,11 +303,11 @@ export default {
       radarPopulationScores: [],
       // 关键指标 - 直接显示的物理指标
       keyIndicators: [
-        { name: '身高', icon: '📏', unit: 'cm', value: null, trend: 'stable', trendIcon: '─', trendDesc: '' },
-        { name: '体重', icon: '⚖️', unit: 'kg', value: null, trend: 'stable', trendIcon: '─', trendDesc: '' },
-        { name: '血压', icon: '🩺', unit: '/mmHg', value: null, trend: 'stable', trendIcon: '─', trendDesc: '' },
-        { name: '体温', icon: '🌡️', unit: '°C', value: null, trend: 'stable', trendIcon: '─', trendDesc: '' },
-        { name: '心率', icon: '❤️', unit: '次/分', value: null, trend: 'stable', trendIcon: '─', trendDesc: '' }
+        { name: '身高', icon: '📏', unit: 'cm', value: null, trend: 'stable', trendIcon: '─', trendDesc: '', _trendColorClass: 'trend-stable', _trendArrow: '─' },
+        { name: '体重', icon: '⚖️', unit: 'kg', value: null, trend: 'stable', trendIcon: '─', trendDesc: '', _trendColorClass: 'trend-stable', _trendArrow: '─' },
+        { name: '血压', icon: '🩺', unit: '/mmHg', value: null, trend: 'stable', trendIcon: '─', trendDesc: '', _trendColorClass: 'trend-stable', _trendArrow: '─' },
+        { name: '体温', icon: '🌡️', unit: '°C', value: null, trend: 'stable', trendIcon: '─', trendDesc: '', _trendColorClass: 'trend-stable', _trendArrow: '─' },
+        { name: '心率', icon: '❤️', unit: '次/分', value: null, trend: 'stable', trendIcon: '─', trendDesc: '', _trendColorClass: 'trend-stable', _trendArrow: '─' }
       ],
       funcList: [
         { icon: '\u{1F3C3}', label: '运动', needLogin: true, page: '/pages/health/meditation-index' },
@@ -513,8 +513,14 @@ export default {
       getLatestIndicators({ memberId: memberId, domain: 'body' }).then(function(res) {
         if (res.code === 200) {
           var data = res.data || []
-          // 预计算对比文案(模板 :class / 插值不调用带参方法)
-          data.forEach(function(it) { it._compareText = self.buildCompareText(it) })
+          // 预计算对比文案 + 趋势展示字段(模板 :class / 插值不调用带参方法)
+          // ★ §6.6 红线:/api/health/indicators 尚未下发 trendCode,
+          //   不得按 delta 正负自判改善/恶化 → 颜色恒中性灰,箭头仅表示方向
+          data.forEach(function(it) {
+            it._compareText = self.buildCompareText(it)
+            it._trendColorClass = 'trend-stable'
+            it._trendArrow = self.buildTrendArrow(it)
+          })
           self.bodyIndicators = data
           // 将API数据同步到keyIndicators中匹配的项目
           for (var i = 0; i < self.keyIndicators.length; i++) {
@@ -524,11 +530,13 @@ export default {
                 keyInd.value = data[j].value
                 keyInd.unit = data[j].unit || keyInd.unit
                 keyInd.trend = data[j].trend || 'stable'
-                keyInd.trendIcon = data[j].trend === 'up' ? '▲' : data[j].trend === 'down' ? '▼' : '─'
+                keyInd.trendIcon = data[j]._trendArrow
                 keyInd.trendDesc = data[j].trendDesc || ''
                 keyInd.definitionId = data[j].definitionId
                 keyInd.previous = data[j].previous || null
                 keyInd.change = data[j].change || null
+                keyInd._trendColorClass = data[j]._trendColorClass
+                keyInd._trendArrow = data[j]._trendArrow
                 break
               }
             }
@@ -538,6 +546,16 @@ export default {
         console.log('[body] loadBodyIndicators failed', e)
       })
     },
+    /**
+     * 趋势箭头(仅表示方向,不表示改善/恶化)
+     */
+    buildTrendArrow: function(ind) {
+      if (!ind) return '─'
+      var t = ind.trend
+      if (t === 'up') return '▲'
+      if (t === 'down') return '▼'
+      return '─'
+    },
     /**
      * 指标卡片第二行对比文案(design §6.4 body-detail)
      *   后端已下发 previous/change(T02 落地后)→ 「较 MM-DD ↑ +2.3」
@@ -611,6 +629,7 @@ export default {
      * 传参口径:subjectId=memberId + definitionId + reportType(可默认"全来源")
      * 降级:后端 T02 未落地时 /api/health/indicators 不下发 definitionId,
      *      此时保持原行为跳报告列表,避免跳到空页面。
+     * ★ 不得落到「暂无趋势数据」孤立态:无 definitionId 时 toast 明示原因。
      */
     goIndicatorDetail: function(ind) {
       var memberId = this.activeChildId
@@ -620,7 +639,10 @@ export default {
         uni.navigateTo({ url: url })
         return
       }
-      uni.navigateTo({ url: '/pages/health/report-list' })
+      uni.showToast({ title: '该指标暂无可查看的趋势数据', icon: 'none' })
+      setTimeout(function() {
+        uni.navigateTo({ url: '/pages/health/report-list' })
+      }, 600)
     },
     goToReportList: function() {
       uni.navigateTo({ url: '/pages/health/report-list' })

+ 119 - 29
cfc-frontend/pages/health/components/TrendChart.vue

@@ -19,7 +19,8 @@
 
 <script>
 import { BrandColors } from '../../../utils/brand-colors.js'
-import { chartRenderConfig, axisLabelIndices, normalizeDate, dateWithSourceMark, unitMismatchMark } from '@/utils/indicator-timeline.js'
+import { chartRenderConfig } from '@/utils/indicator-change.js'
+import { axisLabelIndices, normalizeDate, dateWithSourceMark, unitMismatchMark } from '@/utils/indicator-timeline.js'
 /**
  * TrendChart - 多序列折线图 Canvas 组件(改造版)
  * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit,dateSource}]}]"
@@ -203,43 +204,82 @@ export default {
       return { yMin: Math.max(0, min - pad), yMax: max + pad }
     },
 
-    _doRender: function() {
-      if (!this.ctx) return
-      var ctx = this.ctx
-      var w = this.canvasWidth, h = this.canvasHeight
+    /**
+     * 构建画布布局:X 轴日期全集(含缺失期)、Y 轴数值范围、绘图区尺寸、坐标映射。
+     * 结果同时挂到 this._layout,供 onCanvasTap(点选最近数据点)复用。
+     * 约定(design §6.2):Y 轴范围必须包含 refLow/refHigh(_computeRanges 已处理)。
+     */
+    _buildLayout: function() {
+      var w = this.canvasWidth || this.width || 690
+      var h = this.canvasHeight || this.height || 420
       var padL = 34, padR = 14, padT = 16, padB = 26
-      var plotW = w - padL - padR, plotH = h - padT - padB
-      var seriesList = this._safeSeries(this.series)
-      var renderCfg = this.renderConfig
-      var drawLine = renderCfg.draw
-      var showValues = renderCfg.showValues
-      var labelMode = renderCfg.labelMode
+      var plotW = Math.max(1, w - padL - padR)
+      var plotH = Math.max(1, h - padT - padB)
 
-      // X 轴日期全集(含缺失期),用于等距布局
+      // X 轴日期全集:优先 allPeriodDates(含缺失期),否则从 series 推导并排序
       var allDates = []
-      if (Array.isArray(this.allPeriodDates) && this.allPeriodDates.length > 0) {
-        allDates = this.allPeriodDates.slice()
+      var dates = this.allPeriodDates
+      if (Array.isArray(dates) && dates.length > 0) {
+        allDates = dates.slice()
       } else {
         var set = {}
-        seriesList.forEach(function(s) {
-          s.points.forEach(function(p) {
-            if (p.reportDate) set[p.reportDate] = true
-          })
-        })
+        for (var i = 0; i < this.series.length; i++) {
+          var s = this.series[i]
+          if (!s || !Array.isArray(s.points)) continue
+          for (var j = 0; j < s.points.length; j++) {
+            var d = s.points[j] && s.points[j].reportDate
+            if (d) set[d] = true
+          }
+        }
         allDates = Object.keys(set).sort()
       }
-      function xOf(dateStr) {
-        if (allDates.length <= 1) return padL + plotW / 2
+
+      var ranges = this._computeRanges(this._safeSeries(this.series))
+      var yMin = ranges.yMin
+      var yMax = ranges.yMax
+      var ySpan = (yMax - yMin) || 1
+
+      var layout = {
+        w: w, h: h,
+        padL: padL, padR: padR, padT: padT, padB: padB,
+        plotW: plotW, plotH: plotH,
+        allDates: allDates,
+        yMin: yMin, yMax: yMax
+      }
+      // 日期 → X 像素(等距布局)
+      layout.xOf = function(dateStr) {
         var idx = allDates.indexOf(dateStr)
         if (idx < 0) return padL
+        if (allDates.length <= 1) return padL + plotW / 2
         return padL + plotW * (idx / (allDates.length - 1))
       }
-      var yMin = 0, yMax = 1
-      if (seriesList.length > 0 || this.hasRefBand) {
-        var ranges = this._computeRanges(seriesList)
-        yMin = ranges.yMin; yMax = ranges.yMax
+      // 数值 → Y 像素(Y 轴向上为正)
+      layout.yOfC = function(v) {
+        return padT + plotH * (1 - (v - yMin) / ySpan)
       }
-      function yOfC(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
+      this._layout = layout
+      return layout
+    },
+
+    _doRender: function() {
+      if (!this.ctx) return
+      var self = this
+      var ctx = this.ctx
+      var layout = this._buildLayout()
+      var w = layout.w, h = layout.h
+      var padL = layout.padL, padR = layout.padR, padT = layout.padT, padB = layout.padB
+      var plotW = layout.plotW, plotH = layout.plotH
+      var seriesList = this._safeSeries(this.series)
+      var renderCfg = this.renderConfig
+      var drawLine = renderCfg.draw
+      var showValues = renderCfg.showValues
+      var labelMode = renderCfg.labelMode
+
+      // X 轴日期全集(含缺失期),用于等距布局
+      var allDates = layout.allDates
+      var xOf = layout.xOf
+      var yOfC = layout.yOfC
+      var yMin = layout.yMin, yMax = layout.yMax
 
       ctx.clearRect(0, 0, w, h)
 
@@ -292,9 +332,9 @@ export default {
             for (var pi = 0; pi < s.points.length; pi++) {
               if (s.points[pi].reportDate === d) { pt = s.points[pi]; break }
             }
-            if (!pt) return   // 缺失期:不 lineTo
+            if (!pt) { started = false; return }   // 缺失期:断线,下一个有效点重新起笔
             var v = parseFloat(pt.value)
-            if (isNaN(v)) return
+            if (isNaN(v)) { started = false; return }
             var x = xOf(d), y = yOfC(v)
             if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
           })
@@ -354,7 +394,57 @@ export default {
     },
 
     onCanvasTap: function(e) {
-      this.$emit('pointTap')
+      if (!this.canvasReady) return
+      var layout = this._layout
+      if (!layout) return
+
+      // 从事件中取页面坐标(与 RadarChart 同套容错)
+      var pageX
+      var pageY
+      if (e.detail && typeof e.detail.x === 'number') {
+        pageX = e.detail.x
+        pageY = e.detail.y
+      } else if (e.touches && e.touches[0]) {
+        pageX = e.touches[0].x || e.touches[0].clientX || 0
+        pageY = e.touches[0].y || e.touches[0].clientY || 0
+      } else if (e.changedTouches && e.changedTouches[0]) {
+        pageX = e.changedTouches[0].x || e.changedTouches[0].clientX || 0
+        pageY = e.changedTouches[0].y || e.changedTouches[0].clientY || 0
+      } else {
+        return
+      }
+
+      // 转 canvas 相对坐标
+      var relX = pageX - (this._canvasLeft || 0)
+      var relY = pageY - (this._canvasTop || 0)
+      if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) return
+
+      // 找最近数据点(P2-2 长按读数的基础)
+      var best = null
+      var bestDist = Infinity
+      var hitRadius = 40
+      for (var si = 0; si < this.series.length; si++) {
+        var s = this.series[si]
+        if (!s || !Array.isArray(s.points)) continue
+        for (var pi = 0; pi < s.points.length; pi++) {
+          var p = s.points[pi]
+          if (!p) continue
+          var v = parseFloat(p.value)
+          if (isNaN(v)) continue
+          var x = layout.xOf(p.reportDate)
+          var y = layout.yOfC(v)
+          var dx = x - relX
+          var dy = y - relY
+          var dist = dx * dx + dy * dy
+          if (dist < bestDist) {
+            bestDist = dist
+            best = { index: pi, point: p, x: x, y: y }
+          }
+        }
+      }
+      if (best && bestDist <= hitRadius * hitRadius) {
+        this.$emit('pointTap', { index: best.index, point: best.point, x: best.x, y: best.y })
+      }
     }
   }
 }

+ 16 - 2
cfc-frontend/pages/health/indicator-curve.vue

@@ -24,8 +24,13 @@
           >{{ getIndicatorName(def) }}</view>
         </scroll-view>
       </view>
+      <!-- 残缺横幅(P0 红线:任何路径不得出现孤立「暂无趋势数据」) -->
+      <IndicatorIncompleteBanner
+        :points-count="pointsCount"
+        :total-report-count="totalReportCount"
+      />
       <TrendChart v-if="series && series.length > 0" :series="series" />
-      <view class="empty-tip" v-else>暂无趋势数据</view>
+      <view class="empty-tip" v-else>暂无该指标数据</view>
     </view>
 
     <!-- 变化排名区 -->
@@ -73,6 +78,7 @@
 <script>
 import { BrandColors } from '../../utils/brand-colors.js'
 import TrendChart from '@/pages/health/components/TrendChart.vue'
+import IndicatorIncompleteBanner from '@/components/indicator/IndicatorIncompleteBanner.vue'
 import { getIndicatorTrend, getIndicatorChangeRanking, getIndicatorSourceTypes } from '@/utils/api'
 import { getFamilyMemberList } from '@/utils/api'
 import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
@@ -81,7 +87,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { TrendChart },
+  components: { TrendChart, IndicatorIncompleteBanner },
   data: function() {
     return {
       members: [], currentMemberId: null,
@@ -89,6 +95,8 @@ export default {
       activeReportType: '',
       indicatorOptions: [], theIndicatorId: null,
       series: [], loadedDefinitionIds: [],
+      // 残缺横幅用:本期指标已具备期数 / 该成员报告总份数
+      pointsCount: 0, totalReportCount: 0,
       rankTabs: [ { value: 'flora', label: '菌群变化' }, { value: 'food', label: '食材变化' }, { value: 'indicator', label: '指标变化' } ],
       activeRankTab: 'flora',
       sortOptions: [ { value: 'magnitude', label: '绝对幅度' }, { value: 'up', label: '升幅↑' }, { value: 'down', label: '降幅↓' } ],
@@ -215,6 +223,8 @@ export default {
           self.loadTrendFor([self.theIndicatorId])
         } else {
           self.series = []
+          self.pointsCount = 0
+          self.totalReportCount = 0
         }
       })
     },
@@ -228,6 +238,10 @@ export default {
           return { name: it.name, color: color || BrandColors.primary, points: (it.points || []).map(function(p) { return { reportDate: String(p.reportDate).slice(0, 10), value: p.value, unit: p.unit } }) }
         })
         self.series = series
+        // 残缺横幅:取当前指标 item 的 pointsCount / totalReportCount(后端单一事实来源)
+        var first = items[0]
+        self.pointsCount = first && first.pointsCount ? first.pointsCount : 0
+        self.totalReportCount = first && first.totalReportCount ? first.totalReportCount : 0
       })
     },
     onIndicatorTap: function(definitionId) {

+ 5 - 3
cfc-frontend/pages/health/indicator-quicklook.vue

@@ -114,10 +114,12 @@ export default {
         // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
         var list = data.items || []
         list.forEach(function(it) {
-          // 变化样式:只消费后端 trendCode,前端不按 delta 正负自判改善/恶化
+          // 变化样式:只消费后端 trendCode,前端不按 delta 正负自判改善/恶化;
+          // change 缺失时中性兜底「暂无对比」,不写死 first_record(§6.6 红线)
           var ch = it.change || null
-          var tc = ch ? ch.trendCode : 'first_record'
-          var st = resolveChange(tc, ch && ch.movement, ch && ch.trendLabel)
+          var st = ch
+            ? resolveChange(ch.trendCode || 'flat', ch.movement, ch.trendLabel)
+            : { trendLabel: '暂无对比', cssClass: 'flat', arrow: '', colorClass: 'gray-text', showArrow: false }
           it._cssClass = st.colorClass
           it._arrow = st.arrow
           it._trendLabel = st.trendLabel

+ 14 - 1
cfc-frontend/pages/health/indicator-timeline.vue

@@ -259,7 +259,15 @@ export default {
       var ch = this.item.change
       var r = resolveChange(ch.trendCode, ch.movement, ch.trendLabel)
       var unitMark = ch.unitMismatch ? ' *' : ''
-      return '较 ' + formatCompareDate(ch.previous.reportDate, this.item.latest && this.item.latest.reportDate, ch.previous.dateSource) + ':' + r.arrow + ' ' + (ch.delta > 0 ? '+' : '') + (ch.delta || 0) + ' (' + (ch.deltaPct > 0 ? '+' : '') + (ch.deltaPct || 0) + '%)' + unitMark + ' ' + r.trendLabel
+      var prevDatePart = ''
+      if (ch.previous && ch.previous.reportDate) {
+        prevDatePart = '较 ' + formatCompareDate(ch.previous.reportDate, this.item.latest && this.item.latest.reportDate, ch.previous.dateSource) + ':'
+      }
+      var deltaPart = ''
+      if (ch.delta !== null && ch.delta !== undefined && !isNaN(ch.delta)) {
+        deltaPart = r.arrow + ' ' + (ch.delta > 0 ? '+' : '') + (ch.delta || 0) + ' (' + (ch.deltaPct > 0 ? '+' : '') + (ch.deltaPct || 0) + '%)' + unitMark
+      }
+      return prevDatePart + deltaPart + ' ' + r.trendLabel
     },
     pairArrow: function() {
       if (!this.item || !this.item.change) return '→'
@@ -438,6 +446,11 @@ export default {
     getIndicatorKey: function(def) { return def ? (def.definitionId || def.name) : '' },
     getIndicatorId: function(def) { return def ? def.definitionId : null },
     getIndicatorName: function(def) { return (def && def.name) || '' },
+    /** 期次明细表行 key(小程序 :key 禁止运算符表达式,统一走方法) */
+    getPeriodKey: function(row, ri) {
+      if (!row) return 'row-' + ri
+      return 'row-' + (row.reportDate || ri) + '-' + ri
+    },
     /** 重载全部数据(切成员 / 下拉刷新) */
     reloadAll: function() {
       this.loadSourceTypes()

+ 101 - 15
cfc-frontend/pages/health/report-detail.vue

@@ -201,7 +201,7 @@
             <view class="compare-head" v-if="hasCompareData">
               <!-- 历史数据整理中提示条(pendingCount > 0) -->
               <view class="compare-pending" v-if="comparePendingCount > 0">
-                <text class="compare-pending-text">部分历史数据整理中,暂不可对比</text>
+                <text class="compare-pending-text">部分历史数据正在整理,对比结果可能不全</text>
               </view>
               <!-- 数据来源 / 报告份数 -->
               <view class="compare-meta">
@@ -228,6 +228,7 @@
                   <text class="indicator-group-name">{{ group.category }}</text>
                 </view>
                 <view class="indicator-group-header-right">
+                  <text class="indicator-group-delta" v-if="group._deltaText">{{ group._deltaText }}</text>
                   <text class="indicator-group-abnormal" v-if="group.abnormalCount > 0">{{ group.abnormalCount }} 项异常</text>
                   <text class="indicator-group-count">{{ group.items.length }} 项</text>
                 </view>
@@ -242,6 +243,8 @@
                     <text class="ind-unit-inline" v-if="ind.unit">{{ ind.unit }}</text>
                     <view class="status-tag" :class="'status-' + _cssClass(ind.status)">{{ statusText(ind.status) }}</view>
                     <text class="ind-change-badge" v-if="ind._badgeText">{{ ind._badgeText }}</text>
+                    <!-- 「N 期 ›」趋势入口(pointsCount ≥ 2 才显示) -->
+                    <text class="ind-trend-entry" v-if="ind._showTrendEntry" @tap.stop="goIndicatorTimeline(ind)">{{ ind._pointsCount }} 期 ›</text>
                   </view>
                   <view class="ind-detail" v-if="ind.refRange">
                     <text class="ind-ref" v-if="ind.refRange">参考: {{ ind.refRange }}</text>
@@ -527,6 +530,7 @@ export default {
       // ===== 变化对照(T04 第二段:/api/health/report/indicators)=====
       // 接口失败时静默降级为现状单期渲染(不显示变化对照区域)
       reportIndicatorMap: {},
+      reportIndicatorNameIndex: {},
       indicatorSummary: null,
       indicatorMeta: null,
       // 三档筛选:all 全部 / changed 有变化 / abnormal 异常
@@ -571,28 +575,32 @@ export default {
       var parts = []
       if (m && m.dateSource && m.dateSource !== 'real') { parts.push('来源:上传日期') }
       else if (m && m.reportDate) { parts.push('日期:' + m.reportDate) }
-      if (m && m.olderReportCount != null) { parts.push('共 ' + m.olderReportCount + ' 份报告') }
+      if (m && m.olderReportCount != null) { parts.push('更早 ' + m.olderReportCount + ' 份报告') }
       return parts.length ? parts.join('  ·  ') : ''
     },
     /**
      * 指标分组 + 变化对照合并 + 三档筛选(T04 第二段)
      * 说明:
-     *  - 变化数据按 indicatorName 与 payload 指标合并(编辑态下 name 可改,合并以接口返回的 indicatorName 为键)
+     *  - 变化数据按 definitionId 合并(definitionId→item + name→definitionId 双索引,
+     *    payload 指标名可编辑时用名称索引兜底)
+     *  - 趋势词/颜色只消费后端 trendCode + movement + trendLabel,前端不自判改善/恶化
      *  - 预计算所有展示字段(/:class 不调用带参方法)
      */
     indicatorGroups() {
       var groups = []
       var orderMap = {}
       var list = this.payload.indicators || []
-      var map = this.reportIndicatorMap || {}
       var filter = this.indicatorFilter
       for (var i = 0; i < list.length; i++) {
         var ind = list[i]
-        var ext = map[ind.indicatorName] || null
+        var ext = this.findReportIndicator(ind)
         var prev = ext && ext.previous ? ext.previous : null
         var ch = ext && ext.change ? ext.change : null
-        var tc = ch ? ch.trendCode : 'first_record'
-        var st = resolveChange(tc, ch && ch.movement, ch && ch.trendLabel)
+        // ★ 只消费后端 trendCode;change 缺失时一律中性兜底(不自行断言 first_record)
+        var tc = ch ? ch.trendCode : null
+        var st = ch
+          ? resolveChange(tc, ch.movement, ch.trendLabel)
+          : { trendLabel: '', cssClass: 'flat', arrow: '', colorClass: 'gray-text', showArrow: false }
         // 预计算字段(模板不调用带参方法)
         ind._previous = prev
         ind._change = ch
@@ -600,10 +608,21 @@ export default {
         ind._cardChangeCss = st.colorClass
         ind._isChanged = !!(ch && ch.comparable && tc && tc !== 'flat')
         ind._isAbnormal = this.isAbnormalStatus(ind.status)
-        // 简报徽标(短词)
+        // 简报徽标(短词):由 trendCode + movement 推导,不看 delta 正负
         var badge = ''
-        if (tc === 'first_record') { badge = '首次' } else if (tc === 'pending_backfill') { badge = '整理中' } else if (tc === 'improved' || tc === 'worsened') { badge = '有变化' } else if (tc === 'flat') { badge = '持平' }
+        if (tc === 'first_record') { badge = '首次' }
+        else if (tc === 'pending_backfill') { badge = '整理中' }
+        else if (tc === 'improved' || tc === 'worsened') { badge = '有变化' }
+        else if (tc === 'flat') {
+          var mv = ch.movement
+          if (mv === 'changed') badge = '有变化'
+          else if (mv === 'unchanged') badge = '无变化'
+          else badge = '持平'
+        }
         ind._badgeText = (badge && ch) ? badge : ''
+        // 「N 期 ›」趋势入口(pointsCount ≥ 2 且命中字典)
+        ind._pointsCount = (ext && ext.pointsCount) ? ext.pointsCount : 0
+        ind._showTrendEntry = !!(ext && ext.definitionId !== null && ext.definitionId !== undefined && ind._pointsCount >= 2)
         var curValue = ind.indicatorValue
         ind._current = {
           value: curValue,
@@ -619,10 +638,23 @@ export default {
         if (gIdx === undefined) {
           gIdx = groups.length
           orderMap[cat] = gIdx
-          groups.push({ category: cat, items: [], abnormalCount: 0 })
+          groups.push({ category: cat, items: [], abnormalCount: 0, abnormalDelta: null })
+        }
+        var g = groups[gIdx]
+        g.items.push(ind)
+        if (ind._isAbnormal) g.abnormalCount++
+        // 分组「较上次 ±N」:只统计有上一期对照的项(与后端 summary.abnormalDelta 同口径)
+        if (prev) {
+          if (g.abnormalDelta === null) g.abnormalDelta = 0
+          if (ind._isAbnormal) g.abnormalDelta++
+          if (this.isAbnormalStatus(prev.status)) g.abnormalDelta--
         }
-        groups[gIdx].items.push(ind)
-        if (ind._isAbnormal) groups[gIdx].abnormalCount++
+      }
+      for (var gi = 0; gi < groups.length; gi++) {
+        var grp = groups[gi]
+        grp._deltaText = (grp.abnormalDelta === null)
+          ? ''
+          : ('较上次 ' + (grp.abnormalDelta > 0 ? '+' : '') + grp.abnormalDelta)
       }
       return groups
     },
@@ -780,16 +812,24 @@ export default {
       getReportIndicators({ reportId: self.reportId, withPrevious: true }).then(function(res) {
         if (!res || res.code !== 200 || !res.data) return
         var data = res.data
+        // 双索引:definitionId → item(主键)+ name → definitionId(payload 指标名兜底)
         var map = {}
+        var nameIndex = {}
         var items = data.items || []
         for (var i = 0; i < items.length; i++) {
           var it = items[i]
+          var defId = it.definitionId
           var name = it.indicatorName || it.name
-          if (!name) continue
-          // 同名指标保留第一条(后端已按 definitionId 去重)
-          if (!map[name]) map[name] = it
+          if (defId !== null && defId !== undefined) {
+            if (!map[defId]) map[defId] = it
+            if (name && !nameIndex[name]) nameIndex[name] = defId
+          } else if (name && !map[name]) {
+            // 未命中字典(definitionId=null):按名称兜底
+            map[name] = it
+          }
         }
         self.reportIndicatorMap = map
+        self.reportIndicatorNameIndex = nameIndex
         self.indicatorSummary = data.summary || null
         self.indicatorMeta = {
           reportId: data.reportId,
@@ -803,6 +843,36 @@ export default {
         // 静默降级:保持现状单期渲染
       })
     },
+    /** 变化对照合并:definitionId 优先,名称索引兜底(definitionId→item + name→definitionId 双索引) */
+    findReportIndicator: function(ind) {
+      if (!ind) return null
+      var map = this.reportIndicatorMap || {}
+      var nameIndex = this.reportIndicatorNameIndex || {}
+      var defId = ind.definitionId
+      if (defId !== null && defId !== undefined && map[defId]) return map[defId]
+      var name = ind.indicatorName
+      if (name) {
+        var idBy = nameIndex[name]
+        if (idBy !== undefined && map[idBy]) return map[idBy]
+        if (map[name]) return map[name]
+      }
+      return null
+    },
+    /** 指标行「N 期 ›」→ 跳指标时间轴页(传参口径:subjectId + definitionId + reportType + reportId,spec §L2539) */
+    goIndicatorTimeline: function(ind) {
+      if (!ind || !ind._showTrendEntry) return
+      var subjectId = (this.indicatorMeta && this.indicatorMeta.subjectId) || this.selectedMemberId
+      if (!subjectId) {
+        uni.showToast({ title: '未绑定成员,无法查看趋势', icon: 'none' })
+        return
+      }
+      var url = '/pages/health/indicator-timeline?subjectId=' + subjectId +
+        '&definitionId=' + ind._definitionId +
+        '&reportId=' + (this.reportId || '')
+      var rt = this.indicatorMeta && this.indicatorMeta.reportType
+      if (rt) { url += '&reportType=' + encodeURIComponent(rt) }
+      uni.navigateTo({ url: url })
+    },
     /** 三档筛选切换:all 全部 / changed 有变化 / abnormal 异常 */
     onIndicatorFilterTap(filter) {
       this.indicatorFilter = filter
@@ -1984,6 +2054,22 @@ $c-rptd-orange-line: #FFD4A8; // 暖橙描边
   padding: 4rpx 12rpx;
   flex-shrink: 0;
 }
+/* 「N 期 ›」趋势入口(跳指标时间轴页) */
+.ind-trend-entry {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  font-weight: 500;
+  padding: 4rpx 12rpx;
+  border-radius: 24rpx;
+  background: $brand-primary-soft;
+  flex-shrink: 0;
+}
+/* 分组头「较上次 ±N」 */
+.indicator-group-delta {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $mat-blue-strong;
+  font-weight: 500;
+}
 
 /* ===== 指标输入框状态色(补齐 status-* 全档位) ===== */
 

+ 2 - 2
cfc-frontend/utils/api.js

@@ -3448,8 +3448,8 @@ export const getIndicatorCorrelationList = (params) => request('/api/indicator/c
 export const getIndicatorSourceTypes = (params) => request('/api/indicator/source-types', 'POST', params || {})
 /** 报告指标(含上一期对照):{ reportId, withPrevious } */
 export const getReportIndicators = (params) => request('/api/health/report/indicators', 'POST', params || {})
-/** 指标时间轴:{ subjectId, definitionIds, sourceType?, fromDate?, toDate?, granularity?, sourceKind? } */
-export const getIndicatorTimeline = (params) => request('/api/indicator/trend', 'POST', params || {})
+/** 指标时间轴:{ subjectId, definitionIds, sourceType?, fromDate?, toDate?, granularity?, sourceKind? }(委托规范入口 getIndicatorTrend,避免同 URL 双 request) */
+export const getIndicatorTimeline = (params) => getIndicatorTrend(params)
 /** 期次列表:{ subjectId, sourceType? } */
 export const getIndicatorPeriods = (params) => request('/api/indicator/periods', 'POST', params || {})
 /** [admin] 存量时序回填:{ subjectId?, reportId?, limit?, dryRun? } */