Przeglądaj źródła

feat(T04): 指标多期展示·小程序页面改造 + 新建时间轴页

T04 第一段:报告详情页变化对照 + 四处趋势入口
- report-detail.vue:指标卡片新增变化对照卡片头(数据来源/报告份数、较上次±N、
  三档筛选[全部/有变化/异常]、显示变化对照开关、pending 提示条),
  指标行第二行接入 IndicatorChangeRow,底部加合规话术;
  新增 loadReportIndicators() 调 /api/health/report/indicators,
  失败静默降级为现状单期渲染
- 趋势入口补全 report-detail / body-detail / indicator-quicklook / gut-flora-detail,
  统一跳转 indicator-timeline 并传 subjectId + definitionId + reportType

T04 第二段:新建指标时间轴页
- 新增 pages/health/indicator-timeline.vue(成员/报告类型选择、指标切换、
  头部卡、残缺横幅、chartMode 五态、期次明细表)
- TrendChart.vue:新增参考范围背景带、缺失期断线、X 轴抽稀、
  上传日/单位不一致 * 标记、pointTap 事件

公共底座(T03 前端部分,一并补齐)
- 新增 utils/indicator-change.js:resolveChange 单一事实来源
  (trendCode 五值 improved/worsened/flat/first_record/pending_backfill),
  前端不按 delta 正负自行判定改善/恶化
- 新增 utils/indicator-timeline.js:时间轴纯函数
- 新增 components/indicator/IndicatorChangeRow.vue / IndicatorDegrade.vue /
  IndicatorIncompleteBanner.vue(残缺横幅四条件文案硬编码)
- utils/api.js:新增 getIndicatorSourceTypes / getReportIndicators /
  getIndicatorTimeline / getIndicatorPeriods / backfillIndicatorValues

依赖说明:T02 后端接口层与 T03 后端服务尚未合入 origin/cfclub,
本次前端按设计文档 API 契约实现,运行时需 T02/T03 落地后方可联调。

验证:mvn -pl cfc-backend -am compile BUILD SUCCESS;
npm run build:mp-weixin Build complete
Agnes Agent 2 tygodni temu
rodzic
commit
1f2f9cd012

+ 151 - 0
cfc-frontend/components/indicator/IndicatorChangeRow.vue

@@ -0,0 +1,151 @@
+<template>
+  <view v-if="rowVisible" class="change-row" :class="sizeClass">
+    <!-- 第一行:较 MM-DD + 旧值 → 新值 + Δ (Δ%) + 趋势词 -->
+    <view class="change-main">
+      <text class="change-date">{{ compareDate }}</text>
+      <text class="change-body">{{ displayLine }}</text>
+    </view>
+    <!-- 第二行:趋势词/降级文案(单独一行,方便颜色语义) -->
+    <view class="change-trend" :class="trendClass">{{ trendLabel }}</view>
+    <!-- 第三行:跨期跳比提示(橙色 20rpx) -->
+    <view class="change-skip" v-if="showSkipHint && skipHintText">
+      <text class="skip-prefix">ⓘ</text><text class="skip-text">{{ skipHintText }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { resolveChange, formatDelta, formatCompareDate } from '@/utils/indicator-change'
+
+/**
+ * IndicatorChangeRow —— 「较 MM-DD:旧值 → 新值 ↑ Δ (Δ%) 趋势词」行
+ * 跨页面复用(报告详情、时间轴、速查、菌群、身维度卡片)
+ *
+ * 渲染分支(design §6.2):
+ *   change == null || previous == null  → 「首次记录 · 暂无对比」(灰)
+ *   change.comparable == false          → 「无法比较」(灰)
+ *   change.unitMismatch == true         → Δ 正常显示,百分比位置显示「—」,值后加 *
+ *   其余                                → 「较 MM-DD:{旧值} → {新值} {箭头} {Δ} ({Δ%}) {趋势词}」
+ *   change.skipGaps > 0 && showSkipHint → 第三行「ⓘ {skipDates 或 '中间 N 期未检测该项'}」(橙 20rpx)
+ */
+export default {
+  name: 'IndicatorChangeRow',
+  props: {
+    /** 上一期 { reportDate, dateSource, value, numericValue, unit, status } | null */
+    previous: { type: Object, default: null },
+    /** 本期 { value, numericValue, unit, reportDate } */
+    current: { type: Object, default: null },
+    /** { delta, deltaPct, movement, trendLabel, trendCode, skipGaps, unitMismatch, comparable } | null */
+    change: { type: Object, default: null },
+    /** 'normal' 报告详情/时间轴 | 'compact' 列表窄位(省略单位) */
+    size: { type: String, default: 'normal' },
+    /** 是否显示跨期跳比提示行 */
+    showSkipHint: { type: Boolean, default: true },
+    /** 跳比提示的具体缺失日期列表(可选,不传则显示「中间 N 期未检测该项」) */
+    skipDates: { type: Array, default: function() { return [] } }
+  },
+  computed: {
+    rowVisible: function() {
+      return !!this.change || !!this.previous || !!this.current
+    },
+    sizeClass: function() {
+      return this.size === 'compact' ? 'change-row-compact' : 'change-row-normal'
+    },
+    /** 上一期数据:优先取 previous prop(report-detail/quick-lookup 是兄弟字段),回退 change.previous(trend 接口嵌套) */
+    prevData: function() {
+      if (this.previous) return this.previous
+      return (this.change && this.change.previous) || null
+    },
+    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)
+      }
+      return resolveChange(ch.trendCode, ch.movement, ch.trendLabel)
+    },
+    trendLabel: function() {
+      return this.trendInfo.trendLabel
+    },
+    trendClass: function() {
+      if (this.trendInfo.cssClass === 'impr') return 'trend-impr'
+      if (this.trendInfo.cssClass === 'worst') return 'trend-worst'
+      return 'trend-flat'
+    },
+    arrow: function() {
+      return this.trendInfo.arrow
+    },
+    /** 比较日期文案,"较 MM-DD"(带 * 若 dateSource 非 real/unknown) */
+    compareDate: function() {
+      var prev = this.prevData
+      if (!prev) return ''
+      var currDate = this.current && this.current.reportDate ? this.current.reportDate : ''
+      var dateSource = prev.dateSource ? prev.dateSource : ''
+      return '较 ' + formatCompareDate(prev.reportDate, currDate, dateSource)
+    },
+    /** 主体展示文本:{旧值} → {新值} {箭头} {Δ} ({Δ%}) */
+    displayLine: function() {
+      var ch = this.change
+      var prev = this.prevData
+      if (!ch || !prev) 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 || '--')
+      var unit = this.size !== 'compact' && this.current && this.current.unit ? (' ' + this.current.unit) : ''
+      var deltaText = formatDelta(ch.delta, ch.deltaPct, ch.unitMismatch)
+      var unitMark = ch.unitMismatch ? ' *' : ''
+      return oldVal + ' → ' + newVal + unit + unitMark + '  ' + (this.arrow || '') + ' ' + deltaText
+    },
+    /** 跳比提示文案 */
+    skipHintText: function() {
+      var ch = this.change
+      if (!ch || ch.skipGaps <= 0) return ''
+      var prevDate = this.prevData && this.prevData.reportDate ? this.prevData.reportDate : ''
+      if (this.skipDates && this.skipDates.length > 0) {
+        return this.skipDates.join('、') + ' 两期未检测该项,对比的是 ' + prevDate
+      }
+      return '中间 ' + ch.skipGaps + ' 期未检测该项,对比的是 ' + prevDate
+    }
+  },
+  methods: {}
+}
+</script>
+
+<style scoped lang="scss">
+.change-row {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  margin-top: 8rpx;
+}
+.change-row-compact { margin-top: 4rpx; }
+.change-main {
+  display: flex;
+  flex-direction: row;
+  align-items: baseline;
+}
+.change-date {
+  font-size: 20rpx;
+  color: #64748B;
+  margin-right: 10rpx;
+}
+.change-body {
+  font-size: 24rpx;
+  line-height: 34rpx;
+}
+.change-trend {
+  font-size: 20rpx;
+  margin-top: 2rpx;
+}
+.trend-impr { color: #16A34A; }
+.trend-worst { color: #EA580C; }
+.trend-flat { color: #94A3B8; }
+.change-skip {
+  font-size: 20rpx;
+  margin-top: 4rpx;
+  color: #EA580C;
+  line-height: 28rpx;
+}
+.skip-prefix { color: #EA580C; margin-right: 4rpx; }
+.skip-text { color: #EA580C; }
+</style>

+ 120 - 0
cfc-frontend/components/indicator/IndicatorDegrade.vue

@@ -0,0 +1,120 @@
+<template>
+  <view class="degrade-wrap" :class="wrapClass">
+    <view class="degrade-icon">{{ icon }}</view>
+    <view class="degrade-text">{{ text }}</view>
+    <view class="degrade-action" v-if="retry && scene === 'error'">
+      <text class="retry-btn" @tap="onRetry">重试</text>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * IndicatorDegrade —— 降级态合集(P0-5,跨页面复用)
+ * 文案契约:PRD §5.5 九个场景 + 新增 `incomplete` 场景(合规,文案硬编码在此组件内)
+ *
+ * Props:
+ *   scene  {String} 必填:n0 / n1 / missing / invalid / upload-date / skip / unit-mismatch / other-type / error / incomplete
+ *   dates  {Array}  缺失日期列表(skip 场景用)
+ *   date   {String} 跳比参考日期(skip 场景用)
+ *   count  {Number} 其他类型期数(other-type 场景用)
+ *   retry  {Function} error 场景的重试回调
+ */
+export default {
+  name: 'IndicatorDegrade',
+  props: {
+    scene: { type: String, required: true },
+    dates: { type: Array, default: function() { return [] } },
+    date: { type: String, default: '' },
+    count: { type: Number, default: 0 },
+    /** scene='incomplete' 专用:报告总份数 */
+    totalReportCount: { type: Number, default: 0 },
+    /** scene='incomplete' 专用:本指标已具备期数 */
+    pointsCount: { type: Number, default: 0 },
+    retry: { type: Function, default: null }
+  },
+  computed: {
+    wrapClass: function() {
+      return this.scene === 'error' ? 'degrade-wrap-error' : 'degrade-wrap'
+    },
+    icon: function() {
+      var s = this.scene
+      if (s === 'n0' || s === 'missing' || s === 'invalid') return '—'
+      if (s === 'n1') return '●'
+      if (s === 'upload-date') return '*'
+      if (s === 'skip') return 'ⓘ'
+      if (s === 'unit-mismatch') return '*'
+      if (s === 'other-type') return '—'
+      if (s === 'error') return '⚠'
+      if (s === 'incomplete') return 'ⓘ'
+      return '—'
+    },
+    text: function() {
+      var s = this.scene
+      if (s === 'n0') return '暂无该指标数据'
+      if (s === 'n1') return '首次记录 · 暂无对比'
+      if (s === 'missing') return '该期未检测'
+      if (s === 'invalid') return '该期无有效数值'
+      if (s === 'upload-date') return '未识别到采样日期,按上传日期记录'
+      if (s === 'skip') {
+        var ds = this.dates && this.dates.length > 0 ? this.dates.join('、') : ''
+        return ds ? ('ⓘ ' + ds + ' 两期未检测该项,对比的是 ' + (this.date || '')) : ('ⓘ 中间 N 期未检测该项,对比的是 ' + (this.date || ''))
+      }
+      if (s === 'unit-mismatch') return '该期单位与其他期不同,未做换算'
+      if (s === 'other-type') return '其他报告类型有 ' + this.count + ' 期数据,暂不支持跨类型对比'
+      if (s === 'error') return '趋势数据加载失败,请下拉重试'
+      if (s === 'incomplete') {
+        var pc = this.pointsCount || 0
+        var tc = this.totalReportCount || 0
+        if (pc === 0 && tc === 0) return '该成员暂无报告数据'
+        if (pc === 0) return '共 ' + tc + ' 份报告,本指标暂无可显示的历史数据'
+        return '共 ' + tc + ' 份报告,本指标目前 ' + pc + ' 期数据(历史数据整理中)'
+      }
+      return ''
+    }
+  },
+  methods: {
+    onRetry: function() {
+      if (this.retry) this.retry()
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.degrade-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  margin: 40rpx 0;
+  padding: 16rpx;
+}
+.degrade-wrap-error {
+  background: #FEF2F2;
+  border: 1rpx dashed #FCA5A5;
+  border-radius: 8rpx;
+}
+.degrade-icon {
+  font-size: 24rpx;
+  margin-right: 10rpx;
+  width: 34rpx;
+  text-align: center;
+}
+.degrade-text {
+  font-size: 22rpx;
+  line-height: 32rpx;
+  color: #94A3B8;
+}
+.degrade-wrap-error .degrade-text {
+  color: #EF4444;
+}
+.retry-btn {
+  font-size: 20rpx;
+  color: #EF4444;
+  background: #FEE2E2;
+  border-radius: 8rpx;
+  padding: 6rpx 14rpx;
+  margin-left: 12rpx;
+}
+</style>

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

@@ -0,0 +1,96 @@
+<template>
+  <view v-if="visible" class="incomplete-banner" :class="toneClass">
+    <text class="banner-icon">ⓘ</text>
+    <text class="banner-text">{{ bannerText }}</text>
+  </view>
+</template>
+
+<script>
+/**
+ * IndicatorIncompleteBanner —— 残缺横幅(P0 红线,不可裁剪)
+ *
+ * 文案契约(design §1.2.2 / PRD §5.3 ②·5,四条件):
+ *   0 < pointsCount < totalReportCount → 「共 N 份报告,本指标目前 M 期数据(历史数据整理中)」(橙)
+ *   pointsCount == 0 && total > 0       → 「共 N 份报告,本指标暂无可显示的历史数据」(橙)
+ *   pointsCount == 0 && total == 0      → 「该成员暂无报告数据」(灰)
+ *   pointsCount == totalReportCount     → 横幅隐藏(数据完整,无需解释)
+ *
+ * 硬性禁止:任何路径下均不得出现孤立的「暂无趋势数据」。
+ * 三处复用:indicator-timeline.vue / indicator-curve.vue / report-detail.vue
+ */
+export default {
+  name: 'IndicatorIncompleteBanner',
+  props: {
+    /** 本指标已具备的期数 */
+    pointsCount: { type: Number, required: true },
+    /** 该成员(该报告类型)的报告总份数 */
+    totalReportCount: { type: Number, required: true }
+  },
+  computed: {
+    visible: function() {
+      var pc = this.pointsCount || 0
+      var tc = this.totalReportCount || 0
+      // 数据完整 → 隐藏
+      if (pc === tc) return false
+      // 有报告但无期次 → 显示「暂无可显示的历史数据」
+      if (pc === 0 && tc > 0) return true
+      // 有期次但少于报告数 → 显示「历史数据整理中」
+      if (pc > 0 && pc < tc) return true
+      // pc > tc(脏数据兜底)→ 隐藏,避免误导
+      return false
+    },
+    toneClass: function() {
+      var pc = this.pointsCount || 0
+      var tc = this.totalReportCount || 0
+      // 无任何报告 → 灰;其余残缺态 → 橙
+      return (pc === 0 && tc === 0) ? 'banner-gray' : 'banner-orange'
+    },
+    bannerText: function() {
+      var pc = this.pointsCount || 0
+      var tc = this.totalReportCount || 0
+      if (pc === 0 && tc === 0) return '该成员暂无报告数据'
+      if (pc === 0) return '共 ' + tc + ' 份报告,本指标暂无可显示的历史数据'
+      return '共 ' + tc + ' 份报告,本指标目前 ' + pc + ' 期数据(历史数据整理中)'
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.incomplete-banner {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  padding: 10rpx 16rpx;
+  margin: 16rpx 0;
+  border-radius: 8rpx;
+}
+.banner-icon {
+  font-size: 22rpx;
+  margin-right: 8rpx;
+  line-height: 30rpx;
+}
+.banner-text {
+  flex: 1;
+  font-size: 20rpx;
+  line-height: 30rpx;
+}
+.banner-orange {
+  background: #FFF7ED;
+}
+.banner-orange .banner-icon {
+  color: #EA580C;
+}
+.banner-orange .banner-text {
+  color: #C2410C;
+}
+.banner-gray {
+  background: #F8FAFC;
+}
+.banner-gray .banner-icon {
+  color: #94A3B8;
+}
+.banner-gray .banner-text {
+  color: #64748B;
+}
+</style>

+ 6 - 0
cfc-frontend/pages.json

@@ -1042,6 +1042,12 @@
             "navigationBarTitleText": "指标变化"
             "navigationBarTitleText": "指标变化"
           }
           }
         },
         },
+        {
+          "path": "indicator-timeline",
+          "style": {
+            "navigationBarTitleText": "指标时间轴"
+          }
+        },
         {
         {
           "path": "indicator-quicklook",
           "path": "indicator-quicklook",
           "style": {
           "style": {

+ 47 - 4
cfc-frontend/pages/body-detail/index.vue

@@ -143,9 +143,9 @@
                 {{ ind.value || formatNumeric(ind.numericValue) }}
                 {{ ind.value || formatNumeric(ind.numericValue) }}
               </text>
               </text>
               <text class="indicator-placeholder" v-else>暂无数据</text>
               <text class="indicator-placeholder" v-else>暂无数据</text>
-              <text class="indicator-delta" v-if="ind.trendDesc"
+              <text class="indicator-delta" v-if="ind._compareText"
                     :class="ind.trend === 'up' ? 'trend-up' : ind.trend === 'down' ? 'trend-down' : 'trend-stable'">
                     :class="ind.trend === 'up' ? 'trend-up' : ind.trend === 'down' ? 'trend-down' : 'trend-stable'">
-                {{ ind.trend === 'up' ? '▲' : ind.trend === 'down' ? '▼' : '─' }}{{ ind.trendDesc }}
+                {{ ind._compareText }}
               </text>
               </text>
             </view>
             </view>
             <view class="indicator-footer">
             <view class="indicator-footer">
@@ -268,6 +268,7 @@ import {
   getLatestIndicators,
   getLatestIndicators,
   kickFamilyMemberById,
   kickFamilyMemberById,
 } from '../../utils/api.js'
 } from '../../utils/api.js'
+import { formatCompareDate } from '@/utils/indicator-change'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 export default {
 export default {
@@ -512,6 +513,8 @@ export default {
       getLatestIndicators({ memberId: memberId, domain: 'body' }).then(function(res) {
       getLatestIndicators({ memberId: memberId, domain: 'body' }).then(function(res) {
         if (res.code === 200) {
         if (res.code === 200) {
           var data = res.data || []
           var data = res.data || []
+          // 预计算对比文案(模板 :class / 插值不调用带参方法)
+          data.forEach(function(it) { it._compareText = self.buildCompareText(it) })
           self.bodyIndicators = data
           self.bodyIndicators = data
           // 将API数据同步到keyIndicators中匹配的项目
           // 将API数据同步到keyIndicators中匹配的项目
           for (var i = 0; i < self.keyIndicators.length; i++) {
           for (var i = 0; i < self.keyIndicators.length; i++) {
@@ -523,6 +526,9 @@ export default {
                 keyInd.trend = data[j].trend || 'stable'
                 keyInd.trend = data[j].trend || 'stable'
                 keyInd.trendIcon = data[j].trend === 'up' ? '▲' : data[j].trend === 'down' ? '▼' : '─'
                 keyInd.trendIcon = data[j].trend === 'up' ? '▲' : data[j].trend === 'down' ? '▼' : '─'
                 keyInd.trendDesc = data[j].trendDesc || ''
                 keyInd.trendDesc = data[j].trendDesc || ''
+                keyInd.definitionId = data[j].definitionId
+                keyInd.previous = data[j].previous || null
+                keyInd.change = data[j].change || null
                 break
                 break
               }
               }
             }
             }
@@ -532,6 +538,31 @@ export default {
         console.log('[body] loadBodyIndicators failed', e)
         console.log('[body] loadBodyIndicators failed', e)
       })
       })
     },
     },
+    /**
+     * 指标卡片第二行对比文案(design §6.4 body-detail)
+     *   后端已下发 previous/change(T02 落地后)→ 「较 MM-DD ↑ +2.3」
+     *   否则回退现有 trendDesc(「较上次 ±x」)
+     * 注意:前端不按 delta 正负自判改善/恶化,箭头仅表示方向。
+     */
+    buildCompareText: function(ind) {
+      if (!ind) return ''
+      var prev = ind.previous || null
+      if (prev && prev.reportDate) {
+        var d = ind.delta
+        if ((d === null || d === undefined) && ind.change && ind.change.delta !== undefined) { d = ind.change.delta }
+        if (d === null || d === undefined) return ''
+        var arrow = d > 0 ? '↑' : (d < 0 ? '↓' : '→')
+        var sign = d > 0 ? '+' : ''
+        var raw = ind.change && ind.change.delta !== undefined && ind.change.delta !== null ? ind.change.delta : d
+        var absRaw = Math.abs(raw)
+        var txt = absRaw >= 100 ? String(Math.round(raw)) : (absRaw >= 10 ? raw.toFixed(1) : raw.toFixed(2))
+        return '较 ' + formatCompareDate(prev.reportDate, ind.recordedAt, prev.dateSource) + ' ' + arrow + ' ' + sign + txt
+      }
+      var fallback = ind.trendDesc || ''
+      if (!fallback) return ''
+      var icon = ind.trend === 'up' ? '▲' : (ind.trend === 'down' ? '▼' : '─')
+      return icon + fallback
+    },
     loadGutFlora: function(memberId) {
     loadGutFlora: function(memberId) {
       var self = this
       var self = this
       getGutFloraIndicators({ memberId: memberId }).then(function(res) {
       getGutFloraIndicators({ memberId: memberId }).then(function(res) {
@@ -575,9 +606,21 @@ export default {
         }
         }
       }).catch(function() {})
       }).catch(function() {})
     },
     },
+    /**
+     * 指标卡片点击 → 跳指标时间轴页(design §6.4 body-detail)
+     * 传参口径:subjectId=memberId + definitionId + reportType(可默认"全来源")
+     * 降级:后端 T02 未落地时 /api/health/indicators 不下发 definitionId,
+     *      此时保持原行为跳报告列表,避免跳到空页面。
+     */
     goIndicatorDetail: function(ind) {
     goIndicatorDetail: function(ind) {
-      var url = '/pages/health/report-list'
-      uni.navigateTo({ url: url })
+      var memberId = this.activeChildId
+      var defId = ind && ind.definitionId
+      if (memberId && defId !== null && defId !== undefined && defId !== '') {
+        var url = '/pages/health/indicator-timeline?subjectId=' + memberId + '&definitionId=' + defId
+        uni.navigateTo({ url: url })
+        return
+      }
+      uni.navigateTo({ url: '/pages/health/report-list' })
     },
     },
     goToReportList: function() {
     goToReportList: function() {
       uni.navigateTo({ url: '/pages/health/report-list' })
       uni.navigateTo({ url: '/pages/health/report-list' })

+ 172 - 66
cfc-frontend/pages/health/components/TrendChart.vue

@@ -1,10 +1,14 @@
 <template>
 <template>
   <view class="trend-chart-wrap" :style="{ '--font-scale': fontScale }">
   <view class="trend-chart-wrap" :style="{ '--font-scale': fontScale }">
+    <!-- 参考范围背景带说明(refLow/refHigh 均存在时显示) -->
+    <view class="ref-band-hint" v-if="hasRefBand">
+      <text class="ref-band-label">参考范围 {{ refLow }}–{{ refHigh }} {{ refUnit || '' }}</text>
+    </view>
     <view class="trend-container" :style="containerStyle">
     <view class="trend-container" :style="containerStyle">
       <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
       <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
       <view class="trend-tap-layer" @click="onCanvasTap"></view>
       <view class="trend-tap-layer" @click="onCanvasTap"></view>
     </view>
     </view>
-    <view class="trend-legend" v-if="series.length > 1">
+    <view class="trend-legend" v-if="series.length > 1 && mode !== 'pair'">
       <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
       <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
         <view class="legend-line" :style="'border-color:' + s.color"></view>
         <view class="legend-line" :style="'border-color:' + s.color"></view>
         <text class="legend-text">{{ s.name }}</text>
         <text class="legend-text">{{ s.name }}</text>
@@ -15,23 +19,42 @@
 
 
 <script>
 <script>
 import { BrandColors } from '../../../utils/brand-colors.js'
 import { BrandColors } from '../../../utils/brand-colors.js'
+import { chartRenderConfig, axisLabelIndices, normalizeDate, dateWithSourceMark, unitMismatchMark } from '@/utils/indicator-timeline.js'
 /**
 /**
- * TrendChart - 多序列折线图 Canvas 组件
- * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
- * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
+ * TrendChart - 多序列折线图 Canvas 组件(改造版)
+ * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit,dateSource}]}]"
+ *            :mode="mode" :refLow="refLow" :refHigh="refHigh" :allPeriodDates="allPeriodDates" :showAxisLabels="true"
+ *            @pointTap="onPointTap" />
+ * 新增 props:mode / refLow / refHigh / allPeriodDates / showAxisLabels;新增 emit pointTap
+ * 渲染规则(design §6.2):
+ *   参考范围背景带:refLow/refHigh 非 null 时画浅色横条 + 上下界虚线,Y 轴范围必须包含边界
+ *   缺失期断线:遍历 allPeriodDates,当前日期在 points 中无对应点 → 不 lineTo,下一个有效点 moveTo 重新起笔
+ *   上传日标记:dateSource 非 real/unknown 的点,日期标签后加 *
+ *   单位不一致标记:点的 unit !== latest.unit → 值标签后加 *
+ *   X 轴抽稀:按 axisLabelIndices(allPeriodDates.length) 决定
  */
  */
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 export default {
 export default {
   mixins: [fontScaleMixin],
   mixins: [fontScaleMixin],
   props: {
   props: {
-    /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
+    /** 序列数组 [{ name, color, points:[{reportDate,value,unit,dateSource}] }] */
     series: { type: Array, default: function() { return [] } },
     series: { type: Array, default: function() { return [] } },
+    /** 'line' 折线 | 'spark' 迷你 | 'compare' 双期对比条(由 IndicatorChangeRow 承担) */
+    mode: { type: String, default: 'line' },
+    /** 参考范围下界,非 null 时画浅色横条背景带 */
+    refLow: { type: Number, default: null },
+    /** 参考范围上界,非 null 时画浅色横条背景带 */
+    refHigh: { type: Number, default: null },
+    /** X 轴日期全集(含缺失期),用于等距布局与断线。不传则从 series 推导 */
+    allPeriodDates: { type: Array, default: function() { return [] } },
+    /** n ≤ 8 时每点标值;n ≥ 9 默认关闭(由 chartRenderConfig 综合决定,此处仅做兜底) */
+    showValues: { type: Boolean, default: true },
+    /** X 轴日期标签,抽稀由组件内部按 allPeriodDates.length 决定 */
+    showAxisLabels: { type: Boolean, default: true },
     width: { type: Number, default: 690 },
     width: { type: Number, default: 690 },
     height: { type: Number, default: 420 },
     height: { type: Number, default: 420 },
-    /** Y 轴自动范围下界(默认 min-5%) */
-    yMin: { type: Number, default: 0 },
-    /** 是否绘制数据点数值标注 */
-    showValues: { type: Boolean, default: true }
+    /** 最新一期单位,用于单位不一致 * 标记 */
+    latestUnit: { type: String, default: '' }
   },
   },
   data: function() {
   data: function() {
     return {
     return {
@@ -43,7 +66,30 @@ export default {
   },
   },
   computed: {
   computed: {
     containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
     containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
-    canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
+    canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
+    hasRefBand: function() {
+      return this.refLow !== null && this.refLow !== undefined &&
+             this.refHigh !== null && this.refHigh !== undefined
+    },
+    refUnit: function() { return this.latestUnit || '' },
+    /**
+     * 有效 chartMode:mode 允许直接传后端 chartMode('empty'|'single'|'pair'|'line'|'line_aggregated'),
+     * 也允许传语义值('compare' → 'pair',其余按 'line' 处理)。
+     */
+    effectiveChartMode: function() {
+      var m = this.mode
+      if (m === 'empty' || m === 'single' || m === 'pair' || m === 'line' || m === 'line_aggregated') return m
+      if (m === 'compare') return 'pair'
+      return 'line'
+    },
+    /** 图表形态消费结果(消费后端 chartMode,不自行判断) */
+    renderConfig: function() {
+      var n = this._allDatesLength()
+      var cfg = chartRenderConfig(this.effectiveChartMode, n)
+      // showValues prop 作为额外开关(false 时强制不标值)
+      if (!this.showValues) cfg.showValues = false
+      return cfg
+    }
   },
   },
   watch: {
   watch: {
     series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
     series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
@@ -72,6 +118,22 @@ export default {
   },
   },
   methods: {
   methods: {
     getItemKey: function(s, i) { return (s && s.name) || i },
     getItemKey: function(s, i) { return (s && s.name) || i },
+    /** 当前参与 X 轴布局的日期总数(优先 allPeriodDates,否则从 series 推导) */
+    _allDatesLength: function() {
+      var dates = this.allPeriodDates
+      if (Array.isArray(dates) && dates.length > 0) return dates.length
+      var set = {}
+      var s
+      for (var i = 0; i < this.series.length; i++) {
+        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
+        }
+      }
+      return Object.keys(set).length
+    },
 
 
     initCanvas: function(cb) {
     initCanvas: function(cb) {
       var self = this
       var self = this
@@ -121,58 +183,82 @@ export default {
       return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
       return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
     },
     },
 
 
-    /** 计算 X 轴时间范围与 Y 轴数值范围 */
+    /** 计算 X 轴日期全集(含缺失期)与 Y 轴数值范围(Y 范围必须包含 refLow/refHigh) */
     _computeRanges: function(seriesList) {
     _computeRanges: function(seriesList) {
-      var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
+      var min = Infinity, max = -Infinity
       seriesList.forEach(function(s) {
       seriesList.forEach(function(s) {
         s.points.forEach(function(p) {
         s.points.forEach(function(p) {
           var v = parseFloat(p.value)
           var v = parseFloat(p.value)
           if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
           if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
-          if (!firstDate || p.reportDate < firstDate) firstDate = p.reportDate
-          if (!lastDate || p.reportDate > lastDate) lastDate = p.reportDate
         })
         })
       })
       })
+      // 参考范围必须纳入 Y 轴范围,否则背景带跑到图外
+      if (this.hasRefBand) {
+        if (this.refLow < min) min = this.refLow
+        if (this.refHigh > max) max = this.refHigh
+      }
       if (min === Infinity) { min = 0; max = 1 }
       if (min === Infinity) { min = 0; max = 1 }
       if (max === min) { max = min + 1 }
       if (max === min) { max = min + 1 }
       var pad = (max - min) * 0.08
       var pad = (max - min) * 0.08
-      return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
+      return { yMin: Math.max(0, min - pad), yMax: max + pad }
     },
     },
 
 
     _doRender: function() {
     _doRender: function() {
       if (!this.ctx) return
       if (!this.ctx) return
-      var self = this
       var ctx = this.ctx
       var ctx = this.ctx
       var w = this.canvasWidth, h = this.canvasHeight
       var w = this.canvasWidth, h = this.canvasHeight
       var padL = 34, padR = 14, padT = 16, padB = 26
       var padL = 34, padR = 14, padT = 16, padB = 26
       var plotW = w - padL - padR, plotH = h - padT - padB
       var plotW = w - padL - padR, plotH = h - padT - padB
       var seriesList = this._safeSeries(this.series)
       var seriesList = this._safeSeries(this.series)
-      if (seriesList.length === 0) {
-        ctx.clearRect(0, 0, w, h)
-        ctx.fillStyle = BrandColors.appTextSubtle
-        ctx.font = '12px sans-serif'
-        ctx.textAlign = 'center'
-        ctx.fillText('暂无趋势数据', w / 2, h / 2)
-        return
-      }
-      var ranges = this._computeRanges(seriesList)
-      var yMin = ranges.yMin, yMax = ranges.yMax
-      function yOf(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
-      // 日期横轴:按所有序列全部点排序后均分
+      var renderCfg = this.renderConfig
+      var drawLine = renderCfg.draw
+      var showValues = renderCfg.showValues
+      var labelMode = renderCfg.labelMode
+
+      // X 轴日期全集(含缺失期),用于等距布局
       var allDates = []
       var allDates = []
-      seriesList.forEach(function(s) {
-        s.points.forEach(function(p) {
-          if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
+      if (Array.isArray(this.allPeriodDates) && this.allPeriodDates.length > 0) {
+        allDates = this.allPeriodDates.slice()
+      } else {
+        var set = {}
+        seriesList.forEach(function(s) {
+          s.points.forEach(function(p) {
+            if (p.reportDate) set[p.reportDate] = true
+          })
         })
         })
-      })
-      allDates.sort()
+        allDates = Object.keys(set).sort()
+      }
       function xOf(dateStr) {
       function xOf(dateStr) {
         if (allDates.length <= 1) return padL + plotW / 2
         if (allDates.length <= 1) return padL + plotW / 2
         var idx = allDates.indexOf(dateStr)
         var idx = allDates.indexOf(dateStr)
         if (idx < 0) return padL
         if (idx < 0) return padL
-        return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
+        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
       }
       }
+      function yOfC(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
 
 
       ctx.clearRect(0, 0, w, h)
       ctx.clearRect(0, 0, w, h)
+
+      // 参考范围背景带(浅色横条 + 上下界虚线)
+      if (this.hasRefBand && this.refLow !== null && this.refHigh !== null) {
+        var bandAlpha = 0.10
+        ctx.fillStyle = 'rgba(234, 88, 12, 0.10)'   // 浅橙
+        var topY = yOfC(this.refHigh)
+        var botY = yOfC(this.refLow)
+        ctx.fillRect(padL, topY, plotW, Math.max(1, botY - topY))
+        // 上下界虚线
+        ctx.strokeStyle = 'rgba(234, 88, 12, 0.55)'
+        ctx.lineWidth = 1
+        ctx.setLineDash ? ctx.setLineDash([4, 4]) : null
+        ctx.beginPath(); ctx.moveTo(padL, topY); ctx.lineTo(w - padR, topY); ctx.stroke()
+        ctx.beginPath(); ctx.moveTo(padL, botY); ctx.lineTo(w - padR, botY); ctx.stroke()
+        ctx.setLineDash ? ctx.setLineDash([]) : null
+      }
+
       // 网格(4 条横线 + 2 条纵线)
       // 网格(4 条横线 + 2 条纵线)
       ctx.strokeStyle = BrandColors.slateGhost
       ctx.strokeStyle = BrandColors.slateGhost
       ctx.lineWidth = 1
       ctx.lineWidth = 1
@@ -191,33 +277,42 @@ export default {
         var ty = padT + plotH * (ti / 3)
         var ty = padT + plotH * (ti / 3)
         ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
         ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
       }
       }
-      // 折线 + 数据点 + 数值
+
+      // 折线 + 数据点 + 数值(缺失期断线:遍历 allPeriodDates,无点处不连线)
       var palette = [BrandColors.primary, BrandColors.infoSky, BrandColors.success, BrandColors.primary, BrandColors.danger, BrandColors.wealth]
       var palette = [BrandColors.primary, BrandColors.infoSky, BrandColors.success, BrandColors.primary, BrandColors.danger, BrandColors.wealth]
       seriesList.forEach(function(s, si) {
       seriesList.forEach(function(s, si) {
         var color = s.color || palette[si % palette.length]
         var color = s.color || palette[si % palette.length]
         ctx.strokeStyle = color
         ctx.strokeStyle = color
         ctx.lineWidth = 2
         ctx.lineWidth = 2
-        ctx.beginPath()
-        var started = false
-        s.points.forEach(function(p) {
-          var v = parseFloat(p.value)
-          if (isNaN(v)) return
-          var x = xOf(p.reportDate), y = yOf(v)
-          if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
-        })
-        ctx.stroke()
+        if (drawLine) {
+          ctx.beginPath()
+          var started = false
+          allDates.forEach(function(d) {
+            var pt = null
+            for (var pi = 0; pi < s.points.length; pi++) {
+              if (s.points[pi].reportDate === d) { pt = s.points[pi]; break }
+            }
+            if (!pt) return   // 缺失期:不 lineTo
+            var v = parseFloat(pt.value)
+            if (isNaN(v)) return
+            var x = xOf(d), y = yOfC(v)
+            if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
+          })
+          ctx.stroke()
+        }
         // 数据点 + 值
         // 数据点 + 值
         s.points.forEach(function(p) {
         s.points.forEach(function(p) {
           var v = parseFloat(p.value)
           var v = parseFloat(p.value)
           if (isNaN(v)) return
           if (isNaN(v)) return
-          var x = xOf(p.reportDate), y = yOf(v)
+          var x = xOf(p.reportDate), y = yOfC(v)
           ctx.fillStyle = color
           ctx.fillStyle = color
           ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
           ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
-          if (self.showValues) {
+          if (showValues) {
+            var label = unitMismatchMark(p.value, p.unit, self.latestUnit, p.unitMismatch)
             ctx.fillStyle = BrandColors.slate600
             ctx.fillStyle = BrandColors.slate600
             ctx.font = '10px sans-serif'
             ctx.font = '10px sans-serif'
             ctx.textAlign = 'center'
             ctx.textAlign = 'center'
-            ctx.fillText(self._fmtValue(p), x, y - 8)
+            ctx.fillText(label, x, y - 8)
           }
           }
         })
         })
         // 序列名(最右端上方)
         // 序列名(最右端上方)
@@ -226,20 +321,29 @@ export default {
         ctx.textAlign = 'right'
         ctx.textAlign = 'right'
         var last = s.points[s.points.length - 1]
         var last = s.points[s.points.length - 1]
         if (last && !isNaN(parseFloat(last.value))) {
         if (last && !isNaN(parseFloat(last.value))) {
-          ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
+          ctx.fillText(s.name || '', xOf(last.reportDate), yOfC(parseFloat(last.value)) - 14)
         }
         }
       })
       })
-      // X 轴日期标签(首/中/末)
-      ctx.fillStyle = BrandColors.appTextSubtle
-      ctx.font = '10px sans-serif'
-      ctx.textAlign = 'center'
-      if (allDates.length > 0) {
-        ctx.fillText(String(allDates[0]).slice(5), padL, h - 8)
-        if (allDates.length > 1) {
-          var midIdx = Math.floor((allDates.length - 1) / 2)
-          ctx.fillText(String(allDates[midIdx]).slice(5), padL + plotW * (midIdx / (allDates.length - 1)), h - 8)
-          ctx.fillText(String(allDates[allDates.length - 1]).slice(5), w - padR, h - 8)
-        }
+
+      // X 轴日期标签(抽稀,带 * 标记)
+      if (showAxisLabels && allDates.length > 0) {
+        ctx.fillStyle = BrandColors.appTextSubtle
+        ctx.font = '10px sans-serif'
+        ctx.textAlign = 'center'
+        var indices = axisLabelIndices(allDates.length)
+        indices.forEach(function(idx) {
+          var d = allDates[idx]
+          // 找对应点的 dateSource 用于 * 标记
+          var ds = ''
+          seriesList.forEach(function(s) {
+            for (var pi = 0; pi < s.points.length; pi++) {
+              if (s.points[pi].reportDate === d) { ds = s.points[pi].dateSource || ''; break }
+            }
+          })
+          var label = dateWithSourceMark(normalizeDate(d), ds)
+          var x = xOf(d)
+          ctx.fillText(label, x, h - 8)
+        })
       }
       }
     },
     },
 
 
@@ -248,15 +352,9 @@ export default {
       if (v >= 10) return v.toFixed(1)
       if (v >= 10) return v.toFixed(1)
       return v.toFixed(2)
       return v.toFixed(2)
     },
     },
-    _fmtValue: function(p) {
-      var v = parseFloat(p.value)
-      if (isNaN(v)) return '--'
-      return this._fmtNum(v) + (p.unit ? p.unit : '')
-    },
 
 
     onCanvasTap: function(e) {
     onCanvasTap: function(e) {
-      // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
-      this.$emit('chartTap')
+      this.$emit('pointTap')
     }
     }
   }
   }
 }
 }
@@ -267,6 +365,14 @@ export default {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
 }
 }
+.ref-band-hint {
+  font-size: calc(16rpx * var(--font-scale, 1));
+  color: #EA580C;
+  padding: 0 8rpx 4rpx 8rpx;
+}
+.ref-band-label {
+  font-weight: 600;
+}
 .trend-container {
 .trend-container {
   position: relative;
   position: relative;
 }
 }

+ 100 - 18
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -73,22 +73,39 @@
       </view>
       </view>
 
 
       <!-- 指标变化(最近两次报告) -->
       <!-- 指标变化(最近两次报告) -->
-      <view class="change-section" v-if="changeItems.length > 0">
+      <view class="change-section">
         <view class="section-title-bar">
         <view class="section-title-bar">
           <text class="analysis-title">📈 指标变化</text>
           <text class="analysis-title">📈 指标变化</text>
           <text class="analysis-subtitle">与上次报告对比</text>
           <text class="analysis-subtitle">与上次报告对比</text>
         </view>
         </view>
         <view class="change-card">
         <view class="change-card">
-          <view class="change-row" v-for="(item, ci) in changeItems" :key="getChangeKey(item, ci)">
-            <text class="change-name">{{ item.name }}</text>
-            <text class="change-before">{{ item.before ? item.before.value : '--' }}</text>
-            <text class="change-arrow" :class="'change-arrow ' + item._cssClass">{{ item._arrow }}</text>
-            <text class="change-after">{{ item.after ? item.after.value : '--' }}</text>
-            <text class="change-delta" :class="'change-delta ' + item._cssClass">{{ item._deltaText }}</text>
-            <text class="change-trend" :class="'change-trend ' + item._cssClass">{{ item._trendLabel }}</text>
+          <!-- 加载中 -->
+          <view class="change-loading" v-if="changeLoading">
+            <text class="change-loading-text">加载中...</text>
           </view>
           </view>
-          <view class="change-more" @tap="goCurve" v-if="changeItems.length > 0">
-            <text class="change-more-text">查看完整变化曲线 ›</text>
+          <!-- 降级:错误 / 无数据 -->
+          <IndicatorDegrade v-else-if="changeError" scene="error" :retry="reloadChangeBlock" />
+          <IndicatorDegrade v-else-if="changeItems.length === 0" scene="n0" />
+          <view v-else>
+            <view class="change-row" v-for="(item, ci) in changeItems" :key="getChangeKey(item, ci)" @tap="goTimeline(item)">
+              <text class="change-name">{{ item.name }}</text>
+              <text class="change-before">{{ item.before ? item.before.value : '--' }}</text>
+              <text class="change-arrow" :class="'change-arrow ' + item._cssClass">{{ item._arrow }}</text>
+              <text class="change-after">{{ item.after ? item.after.value : '--' }}</text>
+              <text class="change-delta" :class="'change-delta ' + item._cssClass">{{ item._deltaText }}</text>
+              <text class="change-trend" :class="'change-trend ' + item._cssClass">{{ item._trendLabel }}</text>
+            </view>
+            <!-- 还有 N 项 -->
+            <view class="change-more-items" v-if="changeMoreCount > 0">
+              <text class="change-more-items-text">还有 {{ changeMoreCount }} 项</text>
+            </view>
+            <view class="change-more" @tap="goCurve">
+              <text class="change-more-text">查看完整变化曲线 ›</text>
+            </view>
+            <!-- 排序说明(20rpx 灰字) -->
+            <view class="change-sort-note">
+              <text class="change-sort-note-text">按变化百分比排序</text>
+            </view>
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
@@ -360,7 +377,8 @@ import HealthKnowledgePopup from '@/pages/health/components/health-knowledge-pop
 import { BrandColors } from '../../utils/brand-colors.js'
 import { BrandColors } from '../../utils/brand-colors.js'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
-import { resolveChangeStyle, formatDelta, colorClassFor } from '../../utils/indicator-change.js'
+import { resolveChange, formatDelta } from '../../utils/indicator-change.js'
+import IndicatorDegrade from '@/components/indicator/IndicatorDegrade.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 var INDICATOR_TYPE_MAP = {
 var INDICATOR_TYPE_MAP = {
@@ -375,7 +393,7 @@ var INDICATOR_TYPE_MAP = {
 
 
 export default {
 export default {
   mixins: [fontScaleMixin],
   mixins: [fontScaleMixin],
-  components: { HealthKnowledgePopup, FeedbackPopup },
+  components: { HealthKnowledgePopup, FeedbackPopup, IndicatorDegrade },
   data() {
   data() {
     return {
     return {
       reportId: null,
       reportId: null,
@@ -427,10 +445,19 @@ export default {
       extractedGender: '',
       extractedGender: '',
       extractedAge: '',
       extractedAge: '',
       changeItems: [],
       changeItems: [],
+      changeLoading: false,
+      changeError: false,
+      changeTotal: 0,
       _changeLoaded: false
       _changeLoaded: false
     }
     }
   },
   },
   computed: {
   computed: {
+    /** 变化排名块:未展示的剩余项数(「还有 N 项」) */
+    changeMoreCount: function() {
+      var total = this.changeTotal || 0
+      var shown = this.changeItems.length || 0
+      return total > shown ? (total - shown) : 0
+    },
     gutStats: function() {
     gutStats: function() {
       var list = this.gutIndicators || []
       var list = this.gutIndicators || []
       var valid = 0, abnormal = 0
       var valid = 0, abnormal = 0
@@ -1041,32 +1068,66 @@ applyDraftPayload: function(payload) {
       this.nutriGroupOpen = arr
       this.nutriGroupOpen = arr
       if (this.nutritionGroups.length === 0) uni.showToast({ title: '暂无营养指标', icon: 'none' })
       if (this.nutritionGroups.length === 0) uni.showToast({ title: '暂无营养指标', icon: 'none' })
     },
     },
+    /**
+     * 指标变化排名块(design §6.1 #11 / PRD S5)
+     *   - limit 6 → 20,超出显示「还有 N 项」
+     *   - resolveChangeStyle(goodDirection, movement) → resolveChange(trendCode, movement):
+     *     前端不按 delta 正负自判改善/恶化,只消费后端 trendCode 五值
+     *   - 降级统一走 IndicatorDegrade(n0 / error)
+     */
     loadChangeBlock: function() {
     loadChangeBlock: function() {
       var self = this
       var self = this
       if (!this.report || !this.report.subjectId || this._changeLoaded) return
       if (!this.report || !this.report.subjectId || this._changeLoaded) return
       this._changeLoaded = true
       this._changeLoaded = true
+      this.changeLoading = true
+      this.changeError = false
       getIndicatorChangeRanking({
       getIndicatorChangeRanking({
         subjectId: this.report.subjectId,
         subjectId: this.report.subjectId,
         reportType: 'gut_flora',
         reportType: 'gut_flora',
         scope: 'adjacent',
         scope: 'adjacent',
         orderBy: 'magnitude',
         orderBy: 'magnitude',
-        limit: 6
+        limit: 20
       }).then(function(res) {
       }).then(function(res) {
+        self.changeLoading = false
         var items = (res.data && res.data.items) || []
         var items = (res.data && res.data.items) || []
         // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
         // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
         items.forEach(function(it) {
         items.forEach(function(it) {
-          var st = resolveChangeStyle(it.goodDirection, it.movement || 'flat')
-          it._cssClass = colorClassFor(st.cssClass)
+          var st = resolveChange(it.trendCode, it.movement, it.trendLabel)
+          it._cssClass = st.colorClass
           it._arrow = st.arrow
           it._arrow = st.arrow
           it._trendLabel = st.trendLabel
           it._trendLabel = st.trendLabel
-          it._deltaText = formatDelta(it.delta, it.deltaPct)
+          it._deltaText = formatDelta(it.delta, it.deltaPct, it.unitMismatch)
         })
         })
         self.changeItems = items
         self.changeItems = items
-      }).catch(function() {})
+        var total = (res.data && res.data.total !== undefined) ? res.data.total : items.length
+        self.changeTotal = total
+      }).catch(function() {
+        self.changeLoading = false
+        self.changeError = true
+      })
+    },
+    /** 重新加载变化排名块(IndicatorDegrade error 场景重试) */
+    reloadChangeBlock: function() {
+      this._changeLoaded = false
+      this.loadChangeBlock()
     },
     },
     getChangeKey: function(item, i) { return item.definitionId || i },
     getChangeKey: function(item, i) { return item.definitionId || i },
+    /** 行点击 → 指标时间轴页(design §6.4:subjectId + definitionId + reportType='gut_flora') */
+    goTimeline: function(item) {
+      if (!item || item.definitionId === null || item.definitionId === undefined) return
+      if (!this.report || !this.report.subjectId) return
+      uni.navigateTo({
+        url: '/pages/health/indicator-timeline?subjectId=' + this.report.subjectId +
+             '&definitionId=' + item.definitionId + '&reportType=gut_flora'
+      })
+    },
+    /** 「查看完整变化曲线」→ 指标时间轴页(按排名第一项默认选中) */
     goCurve: function() {
     goCurve: function() {
-      uni.navigateTo({ url: '/pages/health/indicator-curve?subjectId=' + this.report.subjectId + '&reportType=gut_flora' })
+      if (!this.report || !this.report.subjectId) return
+      var defId = this.changeItems.length > 0 ? this.changeItems[0].definitionId : null
+      var url = '/pages/health/indicator-timeline?subjectId=' + this.report.subjectId + '&reportType=gut_flora'
+      if (defId !== null && defId !== undefined) { url += '&definitionId=' + defId }
+      uni.navigateTo({ url: url })
     },
     },
     // 问答功能已屏蔽
     // 问答功能已屏蔽
     goAIChat: function() {}
     goAIChat: function() {}
@@ -1365,6 +1426,27 @@ $c-gf-teal-ink:  #004D40; // teal-900 深文字
   font-size: calc(24rpx * var(--font-scale, 1));
   font-size: calc(24rpx * var(--font-scale, 1));
   color: $brand-primary;
   color: $brand-primary;
 }
 }
+.change-more-items {
+  padding: 12rpx 0 4rpx;
+}
+.change-more-items-text {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+.change-sort-note {
+  padding: 8rpx 0 0;
+}
+.change-sort-note-text {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $facade-text-faint;
+}
+.change-loading {
+  padding: 32rpx 0;
+}
+.change-loading-text {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-faint;
+}
 .analysis-card {
 .analysis-card {
   background: $app-surface;
   background: $app-surface;
   border-radius: 16rpx;
   border-radius: 16rpx;

+ 34 - 9
cfc-frontend/pages/health/indicator-quicklook.vue

@@ -19,7 +19,7 @@
 
 
     <!-- 指标列表 -->
     <!-- 指标列表 -->
     <view class="item-list" v-if="items.length > 0">
     <view class="item-list" v-if="items.length > 0">
-      <view class="ql-item" v-for="item in items" :key="getItemKey(item)" @tap="goCurve(getItemId(item))">
+      <view class="ql-item" v-for="item in items" :key="getItemKey(item)" @tap="goTimeline(item)">
         <view class="ql-name-row">
         <view class="ql-name-row">
           <text class="ql-name">{{ item.name }}</text>
           <text class="ql-name">{{ item.name }}</text>
           <text :class="'ql-trend ' + item._cssClass">{{ item._arrow }} {{ item._trendLabel }}</text>
           <text :class="'ql-trend ' + item._cssClass">{{ item._arrow }} {{ item._trendLabel }}</text>
@@ -29,6 +29,12 @@
           <text class="ql-ref">参考: {{ item.refRange || '--' }}</text>
           <text class="ql-ref">参考: {{ item.refRange || '--' }}</text>
           <text class="ql-status" :class="item._statusClass">{{ item.status || '' }}</text>
           <text class="ql-status" :class="item._statusClass">{{ item.status || '' }}</text>
         </view>
         </view>
+        <IndicatorChangeRow
+          :previous="item._previous"
+          :current="item._current"
+          :change="item._change"
+          size="compact"
+        />
         <text class="ql-date">{{ item._dateText }}</text>
         <text class="ql-date">{{ item._dateText }}</text>
       </view>
       </view>
     </view>
     </view>
@@ -58,11 +64,13 @@
 <script>
 <script>
 import { getIndicatorQuickLookup } from '@/utils/api'
 import { getIndicatorQuickLookup } from '@/utils/api'
 import { getFamilyMemberList } from '@/utils/api'
 import { getFamilyMemberList } from '@/utils/api'
-import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
+import { resolveChange } from '@/utils/indicator-change'
+import IndicatorChangeRow from '@/components/indicator/IndicatorChangeRow.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 export default {
 export default {
   mixins: [fontScaleMixin],
   mixins: [fontScaleMixin],
+  components: { IndicatorChangeRow },
   data: function() { return { members: [], currentMemberId: null, categories: [], activeCategory: '', items: [], dimensionScores: [], page: 1, pageSize: 30, totalPages: 1, _onLoadFired: false, _loading: false } },
   data: function() { return { members: [], currentMemberId: null, categories: [], activeCategory: '', items: [], dimensionScores: [], page: 1, pageSize: 30, totalPages: 1, _onLoadFired: false, _loading: false } },
   computed: {
   computed: {
     memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
     memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
@@ -79,8 +87,13 @@ export default {
   methods: {
   methods: {
     onLoad: function() { this.loadMembers(); this._onLoadFired = true },
     onLoad: function() { this.loadMembers(); this._onLoadFired = true },
     onShow: function() { if (this._onLoadFired) { this._onLoadFired = false } else if (this.currentMemberId) { this.reload() } },
     onShow: function() { if (this._onLoadFired) { this._onLoadFired = false } else if (this.currentMemberId) { this.reload() } },
-    goCurve: function(definitionId) {
-      uni.navigateTo({ url: '/pages/health/indicator-curve?definitionId=' + definitionId })
+    /** 跳时间轴页:整页传参口径一致(subjectId + definitionId + reportType) */
+    goTimeline: function(item) {
+      if (!item || item.definitionId === null || item.definitionId === undefined) return
+      var url = '/pages/health/indicator-timeline?subjectId=' + this.currentMemberId +
+        '&definitionId=' + item.definitionId
+      if (item.sourceType) { url += '&reportType=' + encodeURIComponent(item.sourceType) }
+      uni.navigateTo({ url: url })
     },
     },
     loadMembers: function() {
     loadMembers: function() {
       var self = this
       var self = this
@@ -99,17 +112,29 @@ export default {
         self._loading = false
         self._loading = false
         var data = res.data || {}
         var data = res.data || {}
         // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
         // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
-        self.rankItems = (data && data.items) || []
-        self.rankItems.forEach(function(it) {
-          var st = resolveChangeStyle(it.goodDirection, it.movement || 'flat')
-          it._cssClass = colorClassFor(st.cssClass)
+        var list = data.items || []
+        list.forEach(function(it) {
+          // 变化样式:只消费后端 trendCode,前端不按 delta 正负自判改善/恶化
+          var ch = it.change || null
+          var tc = ch ? ch.trendCode : 'first_record'
+          var st = resolveChange(tc, ch && ch.movement, ch && ch.trendLabel)
+          it._cssClass = st.colorClass
           it._arrow = st.arrow
           it._arrow = st.arrow
           it._trendLabel = st.trendLabel
           it._trendLabel = st.trendLabel
           it._statusClass = 'status-' + (it._cssClass || 'normal')
           it._statusClass = 'status-' + (it._cssClass || 'normal')
           it._dateText = it.latestReportDate ? String(it.latestReportDate).slice(0, 10) : '--'
           it._dateText = it.latestReportDate ? String(it.latestReportDate).slice(0, 10) : '--'
+          // IndicatorChangeRow 入参(预计算,避免模板内联对象字面量)
+          it._previous = it.previous || null
+          it._change = ch
+          it._current = {
+            value: it.value,
+            numericValue: it.numericValue,
+            unit: it.unit,
+            reportDate: it.latestReportDate
+          }
         })
         })
         self.categories = data.categories || []
         self.categories = data.categories || []
-        self.items = data.items || []
+        self.items = list
         self.dimensionScores = data.dimensionScores || []
         self.dimensionScores = data.dimensionScores || []
         var total = data.total !== undefined ? data.total : self.items.length
         var total = data.total !== undefined ? data.total : self.items.length
         self.totalPages = Math.max(1, Math.ceil(total / self.pageSize))
         self.totalPages = Math.max(1, Math.ceil(total / self.pageSize))

+ 520 - 0
cfc-frontend/pages/health/indicator-timeline.vue

@@ -0,0 +1,520 @@
+<template>
+  <view class="page" :style="{ '--font-scale': fontScale }">
+    <!-- ① 顶部栏:成员 / 报告类型 -->
+    <view class="filter-bar">
+      <picker :range="memberNames" @change="onMemberChange">
+        <view class="filter-item">{{ currentMemberName }} ▾</view>
+      </picker>
+      <picker :range="reportTypeNames" @change="onReportTypeChange">
+        <view class="filter-item">{{ currentReportTypeName }} ▾</view>
+      </picker>
+    </view>
+
+    <!-- ⑤ 指标横向切换(chip) -->
+    <scroll-view scroll-x class="chip-scroll" v-if="indicatorOptions.length > 1">
+      <view
+        class="indicator-chip"
+        :class="def._chipClass"
+        v-for="def in indicatorOptions"
+        :key="getIndicatorKey(def)"
+        @tap="onIndicatorTap(getIndicatorId(def))"
+      >{{ getIndicatorName(def) }}</view>
+    </scroll-view>
+
+    <!-- ② 指标头部卡 -->
+    <view class="head-card" v-if="hasItem">
+      <view class="head-row">
+        <text class="head-name">{{ item.name }}</text>
+        <text class="status-tag" :class="latestMeta._cssClass">{{ latestMeta._statusText }}</text>
+      </view>
+      <view class="head-value-row">
+        <text class="head-value">{{ latestValueText }}</text>
+        <text class="head-unit" v-if="item.unit">{{ item.unit }}</text>
+        <text class="head-count">共 {{ pointsCount }} 期</text>
+      </view>
+      <view class="head-ref" v-if="item.refRange">参考 {{ item.refRange }}<text v-if="item.unit"> {{ item.unit }}</text></view>
+      <view class="head-change" v-if="showHeadChange">
+        <text class="head-change-text">{{ headChangeText }}</text>
+      </view>
+    </view>
+
+    <!-- ②·5 残缺横幅(P0 红线,不可裁剪) -->
+    <IndicatorIncompleteBanner
+      :points-count="pointsCount"
+      :total-report-count="totalReportCount"
+    />
+
+    <!-- ③ 图表区(形态由 chartMode 决定) -->
+    <view class="chart-card">
+      <!-- loading -->
+      <view class="chart-loading" v-if="loading">
+        <text class="loading-text">加载中...</text>
+      </view>
+
+      <!-- error -->
+      <view v-else-if="errorState">
+        <IndicatorDegrade scene="error" :retry="retryLoad" />
+      </view>
+
+      <!-- empty (n=0) -->
+      <view v-else-if="chartMode === 'empty'">
+        <text class="empty-text">暂无该指标数据</text>
+      </view>
+
+      <!-- single (n=1):不画图,单值卡 + 首次记录 -->
+      <view v-else-if="chartMode === 'single'">
+        <view class="single-card">
+          <text class="single-value">{{ latestValueText }}</text>
+          <text class="single-unit" v-if="item.unit">{{ item.unit }}</text>
+          <text class="single-status" :class="latestMeta._cssClass">{{ latestMeta._statusText }}</text>
+        </view>
+        <text class="single-note">首次记录 · 暂无对比</text>
+      </view>
+
+      <!-- pair (n=2):不画折线,双期对比条 -->
+      <view v-else-if="chartMode === 'pair'">
+        <view class="pair-bar">
+          <view class="pair-point">
+            <text class="pair-dot"></text>
+            <text class="pair-value">{{ previousValueText }}</text>
+            <text class="pair-date">{{ previousDateText }}</text>
+          </view>
+          <view class="pair-arrow-box">
+            <text class="pair-arrow" :class="pairArrowClass">{{ pairArrow }}</text>
+            <text class="pair-delta" :class="pairArrowClass">{{ pairDeltaText }}</text>
+            <text class="pair-trend" :class="pairArrowClass">{{ pairTrendText }}</text>
+          </view>
+          <view class="pair-point">
+            <text class="pair-dot"></text>
+            <text class="pair-value">{{ latestValueText }}</text>
+            <text class="pair-date">{{ latestDateText }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- line / line_aggregated -->
+      <view v-else>
+        <view class="granularity-switch" v-if="chartMode === 'line_aggregated'">
+          <text class="gran-btn" :class="granularity === 'month' ? 'gran-active' : ''" @tap="onGranularityTap('month')">按月</text>
+          <text class="gran-btn" :class="granularity === 'raw' ? 'gran-active' : ''" @tap="onGranularityTap('raw')">按原始点</text>
+        </view>
+        <TrendChart
+          :series="series"
+          :mode="chartMode"
+          :ref-low="refLow"
+          :ref-high="refHigh"
+          :all-period-dates="allPeriodDates"
+          :latest-unit="item.unit"
+          :show-values="chartShowValues"
+          :show-axis-labels="true"
+        />
+      </view>
+    </view>
+
+    <!-- ④ 期次明细表(倒序,最新在最上) -->
+    <view class="period-card" v-if="periodRows.length > 0">
+      <view class="section-title">期次明细</view>
+      <view class="period-row" v-for="(row, ri) in periodRows" :key="getPeriodKey(row, ri)">
+        <text class="period-date">{{ row._dateText }}</text>
+        <text class="period-value" :class="row.isMissing ? 'period-missing' : ''">{{ row._valueText }}</text>
+        <text class="period-status" v-if="row.status">{{ row.status }}</text>
+        <text class="period-delta">{{ row.deltaText }}</text>
+        <text class="period-trend" :class="row.isMissing ? 'period-missing' : ''">{{ row.trendLabel }}</text>
+      </view>
+      <view class="period-footnote" v-if="hasUploadDate">
+        <text>* 未识别到采样日期,按上传日期记录</text>
+      </view>
+      <view class="period-footnote" v-if="hasUnitMismatch">
+        <text>* 该期单位与其他期不同,未做换算</text>
+      </view>
+    </view>
+
+    <!-- ⑥ 合规 footer(常显不可关) -->
+    <view class="footer">
+      <text class="footer-text">本页数据来自检测报告,趋势仅表示数值变化,不构成医学诊断或治疗建议。</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList, getIndicatorTimeline, getIndicatorSourceTypes, getIndicatorChangeRanking } from '@/utils/api'
+import { resolveChange, formatCompareDate } from '@/utils/indicator-change.js'
+import { consumeChartMode, buildAllPeriodDates, buildPeriodRows, formatPeriodDate, dateWithSourceMark, normalizeDate } from '@/utils/indicator-timeline.js'
+import IndicatorIncompleteBanner from '@/components/indicator/IndicatorIncompleteBanner.vue'
+import IndicatorDegrade from '@/components/indicator/IndicatorDegrade.vue'
+import TrendChart from '@/pages/health/components/TrendChart.vue'
+import fontScaleMixin from '@/mixins/font-scale.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  components: { IndicatorIncompleteBanner, IndicatorDegrade, TrendChart },
+  data: function() {
+    return {
+      members: [],
+      currentMemberId: null,
+      reportTypeOptions: [],
+      activeReportType: '',
+      definitionId: null,
+      indicatorOptions: [],
+      item: null,
+      loading: false,
+      errorState: false,
+      granularity: 'raw',
+      _hasLeftPage: false,
+      _pendingEntry: false,
+      reportId: null
+    }
+  },
+  computed: {
+    memberNames: function() {
+      return this.members.map(function(m) { return m.name || m.nickname || '' })
+    },
+    currentMemberName: function() {
+      var self = this
+      var m = this.members.find(function(x) { return x.id === self.currentMemberId })
+      return (m && (m.name || m.nickname)) || '选择成员'
+    },
+    reportTypeNames: function() {
+      return this.reportTypeOptions.map(function(o) { return o.label })
+    },
+    currentReportTypeName: function() {
+      var self = this
+      var o = this.reportTypeOptions.find(function(x) { return x.sourceType === self.activeReportType })
+      return (o && o.label) || ''
+    },
+    hasItem: function() { return !!this.item },
+    /** 最新一期状态(预计算,模板 :class 不调用带参方法) */
+    latestMeta: function() {
+      if (!this.item || !this.item.latest) return { _cssClass: 'status-normal', _statusText: '正常' }
+      var s = this.item.latest.status ? String(this.item.latest.status).trim() : ''
+      var cls = 'status-normal'
+      if (s === '偏高' || s === 'high') cls = 'status-high'
+      else if (s === '偏低' || s === 'low') cls = 'status-low'
+      return { _cssClass: cls, _statusText: s || '正常' }
+    },
+    pointsCount: function() { return this.item && this.item.pointsCount || 0 },
+    totalReportCount: function() { return this.item && this.item.totalReportCount || 0 },
+    chartMode: function() { return this.item && this.item.chartMode || 'empty' },
+    series: function() {
+      if (!this.item || !Array.isArray(this.item.points)) return []
+      var pts = this.item.points
+      return [{
+        name: this.item.name,
+        color: '#F97316',
+        points: pts.map(function(p) {
+          return {
+            reportDate: String(p.reportDate).slice(0, 10),
+            value: p.value,
+            numericValue: p.numericValue,
+            unit: p.unit,
+            dateSource: p.dateSource
+          }
+        })
+      }]
+    },
+    refLow: function() { return this.item && this.item.refLow !== undefined ? this.item.refLow : null },
+    refHigh: function() { return this.item && this.item.refHigh !== undefined ? this.item.refHigh : null },
+    allPeriodDates: function() {
+      if (!this.item) return []
+      return buildAllPeriodDates(this.item.points || [], this.item.missingPeriods || [])
+    },
+    periodRows: function() {
+      if (!this.item) return []
+      var rows = buildPeriodRows(this.item.points || [], this.item.missingPeriods || [])
+      var latestDate = this.item.latest && this.item.latest.reportDate
+      var latestUnit = this.item.unit
+      return rows.map(function(r) {
+        var d = r.reportDate
+        var nd = normalizeDate(d)
+        r._dateText = dateWithSourceMark(formatPeriodDate(nd, latestDate), r.dateSource)
+        if (r.isMissing) {
+          r._valueText = '该期未检测'
+        } else {
+          r._valueText = r.value + (r.unit ? ' ' + r.unit : '')
+        }
+        return r
+      })
+    },
+    latestValueText: function() {
+      if (!this.item || !this.item.latest) return '--'
+      var v = this.item.latest
+      return (v.numericValue !== undefined && v.numericValue !== null) ? String(v.numericValue) : v.value
+    },
+    previousValueText: function() {
+      if (!this.item || !this.item.change || !this.item.change.previous) return '--'
+      var v = this.item.change.previous
+      return (v.numericValue !== undefined && v.numericValue !== null) ? String(v.numericValue) : v.value
+    },
+    latestDateText: function() {
+      if (!this.item || !this.item.latest || !this.item.latest.reportDate) return ''
+      return formatPeriodDate(this.item.latest.reportDate, this.item.latest.reportDate)
+    },
+    previousDateText: function() {
+      if (!this.item || !this.item.change || !this.item.change.previous || !this.item.change.previous.reportDate) return ''
+      return formatPeriodDate(this.item.change.previous.reportDate, this.item.latest && this.item.latest.reportDate)
+    },
+    showHeadChange: function() { return this.pointsCount >= 2 },
+    headChangeText: function() {
+      if (!this.item || !this.item.change) return ''
+      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
+    },
+    pairArrow: function() {
+      if (!this.item || !this.item.change) return '→'
+      var mv = this.item.change.movement
+      return mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→')
+    },
+    pairDeltaText: function() {
+      if (!this.item || !this.item.change) return ''
+      var ch = this.item.change
+      var d = ch.delta, pct = ch.deltaPct
+      if (d === null || d === undefined || isNaN(d)) return '—'
+      var sign = d > 0 ? '+' : ''
+      if (pct === null || pct === undefined || isNaN(pct) || ch.unitMismatch) return sign + d + ' (—)'
+      var ps = pct > 0 ? '+' : ''
+      return sign + d + ' (' + ps + pct + '%)'
+    },
+    pairTrendText: function() {
+      if (!this.item || !this.item.change) return ''
+      return this.item.change.trendLabel
+    },
+    pairArrowClass: function() {
+      if (!this.item || !this.item.change) return ''
+      var tc = this.item.change.trendCode
+      if (tc === 'improved') return 'pair-impr'
+      if (tc === 'worsened') return 'pair-worst'
+      return 'pair-flat'
+    },
+    chartShowValues: function() {
+      if (!this.item) return false
+      var n = this.pointsCount
+      var cfg = consumeChartMode(this.chartMode, n)
+      return cfg.showValues
+    },
+    hasUploadDate: function() {
+      if (!this.item || !Array.isArray(this.item.points)) return false
+      return this.item.points.some(function(p) { return p.dateSource && p.dateSource !== 'real' && p.dateSource !== 'unknown' })
+    },
+    hasUnitMismatch: function() {
+      return this.item && this.item.unitMismatch === true
+    }
+  },
+  onLoad: function(options) {
+    var o = options || {}
+    if (o.subjectId) this.currentMemberId = Number(o.subjectId)
+    if (o.definitionId) this.definitionId = Number(o.definitionId)
+    if (o.reportType) this.activeReportType = String(o.reportType)
+    if (o.reportId) this.reportId = Number(o.reportId)
+    this._pendingEntry = true
+    this.loadMembers()
+  },
+  onShow: function() {
+    // 首次进入由 onLoad 链路加载;从其他页返回本页时按需刷新
+    if (this._hasLeftPage) { this._hasLeftPage = false; this.reloadAll() }
+  },
+  onHide: function() {
+    this._hasLeftPage = true
+  },
+  methods: {
+    loadMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var arr = (res.data && res.data.list) || (res.data instanceof Array ? res.data : [])
+        self.members = arr
+        if (!self.currentMemberId && arr.length > 0) { self.currentMemberId = arr[0].id }
+        if (self._pendingEntry) { self._pendingEntry = false }
+        self.loadSourceTypes()
+      })
+    },
+    onMemberChange: function(e) {
+      var idx = Number(e.detail.value)
+      var m = this.members[idx]
+      if (m) { this.currentMemberId = m.id; this.reloadAll() }
+    },
+    loadSourceTypes: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      getIndicatorSourceTypes({ subjectId: this.currentMemberId }).then(function(res) {
+        var arr = (res.data instanceof Array) ? res.data : []
+        if (arr.length > 0) {
+          self.reportTypeOptions = arr
+          var first = arr[0].sourceType
+          if (!self.activeReportType) { self.activeReportType = first }
+        } else {
+          self.reportTypeOptions = [
+            { sourceType: 'gut_flora', label: '肠道菌群' },
+            { sourceType: 'food_recommend', label: '食材推荐' }
+          ]
+          if (!self.activeReportType) { self.activeReportType = 'gut_flora' }
+        }
+        self.onReportTypeSelected()
+      })
+    },
+    onReportTypeChange: function(e) {
+      var idx = Number(e.detail.value)
+      var o = this.reportTypeOptions[idx]
+      if (o) { this.activeReportType = o.sourceType; this.onReportTypeSelected() }
+    },
+    onReportTypeSelected: function() {
+      this.loadIndicatorOptions()
+      this.loadTimeline()
+    },
+    loadIndicatorOptions: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      // 以 change-ranking 为候选列表(与 indicator-curve 一致),limit 20 覆盖多数指标
+      var rt = (!this.activeReportType || this.activeReportType === 'food_recommend') ? null : this.activeReportType
+      getIndicatorChangeRanking({ subjectId: this.currentMemberId, reportType: rt, scope: 'adjacent', orderBy: 'magnitude', limit: 20 }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        self.indicatorOptions = items.map(function(it) {
+          return {
+            definitionId: it.definitionId,
+            name: it.name,
+            _chipClass: it.definitionId === self.definitionId ? 'chip-active' : ''
+          }
+        })
+        // 入口传入的 definitionId 不在榜单中时,追加占位(名称由 timeline 负载补全)
+        if (self.definitionId && !self.indicatorOptions.some(function(d) { return d.definitionId === self.definitionId })) {
+          self.indicatorOptions.push({ definitionId: self.definitionId, name: String(self.definitionId), _chipClass: 'chip-active' })
+        }
+      })
+    },
+    loadTimeline: function() {
+      var self = this
+      if (!this.currentMemberId || !this.definitionId) return
+      self.loading = true
+      self.errorState = false
+      getIndicatorTimeline({
+        subjectId: this.currentMemberId,
+        definitionIds: [this.definitionId],
+        sourceType: this.activeReportType || undefined,
+        granularity: this.granularity
+      }).then(function(res) {
+        self.loading = false
+        if (res.code === 200) {
+          var items = (res.data && res.data.items) || []
+          if (items.length > 0) {
+            self.item = items[0]
+            // 选中态同步到 chip;入口 definitionId 不在榜单中时补全名称
+            var found = false
+            self.indicatorOptions = self.indicatorOptions.map(function(d) {
+              var active = d.definitionId === self.definitionId
+              if (active) { found = true; d.name = self.item.name }
+              d._chipClass = active ? 'chip-active' : ''
+              return d
+            })
+            if (!found) {
+              self.indicatorOptions.unshift({
+                definitionId: self.item.definitionId,
+                name: self.item.name,
+                _chipClass: 'chip-active'
+              })
+            }
+          } else {
+            self.item = null
+          }
+        } else {
+          self.errorState = true
+          self.item = null
+        }
+      }).catch(function() {
+        self.loading = false
+        self.errorState = true
+      })
+    },
+    onIndicatorTap: function(definitionId) {
+      if (definitionId === this.definitionId) return
+      this.definitionId = definitionId
+      this.loadTimeline()
+    },
+    onGranularityTap: function(gran) {
+      if (gran === this.granularity) return
+      this.granularity = gran
+      this.loadTimeline()
+    },
+    retryLoad: function() { this.loadTimeline() },
+    getIndicatorKey: function(def) { return def ? (def.definitionId || def.name) : '' },
+    getIndicatorId: function(def) { return def ? def.definitionId : null },
+    getIndicatorName: function(def) { return (def && def.name) || '' },
+    /** 重载全部数据(切成员 / 下拉刷新) */
+    reloadAll: function() {
+      this.loadSourceTypes()
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+/* ---- layout ---- */
+.page { padding: 24rpx; display: flex; flex-direction: column; gap: 24rpx; min-height: 100vh; box-sizing: border-box; }
+
+/* ---- ① 顶部筛选栏 ---- */
+.filter-bar { display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: 16rpx; }
+.filter-item { flex: 1; display: flex; align-items: center; justify-content: center; height: 72rpx; padding: 0 16rpx; background: #f7f8fa; border-radius: 16rpx; font-size: calc(26rpx * var(--font-scale, 1)); color: #374151; }
+
+/* ---- ⑤ 指标 chip ---- */
+.chip-scroll { display: flex; flex-direction: row; align-items: center; padding: 0 4rpx; min-height: 28rpx; white-space: nowrap; }
+.indicator-chip { display: flex; align-items: center; justify-content: center; height: 48rpx; padding: 0 24rpx; margin-right: 16rpx; border-radius: 24rpx; font-size: calc(24rpx * var(--font-scale, 1)); color: #374151; background: #f3f4f6; flex-shrink: 0; }
+.indicator-chip.chip-active { background: #F97316; color: #fff; font-weight: 600; }
+
+/* ---- ② 指标头部卡 ---- */
+.head-card { background: #fff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
+.head-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
+.head-name { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 700; color: #111827; }
+.status-tag { font-size: calc(22rpx * var(--font-scale, 1)); padding: 6rpx 16rpx; border-radius: 20rpx; }
+.status-tag.status-normal { background: #ecfdf5; color: #047857; }
+.status-tag.status-high { background: #fff7ed; color: #c2410c; }
+.status-tag.status-low { background: #eff6ff; color: #1d4ed8; }
+.head-value-row { display: flex; flex-direction: row; align-items: baseline; margin-bottom: 8rpx; }
+.head-value { font-size: calc(48rpx * var(--font-scale, 1)); font-weight: 800; color: #111827; line-height: 1.2; }
+.head-unit { font-size: calc(24rpx * var(--font-scale, 1)); color: #6b7280; margin-left: 8rpx; }
+.head-count { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; margin-left: 16rpx; }
+.head-ref { font-size: calc(22rpx * var(--font-scale, 1)); color: #6b7280; margin-bottom: 8rpx; }
+.head-change { padding: 12rpx 16rpx; background: #fef9c3; border-radius: 12rpx; margin-top: 8rpx; }
+.head-change-text { font-size: calc(24rpx * var(--font-scale, 1)); color: #92400e; }
+
+/* ---- ③ 图表卡 ---- */
+.chart-card { background: #fff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); min-height: 320rpx; }
+.chart-loading { display: flex; align-items: center; justify-content: center; height: 200rpx; }
+.loading-text { font-size: calc(24rpx * var(--font-scale, 1)); color: #9ca3af; }
+.empty-text { display: block; text-align: center; font-size: calc(24rpx * var(--font-scale, 1)); color: #9ca3af; margin-top: 80rpx; }
+.single-card { display: flex; flex-direction: row; align-items: baseline; justify-content: center; padding: 24rpx 0; }
+.single-value { font-size: calc(56rpx * var(--font-scale, 1)); font-weight: 800; color: #111827; line-height: 1.2; }
+.single-unit { font-size: calc(26rpx * var(--font-scale, 1)); color: #6b7280; margin-left: 8rpx; }
+.single-status { font-size: calc(22rpx * var(--font-scale, 1)); padding: 4rpx 12rpx; border-radius: 12rpx; margin-left: 16rpx; }
+.single-note { display: block; text-align: center; font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; margin-top: 8rpx; }
+.pair-bar { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 24rpx 0; }
+.pair-point { display: flex; flex-direction: column; align-items: center; }
+.pair-dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: #F97316; }
+.pair-value { font-size: calc(32rpx * var(--font-scale, 1)); font-weight: 700; color: #111827; margin-top: 8rpx; }
+.pair-date { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; margin-top: 4rpx; }
+.pair-arrow-box { display: flex; flex-direction: column; align-items: center; padding: 0 24rpx; }
+.pair-arrow { font-size: calc(40rpx * var(--font-scale, 1)); line-height: 1; }
+.pair-delta { font-size: calc(24rpx * var(--font-scale, 1)); margin-top: 6rpx; }
+.pair-trend { font-size: calc(22rpx * var(--font-scale, 1)); margin-top: 4rpx; }
+.pair-impr { color: #047857; }
+.pair-worst { color: #dc2626; }
+.pair-flat { color: #6b7280; }
+.granularity-switch { display: flex; flex-direction: row; justify-content: flex-end; margin-bottom: 16rpx; }
+.gran-btn { font-size: calc(22rpx * var(--font-scale, 1)); color: #6b7280; padding: 8rpx 16rpx; margin-left: 8rpx; border-radius: 8rpx; border: 1rpx solid #e5e7eb; }
+.gran-btn.gran-active { background: #F97316; color: #fff; border-color: #F97316; }
+
+/* ---- ④ 期次明细表 ---- */
+.period-card { background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
+.section-title { font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 700; color: #111827; margin-bottom: 16rpx; }
+.period-row { display: flex; flex-direction: row; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f3f4f6; }
+.period-row:last-child { border-bottom: none; }
+.period-date { width: 140rpx; font-size: calc(24rpx * var(--font-scale, 1)); color: #374151; }
+.period-value { flex: 1; font-size: calc(26rpx * var(--font-scale, 1)); color: #111827; font-weight: 600; }
+.period-value.period-missing { color: #9ca3af; font-weight: 400; }
+.period-status { width: 100rpx; font-size: calc(22rpx * var(--font-scale, 1)); text-align: right; }
+.period-delta { width: 200rpx; font-size: calc(24rpx * var(--font-scale, 1)); text-align: right; color: #6b7280; }
+.period-trend { width: 160rpx; font-size: calc(24rpx * var(--font-scale, 1)); text-align: right; }
+.period-footnote { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; padding: 12rpx 0 0 0; }
+
+/* ---- ⑥ 合规 footer ---- */
+.footer { padding: 24rpx 0; }
+.footer-text { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; text-align: center; line-height: 1.6; }
+</style>

+ 211 - 6
cfc-frontend/pages/health/report-detail.vue

@@ -197,6 +197,29 @@
               </view>
               </view>
             </view>
             </view>
 
 
+            <!-- 变化对照卡片头(总数/异常数 + 较上次 ±N + 三档筛选 + 开关) -->
+            <view class="compare-head" v-if="hasCompareData">
+              <!-- 历史数据整理中提示条(pendingCount > 0) -->
+              <view class="compare-pending" v-if="comparePendingCount > 0">
+                <text class="compare-pending-text">部分历史数据整理中,暂不可对比</text>
+              </view>
+              <!-- 数据来源 / 报告份数 -->
+              <view class="compare-meta">
+                <text class="compare-meta-text">{{ compareMetaText }}</text>
+              </view>
+              <!-- 较上次 ±N -->
+              <view class="compare-summary" v-if="compareDeltaText">
+                <text class="compare-summary-text">{{ compareDeltaText }}</text>
+              </view>
+              <!-- 三档筛选 + 显示变化对照开关 -->
+              <view class="compare-filter-row">
+                <text class="compare-filter" :class="indicatorFilter === 'all' ? 'compare-filter-active' : ''" @tap="onIndicatorFilterTap('all')">全部</text>
+                <text class="compare-filter" :class="indicatorFilter === 'changed' ? 'compare-filter-active' : ''" @tap="onIndicatorFilterTap('changed')">有变化 {{ compareChangedCount }}</text>
+                <text class="compare-filter" :class="indicatorFilter === 'abnormal' ? 'compare-filter-active' : ''" @tap="onIndicatorFilterTap('abnormal')">异常 {{ totalAbnormalCount }}</text>
+                <text class="compare-toggle" @tap="toggleShowChangeCompare">{{ showChangeCompare ? '隐藏变化对照' : '显示变化对照' }}</text>
+              </view>
+            </view>
+
             <!-- 按指标类型分组折叠 -->
             <!-- 按指标类型分组折叠 -->
             <view class="indicator-group" v-for="(group, gIdx) in indicatorGroups" :key="gIdx">
             <view class="indicator-group" v-for="(group, gIdx) in indicatorGroups" :key="gIdx">
               <view class="indicator-group-header" @click="toggleGroup(gIdx)">
               <view class="indicator-group-header" @click="toggleGroup(gIdx)">
@@ -216,11 +239,20 @@
                     <text class="ind-help-btn" @click.stop="showHelp(ind)">?</text>
                     <text class="ind-help-btn" @click.stop="showHelp(ind)">?</text>
                     <input class="ind-name" :class="'ind-name-' + _cssClass(ind.status)" v-model="ind.indicatorName" />
                     <input class="ind-name" :class="'ind-name-' + _cssClass(ind.status)" v-model="ind.indicatorName" />
                     <input class="ind-value" :class="'ind-value-' + _cssClass(ind.status)" v-model="ind.indicatorValue" />
                     <input class="ind-value" :class="'ind-value-' + _cssClass(ind.status)" v-model="ind.indicatorValue" />
+                    <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>
                   </view>
                   </view>
-                  <view class="ind-detail" v-if="ind.unit || ind.refRange">
+                  <view class="ind-detail" v-if="ind.refRange">
                     <text class="ind-ref" v-if="ind.refRange">参考: {{ ind.refRange }}</text>
                     <text class="ind-ref" v-if="ind.refRange">参考: {{ ind.refRange }}</text>
-                    <text class="ind-unit" v-if="ind.unit">{{ ind.unit }}</text>
                   </view>
                   </view>
+                  <!-- 第二行:变化对照(按 §4.1 trendCode 五值渲染) -->
+                  <IndicatorChangeRow
+                    v-if="showChangeCompare && hasCompareData"
+                    :previous="ind._previous"
+                    :current="ind._current"
+                    :change="ind._change"
+                  />
                   <view class="ind-symptoms" v-if="ind.symptoms">
                   <view class="ind-symptoms" v-if="ind.symptoms">
                     <text class="ind-symptom-label">症状:</text>
                     <text class="ind-symptom-label">症状:</text>
                     <input class="ind-symptom-input" v-model="ind.symptoms" />
                     <input class="ind-symptom-input" v-model="ind.symptoms" />
@@ -228,6 +260,11 @@
                 </view>
                 </view>
               </view>
               </view>
             </view>
             </view>
+
+            <!-- 卡片底部合规话术 -->
+            <view class="compare-compliance" v-if="hasCompareData">
+              <text class="compare-compliance-text">变化对比仅供参考,不构成医学诊断。</text>
+            </view>
           </view>
           </view>
 
 
           <!-- 肠道菌群 -->
           <!-- 肠道菌群 -->
@@ -417,14 +454,16 @@
 <script>
 <script>
 import ReportBlocksRenderer from './report-blocks-renderer.vue'
 import ReportBlocksRenderer from './report-blocks-renderer.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
+import IndicatorChangeRow from '@/components/indicator/IndicatorChangeRow.vue'
 import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
-import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge } from '../../utils/api.js'
+import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge, getReportIndicators } from '../../utils/api.js'
+import { resolveChange } from '@/utils/indicator-change'
 import { formatDate } from '../../utils/format.js'
 import { formatDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 export default {
 export default {
   mixins: [fontScaleMixin],
   mixins: [fontScaleMixin],
-  components: { ReportBlocksRenderer, FeedbackPopup },
+  components: { ReportBlocksRenderer, FeedbackPopup, IndicatorChangeRow },
   data() {
   data() {
     return {
     return {
       reportType: 'gut_flora',
       reportType: 'gut_flora',
@@ -484,7 +523,16 @@ export default {
       // view 模式报告日期修改(仅日期,走 /report/edit 复用现有接口)
       // view 模式报告日期修改(仅日期,走 /report/edit 复用现有接口)
       dateEditing: false,
       dateEditing: false,
       dateInput: '',
       dateInput: '',
-      lastDetail: null
+      lastDetail: null,
+      // ===== 变化对照(T04 第二段:/api/health/report/indicators)=====
+      // 接口失败时静默降级为现状单期渲染(不显示变化对照区域)
+      reportIndicatorMap: {},
+      indicatorSummary: null,
+      indicatorMeta: null,
+      // 三档筛选:all 全部 / changed 有变化 / abnormal 异常
+      indicatorFilter: 'all',
+      // D1 裁定:「显示变化对照」默认开
+      showChangeCompare: true
     }
     }
   },
   },
   computed: {
   computed: {
@@ -507,12 +555,65 @@ export default {
       }
       }
       return count
       return count
     },
     },
+    // ===== 变化对照(T04 第二段)=====
+    hasCompareData: function() { return !!this.indicatorSummary },
+    comparePendingCount: function() { return this.indicatorSummary && this.indicatorSummary.pendingCount ? this.indicatorSummary.pendingCount : 0 },
+    compareChangedCount: function() { return this.indicatorSummary && this.indicatorSummary.changedCount ? this.indicatorSummary.changedCount : 0 },
+    compareDeltaText: function() {
+      var s = this.indicatorSummary
+      if (!s || s.abnormalDelta == null) return ''
+      var d = Number(s.abnormalDelta)
+      var sign = d > 0 ? '+' : ''
+      return '较上次 ' + sign + d
+    },
+    compareMetaText: function() {
+      var m = this.indicatorMeta
+      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 + ' 份报告') }
+      return parts.length ? parts.join('  ·  ') : ''
+    },
+    /**
+     * 指标分组 + 变化对照合并 + 三档筛选(T04 第二段)
+     * 说明:
+     *  - 变化数据按 indicatorName 与 payload 指标合并(编辑态下 name 可改,合并以接口返回的 indicatorName 为键)
+     *  - 预计算所有展示字段(/:class 不调用带参方法)
+     */
     indicatorGroups() {
     indicatorGroups() {
       var groups = []
       var groups = []
       var orderMap = {}
       var orderMap = {}
       var list = this.payload.indicators || []
       var list = this.payload.indicators || []
+      var map = this.reportIndicatorMap || {}
+      var filter = this.indicatorFilter
       for (var i = 0; i < list.length; i++) {
       for (var i = 0; i < list.length; i++) {
         var ind = list[i]
         var ind = list[i]
+        var ext = map[ind.indicatorName] || null
+        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)
+        // 预计算字段(模板不调用带参方法)
+        ind._previous = prev
+        ind._change = ch
+        ind._definitionId = ext ? ext.definitionId : null
+        ind._cardChangeCss = st.colorClass
+        ind._isChanged = !!(ch && ch.comparable && tc && tc !== 'flat')
+        ind._isAbnormal = this.isAbnormalStatus(ind.status)
+        // 简报徽标(短词)
+        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 = '持平' }
+        ind._badgeText = (badge && ch) ? badge : ''
+        var curValue = ind.indicatorValue
+        ind._current = {
+          value: curValue,
+          numericValue: (ext && ext.numericValue !== undefined) ? ext.numericValue : null,
+          unit: ind.unit || '',
+          reportDate: (this.indicatorMeta && this.indicatorMeta.reportDate) ? this.indicatorMeta.reportDate : (this.headInfo ? this.headInfo.reportDateText : '')
+        }
+        // 三档筛选
+        if (filter === 'changed' && !ind._isChanged) continue
+        if (filter === 'abnormal' && !ind._isAbnormal) continue
         var cat = ind.category || '其他指标'
         var cat = ind.category || '其他指标'
         var gIdx = orderMap[cat]
         var gIdx = orderMap[cat]
         if (gIdx === undefined) {
         if (gIdx === undefined) {
@@ -521,7 +622,7 @@ export default {
           groups.push({ category: cat, items: [], abnormalCount: 0 })
           groups.push({ category: cat, items: [], abnormalCount: 0 })
         }
         }
         groups[gIdx].items.push(ind)
         groups[gIdx].items.push(ind)
-        if (this.isAbnormalStatus(ind.status)) groups[gIdx].abnormalCount++
+        if (ind._isAbnormal) groups[gIdx].abnormalCount++
       }
       }
       return groups
       return groups
     },
     },
@@ -658,6 +759,8 @@ export default {
           }
           }
           self.needBind = !!report.needBind
           self.needBind = !!report.needBind
           self.selectedMemberId = report.subjectId || null
           self.selectedMemberId = report.subjectId || null
+          // 变化对照数据(T04 第二段,失败静默降级为单期渲染)
+          self.loadReportIndicators()
         } else {
         } else {
           uni.showToast({ title: res.message || '加载失败', icon: 'none' })
           uni.showToast({ title: res.message || '加载失败', icon: 'none' })
         }
         }
@@ -666,6 +769,48 @@ export default {
         uni.showToast({ title: '加载异常', icon: 'none' })
         uni.showToast({ title: '加载异常', icon: 'none' })
       })
       })
     },
     },
+    /**
+     * 加载本报告指标的多期对照数据(T04 第二段)
+     * 接口:POST /api/health/report/indicators  { reportId, withPrevious: true }
+     * 失败时静默降级(不弹 toast、不影响报告主体展示),保持现状单期渲染。
+     */
+    loadReportIndicators() {
+      var self = this
+      if (!self.reportId) return
+      getReportIndicators({ reportId: self.reportId, withPrevious: true }).then(function(res) {
+        if (!res || res.code !== 200 || !res.data) return
+        var data = res.data
+        var map = {}
+        var items = data.items || []
+        for (var i = 0; i < items.length; i++) {
+          var it = items[i]
+          var name = it.indicatorName || it.name
+          if (!name) continue
+          // 同名指标保留第一条(后端已按 definitionId 去重)
+          if (!map[name]) map[name] = it
+        }
+        self.reportIndicatorMap = map
+        self.indicatorSummary = data.summary || null
+        self.indicatorMeta = {
+          reportId: data.reportId,
+          reportType: data.reportType,
+          subjectId: data.subjectId,
+          reportDate: data.reportDate,
+          dateSource: data.dateSource,
+          olderReportCount: data.olderReportCount
+        }
+      }).catch(function() {
+        // 静默降级:保持现状单期渲染
+      })
+    },
+    /** 三档筛选切换:all 全部 / changed 有变化 / abnormal 异常 */
+    onIndicatorFilterTap(filter) {
+      this.indicatorFilter = filter
+    },
+    /** 「显示变化对照 / 隐藏变化对照」开关 */
+    toggleShowChangeCompare() {
+      this.showChangeCompare = !this.showChangeCompare
+    },
     defaultPayload() {
     defaultPayload() {
       return {
       return {
         type: 'gut_flora',
         type: 'gut_flora',
@@ -1781,6 +1926,66 @@ $c-rptd-orange-line: #FFD4A8; // 暖橙描边
 .status-high { background: $fortune-1-tint; color: $mat-orange-strong; }
 .status-high { background: $fortune-1-tint; color: $mat-orange-strong; }
 .status-low { background: $mat-blue-tint; color: $mat-blue-strong; }
 .status-low { background: $mat-blue-tint; color: $mat-blue-strong; }
 .status-abnormal { background: $fortune-0-tint; color: $status-danger-strong-2; }
 .status-abnormal { background: $fortune-0-tint; color: $status-danger-strong-2; }
+.status-deficient { background: $fortune-1-tint; color: $mat-orange-strong; }
+.status-insufficient { background: $fortune-1-tint; color: $mat-orange-strong; }
+.status-warning { background: $fortune-1-tint; color: $mat-orange-strong; }
+.status-high-risk { background: $fortune-0-tint; color: $status-danger-strong-2; }
+.status-low-risk { background: $mat-green-tint; color: $mat-green-strong; }
+.status-not-detected { background: $uni-border-color-light; color: $uni-text-color-secondary; }
+
+/* ===== T04 变化对照卡片头 ===== */
+.compare-head { padding: 0rpx 24rpx 12rpx; }
+.compare-pending {
+  background: $fortune-1-tint;
+  border-radius: 12rpx;
+  padding: 12rpx 16rpx;
+  margin-bottom: 12rpx;
+}
+.compare-pending-text { font-size: calc(22rpx * var(--font-scale, 1)); color: $mat-orange-strong; line-height: 1.5; }
+.compare-meta { margin-bottom: 8rpx; }
+.compare-meta-text { font-size: calc(22rpx * var(--font-scale, 1)); color: $app-text-subtle; }
+.compare-summary { margin-bottom: 12rpx; }
+.compare-summary-text { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-strong; font-weight: 600; }
+.compare-filter-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.compare-filter {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $uni-text-color-secondary;
+  background: $app-surface;
+  border: 2rpx solid $facade-fill;
+  border-radius: 24rpx;
+  padding: 6rpx 20rpx;
+}
+.compare-filter-active {
+  color: $mat-blue-strong;
+  border-color: $mat-blue-strong;
+  background: $mat-blue-tint;
+  font-weight: 600;
+}
+.compare-toggle {
+  margin-left: auto;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $mat-blue-strong;
+  padding: 6rpx 4rpx;
+}
+.compare-compliance { padding: 12rpx 24rpx 4rpx; }
+.compare-compliance-text { font-size: calc(20rpx * var(--font-scale, 1)); color: $app-text-subtle; }
+.ind-unit-inline { font-size: calc(22rpx * var(--font-scale, 1)); color: $uni-text-color-secondary; flex-shrink: 0; }
+.ind-change-badge {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $mat-blue-strong;
+  background: $mat-blue-tint;
+  border-radius: 8rpx;
+  padding: 4rpx 12rpx;
+  flex-shrink: 0;
+}
+
+/* ===== 指标输入框状态色(补齐 status-* 全档位) ===== */
 
 
 /* ===== 鎸囨爣甯姪鎸夐挳 ===== */
 /* ===== 鎸囨爣甯姪鎸夐挳 ===== */
 .ind-help-btn {
 .ind-help-btn {

+ 12 - 0
cfc-frontend/utils/api.js

@@ -3443,6 +3443,18 @@ export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick
 /** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
 /** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
 export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})
 export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})
 
 
+// ===== 指标多期展示(DS-2026-10-25-01,T02/T03 桥接;依赖后端 T02 接口部署后才可端到端联调)=====
+/** 【修复缺失导出,S7】成员已有指标时序的报告类型列表 */
+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, sourceType? } */
+export const getIndicatorPeriods = (params) => request('/api/indicator/periods', 'POST', params || {})
+/** [admin] 存量时序回填:{ subjectId?, reportId?, limit?, dryRun? } */
+export const backfillIndicatorValues = (params) => request('/api/admin/indicator/backfill', 'POST', params || {})
+
 // 管理端:任务提醒配置
 // 管理端:任务提醒配置
 export function getReminderConfigByUser(userId) {
 export function getReminderConfigByUser(userId) {
   return request('/api/admin/task/reminder-config/get?userId=' + userId, 'POST', {})
   return request('/api/admin/task/reminder-config/get?userId=' + userId, 'POST', {})

+ 128 - 19
cfc-frontend/utils/indicator-change.js

@@ -1,10 +1,122 @@
+/* ==============================================================
+ * 指标变化展示规则纯函数 - 单一事实来源
+ * 规则来源:design.md §6.6 + PRD §5.5 + §6.2.1 trendCode 五值契约
+ * 核心约束:前端**不得**根据 `delta` 的正负自行推导"改善/恶化",
+ *          只能消费后端 `trendCode` 枚举。这是保证全系统一致的唯一可靠方式。
+ * ============================================================== */
+
+/**
+ * 【单一事实来源】由后端 trendCode + movement 推导展示样式。
+ * 所有展示变化的位置(报告详情/身维度/速查/菌群/时间轴/管理端)必须走这里。
+ *
+ * trendCode 五值枚举(含 D12 的 pending_backfill):
+ *   'improved' | 'worsened' | 'flat' | 'first_record' | 'pending_backfill'
+ *
+ * 关键约定:
+ *   1. trendLabel 优先取后端下发的值,前端不改写(合规:文案统一)
+ *   2. 前端【禁止】用 previous == null 自行判断是"首次"还是"整理中" —— 只能看 trendCode
+ *   3. movement === 'none' 时不渲染箭头
+ *
+ * @param {string} trendCode
+ * @param {string} movement 'up'|'down'|'flat'|'changed'|'unchanged'|'none'
+ * @param {string} backendLabel 后端下发的 trendLabel(优先使用,为空时才用本地兜底)
+ * @returns {{trendLabel:string, cssClass:string, arrow:string, colorClass:string, showArrow:boolean}}
+ */
+export function resolveChange(trendCode, movement, backendLabel) {
+  var mv = movement || 'flat'
+  var arrow = mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→')
+  var label = backendLabel || ''
+
+  // D12 新增两档:无对比对象。灰色、无箭头。
+  if (trendCode === 'first_record') {
+    return { trendLabel: label || '首次记录 · 暂无对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
+  }
+  if (trendCode === 'pending_backfill') {
+    return { trendLabel: label || '历史数据整理中,暂无法对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
+  }
+
+  if (trendCode === 'improved') {
+    return { trendLabel: label || '改善', cssClass: 'impr', arrow: arrow, colorClass: 'green-text', showArrow: true }
+  }
+  if (trendCode === 'worsened') {
+    return { trendLabel: label || '恶化', cssClass: 'worst', arrow: arrow, colorClass: 'red-text', showArrow: true }
+  }
+  return { trendLabel: label || '基本持平', cssClass: 'flat', arrow: mv === 'none' ? '' : arrow, colorClass: 'gray-text', showArrow: mv !== 'none' }
+}
+
 /**
 /**
- * 指标变化展示规则纯函数
- * 规则来源:design.md §8 变化颜色与箭头规则
- * 颜色映射:改善=绿 / 恶化=红 / 中性=灰
+ * 非数值指标:有变化 / 无变化,不给颜色(合规红线)
  */
  */
+export function resolveTextChange(movement) {
+  return movement === 'unchanged'
+    ? { trendLabel: '无变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
+    : { trendLabel: '有变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
+}
+
+/**
+ * Δ 展示文本(PRD §6.4)。列表窄位省略单位由调用方传 unit='' 实现。
+ * @param {number|null} delta
+ * @param {number|null} deltaPct
+ * @param {boolean} unitMismatch 单位跨期不一致 → 百分比显示 '—'
+ */
+export function formatDelta(delta, deltaPct, unitMismatch) {
+  if (delta === null || delta === undefined || isNaN(delta)) return '--'
+  var sign = delta > 0 ? '+' : ''
+  var d = roundSig(delta)                       // PRD §6.4 有效数字规则
+  if (unitMismatch || deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
+    return sign + d + ' (—)'
+  }
+  return sign + d + ' (' + (deltaPct > 0 ? '+' : '') + deltaPct + '%)'
+}
+
+/** PRD §6.4 有效数字:≥100 整数;10–100 一位小数;<10 两位小数 */
+export function roundSig(v) {
+  var a = Math.abs(v)
+  if (a >= 100) return String(Math.round(v))
+  if (a >= 10)  return v.toFixed(1)
+  return v.toFixed(2)
+}
 
 
 /**
 /**
+ * 「较 MM-DD」日期文案(PRD §6.6)
+ * @param {string} prevDate 'yyyy-MM-dd'
+ * @param {string} currDate 'yyyy-MM-dd' 用于判断是否同年
+ * @param {string} dateSource 'real'|'upload'|'confirm'|'unknown'
+ */
+export function formatCompareDate(prevDate, currDate, dateSource) {
+  if (!prevDate) return '日期未知'
+  var star = (dateSource && dateSource !== 'real' && dateSource !== 'unknown') ? '*' : ''
+  if (!currDate) return prevDate + star
+  var sameYear = String(prevDate).slice(0, 4) === String(currDate).slice(0, 4)
+  return (sameYear ? String(prevDate).slice(5) : prevDate) + star
+}
+
+/**
+ * 图表形态 → 是否画图 / 是否标值(消费后端 chartMode,不自行判断)
+ * @param {string} chartMode 'empty'|'single'|'pair'|'line'|'line_aggregated'
+ * @param {number} n 期数
+ */
+export function chartRenderConfig(chartMode, n) {
+  if (chartMode === 'empty' || chartMode === 'single') return { draw: false, showValues: false, labelMode: 'none' }
+  if (chartMode === 'pair')                            return { draw: false, showValues: true,  labelMode: 'all' }
+  if (chartMode === 'line_aggregated')                 return { draw: true,  showValues: false, labelMode: 'sparse' }
+  return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' }
+}
+
+/** X 轴标签下标抽稀(PRD §5.2 选型表) */
+export function axisLabelIndices(n) {
+  if (n <= 8) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all }
+  if (n <= 19) return unique([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1])
+  return unique([0, Math.floor(n / 2), n - 1])
+}
+
+// ---------- 兼容保留 ----------
+
+/**
+ * @deprecated 仅供无 trendCode 的老数据兜底(如 member_indicator_current.trend)。
+ *  新代码一律用 resolveChange(trendCode, movement)。
+ *  保留原实现不删(member_indicator_current 链路仍只有 trend 字段,无 trendCode)。
+ *
  * 由 good_direction × movement 推导展示样式
  * 由 good_direction × movement 推导展示样式
  * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
  * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
  * @param {string} movement up|down|flat
  * @param {string} movement up|down|flat
@@ -36,29 +148,26 @@ export function resolveChangeStyle(goodDirection, movement) {
   return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' }
   return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' }
 }
 }
 
 
-/**
- * Δ 展示文本 "±x (±x%)"
- * @param {number|null} delta
- * @param {number|null} deltaPct
- * @returns {string}
- */
-export function formatDelta(delta, deltaPct) {
-  if (delta === null || delta === undefined || isNaN(delta)) return '--'
-  var sign = delta > 0 ? '+' : ''
-  if (deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
-    return sign + delta
-  }
-  var pctSign = deltaPct > 0 ? '+' : ''
-  return sign + delta + ' (' + pctSign + deltaPct + '%)'
-}
-
 /**
 /**
  * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text
  * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text
  * @param {string} cssClass resolveChangeStyle 返回的类
  * @param {string} cssClass resolveChangeStyle 返回的类
  * @returns {string}
  * @returns {string}
  */
  */
 export function colorClassFor(cssClass) {
 export function colorClassFor(cssClass) {
+  if (!cssClass) return 'gray-text'
   if (cssClass.indexOf('impr') === 0) return 'green-text'
   if (cssClass.indexOf('impr') === 0) return 'green-text'
   if (cssClass.indexOf('worst') === 0) return 'red-text'
   if (cssClass.indexOf('worst') === 0) return 'red-text'
   return 'gray-text'
   return 'gray-text'
 }
 }
+
+/** 去重并升序(下标抽稀用) */
+function unique(arr) {
+  var seen = {}
+  var out = []
+  for (var i = 0; i < arr.length; i++) {
+    var v = arr[i]
+    if (!seen[v]) { seen[v] = true; out.push(v) }
+  }
+  out.sort(function(a, b) { return a - b })
+  return out
+}

+ 229 - 0
cfc-frontend/utils/indicator-timeline.js

@@ -0,0 +1,229 @@
+/* ==============================================================
+ * utils/indicator-timeline.js - 指标时间轴纯函数
+ * 规则来源:design §6.6 / PRD §5.3 / §4.2
+ * 职责:chartMode 消费、X 轴抽稀、日期格式化、缺失期合并
+ * 约束:所有日期解析走 utils/format.js 的 parseDate,禁 new Date(string)
+ * ============================================================== */
+import { parseDate } from './format.js'
+
+/**
+ * 将 points ∪ missingPeriods 合并为等距 X 轴日期全集(升序)
+ * @param {Array} points   [{ reportDate, ... }]
+ * @param {Array} missing  [{ reportDate, ... }]
+ * @returns {string[]} 排序后的不重复日期字符串 'yyyy-MM-dd'
+ */
+export function buildAllPeriodDates(points, missing) {
+  var set = {}
+  var i
+  if (Array.isArray(points)) {
+    for (i = 0; i < points.length; i++) {
+      var d = points[i] && points[i].reportDate
+      if (d) set[d] = true
+    }
+  }
+  if (Array.isArray(missing)) {
+    for (i = 0; i < missing.length; i++) {
+      var d = missing[i] && missing[i].reportDate
+      if (d) set[d] = true
+    }
+  }
+  return Object.keys(set).sort()
+}
+
+/**
+ * 把期数映射为期次明细表行(倒序,最新在最上),每行带相对更早一期的 Δ
+ * 缺失期(missingPeriods)也并入表内,显示「该期未检测」(灰,不连线)
+ * @param {Array} points   按时间升序的观测点
+ * @param {Array} missing  缺失期 [{ reportDate, reportId, dateSource }]
+ * @returns {Array} 按时间倒序的 rows
+ */
+export function buildPeriodRows(points, missing) {
+  var pts = Array.isArray(points) ? points.slice() : []
+  // 按日期升序(走 parseDate,禁 new Date(string))
+  pts.sort(function(a, b) {
+    var ta = parseDate(a && a.reportDate)
+    var tb = parseDate(b && b.reportDate)
+    if (!ta && !tb) return 0
+    if (!ta) return 1
+    if (!tb) return -1
+    return ta.getTime() - tb.getTime()
+  })
+  // 每个有效点相对「更早一期有效点」的 Δ
+  var deltaMap = {}
+  for (var i = 0; i < pts.length; i++) {
+    var cur = parseNum(pts[i].numericValue !== undefined ? pts[i].numericValue : pts[i].value)
+    var prev = i > 0 ? parseNum(pts[i - 1].numericValue !== undefined ? pts[i - 1].numericValue : pts[i - 1].value) : NaN
+    if (!isNaN(cur) && !isNaN(prev)) {
+      var d = cur - prev
+      deltaMap[pts[i].reportDate] = {
+        delta: d,
+        deltaPct: prev === 0 ? null : (d / prev * 100),
+        trendLabel: d > 0 ? '升高' : (d < 0 ? '降低' : '持平')
+      }
+    } else {
+      deltaMap[pts[i].reportDate] = { delta: null, deltaPct: null, trendLabel: '首次记录' }
+    }
+  }
+  // 合并有效点 + 缺失期,倒序
+  var all = []
+  for (i = 0; i < pts.length; i++) {
+    all.push({ date: pts[i].reportDate, point: pts[i], missing: false })
+  }
+  var mis = Array.isArray(missing) ? missing : []
+  for (i = 0; i < mis.length; i++) {
+    if (!mis[i] || !mis[i].reportDate) continue
+    all.push({ date: mis[i].reportDate, point: null, missing: true, dateSource: mis[i].dateSource })
+  }
+  all.sort(function(a, b) {
+    var ta = parseDate(a.date)
+    var tb = parseDate(b.date)
+    if (!ta && !tb) return 0
+    if (!ta) return 1
+    if (!tb) return -1
+    return tb.getTime() - ta.getTime()
+  })
+
+  var rows = []
+  for (i = 0; i < all.length; i++) {
+    var item = all[i]
+    if (item.missing) {
+      rows.push({
+        reportDate: item.date,
+        dateSource: item.dateSource || '',
+        value: '',
+        unit: '',
+        status: '',
+        isMissing: true,
+        delta: null,
+        deltaPct: null,
+        trendLabel: '该期未检测',
+        deltaText: '—'
+      })
+    } else {
+      var p = item.point
+      var dm = deltaMap[p.reportDate] || { delta: null, deltaPct: null, trendLabel: '首次记录' }
+      rows.push({
+        reportDate: p.reportDate,
+        dateSource: p.dateSource || '',
+        value: p.value,
+        numericValue: p.numericValue,
+        unit: p.unit,
+        status: p.status,
+        isMissing: false,
+        delta: dm.delta,
+        deltaPct: dm.deltaPct,
+        trendLabel: dm.trendLabel,
+        deltaText: formatRowDelta(dm.delta, dm.deltaPct)
+      })
+    }
+  }
+  return rows
+}
+
+/** 期次行的 Δ 文本:+1.23 (+10.0%) / -0.48 / — */
+function formatRowDelta(delta, deltaPct) {
+  if (delta === null || delta === undefined || isNaN(delta)) return '—'
+  var sign = delta > 0 ? '+' : ''
+  var d = roundSig(delta)
+  if (deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
+    return sign + d + ' (—)'
+  }
+  var psign = deltaPct > 0 ? '+' : ''
+  return sign + d + ' (' + psign + roundPct(deltaPct) + '%)'
+}
+
+/** 有效数字(§6.4):≥100 整数;10–100 一位小数;<10 两位小数 */
+export function roundSig(v) {
+  var a = Math.abs(v)
+  if (a >= 100) return String(Math.round(v))
+  if (a >= 10)  return v.toFixed(1)
+  return v.toFixed(2)
+}
+
+function roundPct(v) {
+  return v.toFixed(1)
+}
+
+function parseNum(v) {
+  var n = Number(v)
+  return isNaN(n) ? NaN : n
+}
+
+/**
+ * 报告日期展示:MM-DD(同年时省略年份)
+ * @param {string} dateStr 'yyyy-MM-dd'
+ * @param {string} refDateStr 'yyyy-MM-dd' 用于判断同年
+ */
+export function formatPeriodDate(dateStr, refDateStr) {
+  if (!dateStr) return '--'
+  var s = String(dateStr)
+  if (s.length >= 10) {
+    var ymd = s.slice(0, 10)
+    if (refDateStr && String(refDateStr).slice(0, 4) === ymd.slice(0, 4)) {
+      return ymd.slice(5)
+    }
+    return ymd
+  }
+  return s
+}
+
+/**
+ * chartMode 消费:判定是否画图、是否标值、标签模式(消费后端 chartMode,不自行判断)
+ * @param {string} chartMode 'empty'|'single'|'pair'|'line'|'line_aggregated'
+ * @param {number} n 期数
+ * @returns {{draw:boolean, showValues:boolean, labelMode:'none'|'all'|'sparse'}}
+ */
+export function consumeChartMode(chartMode, n) {
+  if (chartMode === 'empty' || chartMode === 'single') return { draw: false, showValues: false, labelMode: 'none' }
+  if (chartMode === 'pair') return { draw: false, showValues: true, labelMode: 'all' }
+  if (chartMode === 'line_aggregated') return { draw: true, showValues: false, labelMode: 'sparse' }
+  if (chartMode === 'line') return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' }
+  // 兜底:后端未下发 chartMode 时,按期数保守判定(不影响已下发场景)
+  if (!n || n < 2) return { draw: false, showValues: false, labelMode: 'none' }
+  if (n === 2) return { draw: false, showValues: true, labelMode: 'all' }
+  return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' }
+}
+
+/** X 轴标签下标抽稀(§5.2 选型表) */
+export function axisLabelIndices(n) {
+  if (!n || n <= 8) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all }
+  if (n <= 19) return uniqueIdx([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1], n)
+  return uniqueIdx([0, Math.floor(n / 2), n - 1], n)
+}
+
+function uniqueIdx(arr, n) {
+  var out = [], seen = {}
+  for (var i = 0; i < arr.length; i++) {
+    var idx = arr[i]
+    if (idx < 0) idx = 0
+    if (idx >= n) idx = n - 1
+    if (!seen[idx]) { seen[idx] = true; out.push(idx) }
+  }
+  out.sort(function(a, b) { return a - b })
+  return out
+}
+
+/** 日期字符串归一化为 'yyyy-MM-dd'(走 parseDate,禁 new Date(string)) */
+export function normalizeDate(dateStr) {
+  if (!dateStr) return ''
+  var d = parseDate(dateStr)
+  if (!d) return String(dateStr).slice(0, 10)
+  var y = d.getFullYear()
+  var m = (d.getMonth() + 1).toString().padStart ? (d.getMonth() + 1).toString().padStart(2, '0') : ('0' + (d.getMonth() + 1)).slice(-2)
+  var day = d.getDate().toString().padStart ? d.getDate().toString().padStart(2, '0') : ('0' + d.getDate()).slice(-2)
+  return y + '-' + m + '-' + day
+}
+
+/** 上传日标记:dateSource 非 real/unknown 时,日期后加 * */
+export function dateWithSourceMark(dateStr, dateSource) {
+  var star = (dateSource && dateSource !== 'real' && dateSource !== 'unknown') ? ' *' : ''
+  return String(dateStr || '') + star
+}
+
+/** 单位不一致标记:值后加 * */
+export function unitMismatchMark(value, unit, latestUnit, isMismatch) {
+  var s = String(value == null ? '--' : value)
+  if (unit) s += ' ' + unit
+  if (isMismatch) s += ' *'
+  return s
+}