ソースを参照

feat(T03): 新增组件 — IndicatorChangeRow/IndicatorDegrade/IndicatorIncompleteBanner/SparkLine

- IndicatorChangeRow.vue: §6.2 三档分支(normal/degrade/incomparable)+ skipGaps 第三行 + resolveChange 消费 trendCode
- IndicatorDegrade.vue: §6.2 九档降级场景 + incomplete 新增档,文案硬编码
- IndicatorIncompleteBanner.vue: §1.2.2 残缺横幅,四条件文案(含 n=0+t>0/n=0+t=0/n<t/n=t),隐藏逻辑
- SparkLine.vue: §6.2 view 点阵实现,禁用 canvas;8 点等距抽样首末点强制保留;末点主色实心
E2E Test Bot 2 週間 前
親
コミット
82a7e63671

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

@@ -0,0 +1,149 @@
+<template>
+  <view class="indicator-change-row">
+    <block v-if="isDegrade || isIncomparable">
+      <text class="change-degrade gray-text">{{ resolved.trendLabel }}</text>
+    </block>
+    <block v-else>
+      <text class="change-compare-label gray-text-subtle">较{{ prevDateText }}{{ prevDateStar }}:</text>
+      <text class="change-values">{{ prevValue }} → {{ currValue }}{{ unitText }}{{ valueStar }}</text>
+      <text v-if="showArrow" class="change-arrow" :class="colorClass">{{ arrow }}</text>
+      <text class="change-delta" :class="colorClass">{{ deltaText }}</text>
+      <text class="change-trend" :class="colorClass">{{ trendLabel }}</text>
+    </block>
+    <view v-if="showSkipHint && skipGaps > 0" class="skip-row">
+      <text class="skip-text">ⓘ {{ skipHintText }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { resolveChange, formatDelta } from '@/utils/indicator-change.js'
+
+export default {
+  name: 'IndicatorChangeRow',
+  props: {
+    /** 上一期 { reportDate, dateSource, value, numericValue, unit, status } | null */
+    previous: { type: Object, default: null },
+    /** 本期 { value, numericValue, unit } */
+    current: { type: Object, required: true },
+    /** { 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: {
+    /**
+     * 【单一事实来源】消费后端 trendCode,优先使用后端下发的 trendLabel。
+     * 兼容老数据:无 trendCode 时按 previous 有无走 first_record 兜底,
+     * 前端不按 delta 正负自行推导改善/恶化。
+     */
+    resolved: function() {
+      var ch = this.change
+      var code = ch && ch.trendCode
+      if (code) {
+        return resolveChange(code, ch.movement, ch.trendLabel)
+      }
+      // 老数据兜底:无 trendCode 时,previous 为空 → 首次记录
+      if (!this.previous) return resolveChange('first_record', 'none')
+      return resolveChange('flat', 'flat')
+    },
+    /** 降级态:首次记录 / 历史整理中 */
+    isDegrade: function() {
+      var ch = this.change
+      return ch && (ch.trendCode === 'first_record' || ch.trendCode === 'pending_backfill')
+    },
+    /** 不可比态:非数值 / 单位不可比(trendCode 仍走前端,文案由后端下发) */
+    isIncomparable: function() {
+      var ch = this.change
+      return ch && ch.comparable === false && !this.isDegrade
+    },
+    colorClass: function() {
+      return this.resolved && this.resolved.colorClass || 'gray-text'
+    },
+    showArrow: function() {
+      return this.resolved && this.resolved.showArrow === true
+    },
+    arrow: function() {
+      return this.resolved ? this.resolved.arrow : ''
+    },
+    trendLabel: function() {
+      return this.resolved ? this.resolved.trendLabel : ''
+    },
+    /** 上一期日期文案 + 上传日 * 标记 */
+    prevDateText: function() {
+      var pd = this.previous && this.previous.reportDate
+      var cd = this.current && this.current.reportDate
+      if (!pd) return '未知日期'
+      if (!cd) return pd
+      var sameYear = String(pd).slice(0, 4) === String(cd).slice(0, 4)
+      return sameYear ? String(pd).slice(5) : pd
+    },
+    prevDateStar: function() {
+      var ds = this.previous && this.previous.dateSource
+      return (ds && ds !== 'real' && ds !== 'unknown') ? '*' : ''
+    },
+    prevValue: function() {
+      if (!this.previous) return '--'
+      return this.previous.value != null ? this.previous.value : '--'
+    },
+    currValue: function() {
+      if (!this.current) return '--'
+      return this.current.value != null ? this.current.value : '--'
+    },
+    unitText: function() {
+      if (this.size === 'compact') return ''
+      return this.current && this.current.unit ? ' ' + this.current.unit : ''
+    },
+    /** 单位不一致 → 值后加 * */
+    valueStar: function() {
+      var ch = this.change
+      return ch && ch.unitMismatch ? '*' : ''
+    },
+    /** Δ 展示文本(PRD §6.4:unitMismatch 时百分比显示 —) */
+    deltaText: function() {
+      var ch = this.change
+      if (!ch || ch.delta == null) return '--'
+      return formatDelta(ch.delta, ch.deltaPct, ch.unitMismatch)
+    },
+    skipGaps: function() {
+      var ch = this.change
+      return ch && ch.skipGaps ? ch.skipGaps : 0
+    },
+    skipHintText: function() {
+      if (this.skipDates && this.skipDates.length > 0) {
+        return this.skipDates.join('、') + ' 多次未检测该项,对比的是更早期'
+      }
+      return '中间 ' + this.skipGaps + ' 期未检测该项'
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.indicator-change-row {
+  padding: 8rpx 0;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  line-height: 1.6;
+}
+.change-compare-label { color: $app-text-subtle; }
+.gray-text-subtle { color: $app-text-subtle; }
+.change-values { color: $facade-text-strong; }
+.change-delta { font-weight: 600; }
+.change-trend { font-weight: 500; }
+
+/* 变化着色:改善=绿 / 恶化=橙红 / 中性=灰(双通道解耦,颜色=变化性质) */
+.green-text { color: $status-success-strong-2; }
+.red-text   { color: $status-danger-deep; }
+.gray-text  { color: $app-text-subtle; }
+
+/* 第三行:跨期跳比提示 */
+.skip-row { display: block; padding-top: 4rpx; }
+.skip-text {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $mat-orange-base;
+}
+</style>

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

@@ -0,0 +1,107 @@
+<template>
+  <view class="indicator-degrade">
+    <text class="degrade-text" :class="degradeClass">{{ degradeText }}</text>
+    <view v-if="scene === 'error' && retry" class="degrade-retry" @tap="onRetry">
+      <text class="retry-text">重试</text>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * 指标趋势降级态组件
+ * PRD §5.5 九档降级 + incomplete 新增档
+ * 文案硬编码(合规要求),页面不得改写。
+ */
+export default {
+  name: 'IndicatorDegrade',
+  props: {
+    /**
+     * 场景枚举:
+     * 'n0'            暂无该指标数据
+     * 'n1'            首次记录 · 暂无对比
+     * 'missing'       该期未检测
+     * 'invalid'       该期无有效数值
+     * 'upload-date'   * 未识别到采样日期,按上传日期记录
+     * 'skip'          ⓘ {dates} 未检测该项,对比的是 {date}
+     * 'unit-mismatch' * 该期单位与其他期不同,未做换算
+     * 'other-type'    其他报告类型有 N 期数据,暂不支持跨类型对比
+     * 'error'         趋势数据加载失败,请下拉重试
+     * 'incomplete'    残缺横幅(pointsCount < totalReportCount)
+     */
+    scene: { type: String, required: true },
+    dates: { type: Array, default: function() { return [] } },
+    date: { type: String, default: '' },
+    count: { type: Number, default: 0 },
+    /** 残缺横幅用:总报告数 */
+    totalReportCount: { type: Number, default: 0 },
+    /** 残缺横幅用:当前有数据的期数 */
+    pointsCount: { type: Number, default: 0 },
+    retry: { type: Function, default: null }
+  },
+  computed: {
+    degradeText: 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') return 'ⓘ ' + (this.dates && this.dates.length > 0 ? this.dates.join('、') : '') + ' 未检测该项'
+      if (s === 'unit-mismatch') return '* 该期单位与其他期不同,未做换算'
+      if (s === 'other-type') return '其他报告类型有 ' + this.count + ' 期数据,暂不支持跨类型对比'
+      if (s === 'error') return '趋势数据加载失败,请下拉重试'
+      if (s === 'incomplete') {
+        // §1.2.2 四条件文案(由组件硬编码,页面不得改写)
+        var n = this.pointsCount || 0
+        var total = this.totalReportCount || 0
+        if (n === 0 && total === 0) return '该成员暂无报告数据'
+        if (n === 0 && total > 0) return '该成员共 ' + total + ' 份报告,本指标暂无可显示的历史数据'
+        if (n > 0 && n < total) return '该成员共 ' + total + ' 份报告,本指标目前 ' + n + ' 期数据(历史数据整理中)'
+        return ''
+      }
+      return ''
+    },
+    degradeClass: function() {
+      var s = this.scene
+      // n0/error/incomplete(无数据) → 灰 26rpx;其余灰色 22rpx;橙红色为 skip/unit-mismatch
+      if (s === 'error') return 'gray-text large'
+      if (s === 'n0') return 'gray-text large'
+      if (s === 'skip' || s === 'unit-mismatch') return 'orange-text'
+      return 'gray-text'
+    }
+  },
+  methods: {
+    onRetry: function() {
+      if (this.retry) this.retry()
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.indicator-degrade {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 12rpx 0;
+}
+.degrade-text {
+  font-size: calc(22rpx * var(--font-scale, 1));
+}
+.degrade-text.large {
+  font-size: calc(26rpx * var(--font-scale, 1));
+}
+.gray-text { color: $app-text-subtle; }
+.orange-text { color: $mat-orange-base; }
+.degrade-retry {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 8rpx;
+}
+.retry-text {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $uni-primary;
+}
+</style>

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

@@ -0,0 +1,68 @@
+<template>
+  <view v-if="shouldShow" class="incomplete-banner" :class="bannerClass">
+    <text class="banner-icon">ⓘ</text>
+    <text class="banner-text">{{ bannerText }}</text>
+  </view>
+</template>
+
+<script>
+/**
+ * §1.2.2 残缺横幅组件(三处复用:时间轴页 / 旧曲线页 / 报告详情页)
+ * 硬编码文案,页面不得改写(合规要求)。
+ */
+export default {
+  name: 'IndicatorIncompleteBanner',
+  props: {
+    /** 该成员该 sourceType 下该指标的有值期数 */
+    pointsCount: { type: Number, default: 0 },
+    /** 该成员该 sourceType 下的健康报告总数 */
+    totalReportCount: { type: Number, default: 0 }
+  },
+  computed: {
+    shouldShow: function() {
+      var n = this.pointsCount || 0
+      var t = this.totalReportCount || 0
+      // 数据完整:pointsCount == totalReportCount 时隐藏横幅
+      return n < t
+    },
+    bannerText: function() {
+      var n = this.pointsCount || 0
+      var t = this.totalReportCount || 0
+      if (n === 0 && t === 0) return '该成员暂无报告数据'
+      if (n === 0 && t > 0) return '该成员共 ' + t + ' 份报告,本指标暂无可显示的历史数据'
+      if (n > 0 && n < t) return '该成员共 ' + t + ' 份报告,本指标目前 ' + n + ' 期数据(历史数据整理中)'
+      return ''
+    },
+    bannerClass: function() {
+      var n = this.pointsCount || 0
+      var t = this.totalReportCount || 0
+      // n == 0 && t == 0 → 完全无报告 → 灰条;其余 → 橙条
+      return n === 0 && t === 0 ? 'empty-banner' : 'incomplete-banner'
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.incomplete-banner {
+  display: flex;
+  align-items: flex-start;
+  gap: 8rpx;
+  padding: 16rpx 24rpx;
+  margin-bottom: 16rpx;
+  border-radius: 12rpx;
+  font-size: calc(22rpx * var(--font-scale, 1));
+}
+.incomplete-banner .banner-text { flex: 1; }
+.incomplete-banner .banner-icon { flex-shrink: 0; }
+/* 橙条:数据不完整 */
+.incomplete-banner.incomplete-banner {
+  background: $facade-fill-gray-100;
+  color: $mat-orange-base;
+}
+/* 灰条:完全无报告 */
+.empty-banner {
+  background: $facade-fill-slate-50;
+  color: $facade-text-muted;
+}
+</style>

+ 124 - 0
cfc-frontend/components/indicator/SparkLine.vue

@@ -0,0 +1,124 @@
+<template>
+  <view class="sparkline-wrap" :style="wrapStyle">
+    <view
+      v-for="(pt, idx) in renderedPoints"
+      :key="getItemKey(idx)"
+      class="spark-dot"
+      :class="{ 'spark-dot-last': isLast(idx) }"
+      :style="dotStyle(idx, pt)"
+    />
+  </view>
+</template>
+
+<script>
+/**
+ * §6.2 SparkLine 组件 — 纯 view 点阵实现,禁用 canvas。
+ * 约束:
+ *   - 8 个数据点(n > 8 等距抽样,首末点强制保留)
+ *   - 末点 6rpx 主色实心,其余 4rpx 次级灰
+ *   - 不画连线
+ */
+import { colorClassFor } from '@/utils/indicator-change.js'
+
+export default {
+  name: 'SparkLine',
+  props: {
+    /** [{ reportDate, numericValue }],已按时间升序,长度 ≤ 12 */
+    points: { type: Array, default: function() { return [] } },
+    width: { type: Number, default: 60 },    // rpx
+    height: { type: Number, default: 24 },   // rpx
+    /** 趋势色,来自 utils/indicator-change.js 的 colorClassFor */
+    colorClass: { type: String, default: 'gray-text' }
+  },
+  computed: {
+    wrapStyle: function() {
+      return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx'
+    },
+    /** 抽样后的点集(保留首末点) */
+    sampledPoints: function() {
+      var pts = this.points || []
+      var n = pts.length
+      if (n === 0) return []
+      if (n <= 8) return pts
+      // 等距抽样,强制保留首末
+      var limit = 8
+      var step = (n - 1) / (limit - 1)
+      var out = []
+      for (var i = 0; i < limit - 1; i++) {
+        out.push(pts[Math.round(i * step)])
+      }
+      out.push(pts[n - 1]) // ← 末点强制保留
+      return out
+    },
+    /** 极值用于归一化 */
+    minVal: function() {
+      var pts = this.sampledPoints
+      if (pts.length === 0) return 0
+      var m = Infinity
+      pts.forEach(function(p) {
+        if (p && !isNaN(p.numericValue) && p.numericValue < m) m = p.numericValue
+      })
+      return m === Infinity ? 0 : m
+    },
+    maxVal: function() {
+      var pts = this.sampledPoints
+      if (pts.length === 0) return 1
+      var m = -Infinity
+      pts.forEach(function(p) {
+        if (p && !isNaN(p.numericValue) && p.numericValue > m) m = p.numericValue
+      })
+      return m === -Infinity ? 1 : m
+    },
+    range: function() {
+      var r = this.maxVal - this.minVal
+      return r === 0 ? 1 : r
+    },
+    /** 渲染点集:每条含 left%、top%、是否是末点 */
+    renderedPoints: function() {
+      var pts = this.sampledPoints
+      if (pts.length === 0) return []
+      var n = pts.length
+      var minV = this.minVal
+      var rng = this.range
+      return pts.map(function(p, i) {
+        var v = p && !isNaN(p.numericValue) ? p.numericValue : minV
+        var leftPct = (n === 1) ? 50 : (i / (n - 1)) * 100
+        // top: minV → 100%, maxV → 0%
+        var topPct = 100 - ((v - minV) / rng) * 100
+        return { leftPct: leftPct, topPct: topPct, isLast: i === n - 1 }
+      })
+    }
+  },
+  methods: {
+    getItemKey: function(idx) { return 's_' + idx },
+    isLast: function(idx) {
+      return idx === (this.renderedPoints.length - 1)
+    },
+    dotStyle: function(idx, pt) {
+      var size = pt.isLast ? 6 : 4
+      return 'left:' + pt.leftPct + '%;top:' + pt.topPct + '%;width:' + size + 'rpx;height:' + size + 'rpx'
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.sparkline-wrap {
+  position: relative;
+  overflow: hidden;
+  display: inline-block;
+}
+.spark-dot {
+  position: absolute;
+  border-radius: 999rpx;
+  transform: translate(-50%, -50%);
+  // 默认次级灰
+  background: $app-text-subtle;
+}
+.spark-dot-last {
+  // 末点主色(由 colorClass 决定)
+}
+.green-text .spark-dot-last { background: $status-success-strong-2; }
+.red-text .spark-dot-last   { background: $status-danger-deep; }
+.gray-text .spark-dot-last  { background: $facade-text-muted; }
+</style>