2026-10-09-indicator-change-curve-miniapp.md 55 KB

指标变化曲线 · 指标速查 - 小程序实施计划(P4)

版本: v1.0 日期: 2026-10-09 状态: 待执行 关联设计: docs/superpowers/specs/2026-10-09-indicator-change-curve-design.md(v1.0 已确认) 依赖后端: docs/superpowers/plans/2026-10-09-indicator-change-curve-backend.md(P1-P3 视图 API 必须先完成)


面向 AI 代理的工作者

本计划由 AI 代理执行。目标:为 uni-app 微信小程序(cfc-frontend/,Vue 2 Options API)实现「指标变化曲线页」「指标速查页」「菌群报告详情页变化区块」及通用 TrendChart 折线组件。所有 API 依赖后端 P1-P3。

执行纪律(违反即失败):

  1. 单一写者:每个任务必须是「写测试 → 运行确认失败 → 实现 → 运行确认通过 → commit」的 TDD 闭环。测试永远先行。
  2. 禁止打包:Agent 禁止执行 npm run build:mp-weixin / npm run dev:mp-weixin。只做语法/结构校验(node --check)、审计(audit-duplicate-api-calls.js)、jest 单测。最终打包由用户在 HBuilderX 完成。
  3. 前端硬规则硬编码进每一步:
    • 禁止可选链 ?. → 用 &&
    • 禁止 CSS Grid → flexbox
    • 禁止 :key 表达式 → methods 方法 getItemKey(item)
    • 禁止 :class 绑定方法调用 → 内联表达式 :class="'xxx-' + item._cssClass";_cssClass 由前端 computed/后端字段映射为英文
    • 禁止直接 new Date(string) → utils/format.js 的 parseDate()
    • 禁止中文 CSS 类名 → 英文
    • 禁止 uni.getSystemInfoSync() → uni.getWindowInfo()
    • 组件接收数组 prop 必须 || [] + Array.isArray 兜底(三态兼容)
    • 页面静态标题在 pages.json 的 navigationBarTitleText 配置,模板内禁止手写 <text class="nav-title">
  4. API 去重规范(cfc-frontend/AGENTS.md):_onLoadFired 标志位;utils/api.js 每个 URL 单一规范入口,别名委托;禁止循环内请求;CI 门禁 node scripts/audit-duplicate-api-calls.js 必须 exit 0。
  5. 新增 API 封装前查重 API_REFERENCE;封装改动同步更新 docs/superpowers/api/API_REFERENCE.md(接口校准不存在时留待后端实现后补)。
  6. 只修改本计划列出的文件,禁止顺手改动其他文件;不改样式设计体系 token。

1. 目标与范围

1.1 本期范围(P4)

# 交付物 文件 说明
1 通用折线组件 cfc-frontend/components/TrendChart.vue 多序列折线 + 数据点 + 数值标注,手写 canvas(参照 RadarChart.vue type=2d + DPR + 重试初始化模式),不引入图表库
2 指标变化曲线页 cfc-frontend/pages/health/indicator-curve.vue 顶部筛选(成员/报告类型/时间范围)+ 曲线区(指标选择器 → TrendChart)+ 变化排名区(Tab:菌群/食材/指标 × 排序:升幅/降幅/绝对幅度;adjacent 相邻两次报告 Δ;按 good_direction 着色)
3 指标速查页 cfc-frontend/pages/health/indicator-quicklook.vue 分类 Tab + 分页(≤30/页),每项:名称 · 最新值 · 参考区间 · ↑↓→ 趋势 · 最近报告日期;独立小节五维/七维评分趋势;点击单项 → 跳转曲线页携带 definitionId
4 菌群详情页变化区块 cfc-frontend/pages/health/gut-flora-detail.vue(改) 最近两次同类型报告的菌属 Δ,红/绿标注(一期仅此详情页)
5 页面注册 + API 封装 cfc-frontend/pages.json(改)、cfc-frontend/utils/api.js(改) 注册 2 个新页面路径;新增 trend / change-ranking / quick-lookup / correlation/list 4 个封装
6 单测 cfc-frontend/__tests__/utils/indicator-change.test.js、cfc-frontend/__tests__/api/api.indicator.test.js 变化规则纯函数 + API 封装契约

1.2 明确不做(二期)

  • DAN / 体检报告详情页的「变化」区块(一期仅菌群报告详情页)
  • 跨报告类型同曲线叠加对比
  • 曲线页知识库弹出框内嵌(复用既有 health-knowledge-popup 组件已有能力,如需展示弹出框直接复用,不新增接口)
  • 关联关系在小程序的展示页(correlation/list 封装本期提供,展示留二期;quick-lookup 页面不渲染关联区块)

1.3 数据流(依赖后端 API 契约)

indicator-curve.vue
  ├─ POST /api/indicator/trend            { subjectId, definitionIds:[...] }
  │     → { items:[{ definitionId, name, points:[{reportDate,value,unit,status}] }] }
  ├─ POST /api/indicator/change-ranking    { subjectId, reportType?, category?, scope:"adjacent", orderBy:"up|down|magnitude", limit:50 }
  │     → { items:[{ definitionId, name, category, sourceKind, unit, before, after, delta, deltaPct, movement, trendLabel }], overall:{...} }
  └─ POST /api/family/member/list          (成员切换,复用既有封装 getFamilyMemberList)

indicator-quicklook.vue
  ├─ POST /api/indicator/quick-lookup      { subjectId, category?, page:1, pageSize:30 }
  │     → { categories:[{name,total}], items:[{definitionId,name,category,value,unit,refRange,status,trend,trendLabel,latestReportDate,pointsCount}], dimensionScores:[...] }
  └─ 跳转 → indicator-curve.vue?definitionId=X

gut-flora-detail.vue
  └─ POST /api/indicator/change-ranking    { subjectId, reportType:'gut_flora', scope:"adjacent", orderBy:"magnitude", limit:6 }
      → 渲染最近两次报告的菌属 Δ 区块(红/绿标注)

2. 技术栈与关键约定

项 约定
框架 uni-app Vue 2(Options API),微信小程序
图表 TrendChart.vue 手写 canvas,<canvas type="2d"> + uni.createSelectorQuery().in(this).select('#'+canvasId).fields({node:true,size:true,rect:true}) + uni.getWindowInfo().pixelRatio DPR 适配 + 重试初始化(参考 components/RadarChart.vue)
单测 jest(tests/jest.config.js:babel-jest + @/ → cfc-frontend/ + setup mock uni 全局 + getCurrentPages)。运行:cd cfc-frontend && npx jest __tests__/... 或 cd tests && npx jest ../cfc-frontend/__tests__/...(plan 内统一使用 cd cfc-frontend && npx jest <path>)
语法校验 提取 <script> 块 node --check;模板部分靠人工 + HBuilderX 编译
审计 node scripts/audit-duplicate-api-calls.js(exit 0)
时间 展示格式 yyyy-MM-dd(截取前 10 位)或 yyyy-MM-dd HH:mm:ss;日期解析一律 parseDate()

2.1 API 封装规范(utils/api.js)

统一 export const fnName = (params) => request('/api/indicator/xxx', 'POST', params || {})。request() 已内置 3 秒去重 + PENDING 合并(同 URL+method+body),页面侧仍需遵守 _onLoadFired。


3. 文件结构与变更清单

操作 文件 内容
新建 cfc-frontend/components/TrendChart.vue 通用折线组件
新建 cfc-frontend/pages/health/indicator-curve.vue 变化曲线页
新建 cfc-frontend/pages/health/indicator-quicklook.vue 速查页
修改 cfc-frontend/pages/health/gut-flora-detail.vue 「变化」区块
修改 cfc-frontend/pages.json health 分包注册 2 页
修改 cfc-frontend/utils/api.js 4 个封装(含坐标:change-ranking 已可按 sourceKind/category 过滤)
新建 cfc-frontend/__tests__/utils/indicator-change.test.js 变化规则纯函数单测
新建 cfc-frontend/__tests__/api/api.indicator.test.js API 封装契约单测
修改 docs/superpowers/api/API_REFERENCE.md 同步 4 个新增封装对应的接口记录

4. 任务分解(TDD 小步)

每个任务验收 = 测试/校验命令通过 + commit。commit message 遵循仓库风格(Conventional Commits 中文,参考 git log 历史)。

任务 M1:变化规则纯函数 + 单测(TDD 红 → 绿)

目标:把「good_direction × 变化方向 → 趋势标签/颜色/CSS 类」提炼成纯函数,供曲线页排名区与详情页变化区块共用,避免两处复制。

文件:cfc-frontend/utils/indicator-change.js(新建,纯函数,可被 jest require 且不依赖 uni)

步骤 1 — 写失败测试 cfc-frontend/__tests__/utils/indicator-change.test.js:

/**
 * 指标变化规则纯函数单测
 * 规则来源:design.md §8 变化颜色与箭头规则表
 */
import * as ic from '@/utils/indicator-change'

describe('indicator-change 变化规则', () => {
  describe('resolveChangeStyle(goodDirection, movement)', () => {
    test('higher_is_better + up → 改善/绿 ↑', () => {
      const s = ic.resolveChangeStyle('higher_is_better', 'up')
      expect(s.trendLabel).toBe('改善')
      expect(s.cssClass).toBe('impr-up')
      expect(s.arrow).toBe('↑')
    })
    test('higher_is_better + down → 恶化/红 ↓', () => {
      const s = ic.resolveChangeStyle('higher_is_better', 'down')
      expect(s.trendLabel).toBe('恶化')
      expect(s.cssClass).toBe('worst-down')
      expect(s.arrow).toBe('↓')
    })
    test('lower_is_better + up → 恶化/红 ↑ (如炎症水平上升)', () => {
      const s = ic.resolveChangeStyle('lower_is_better', 'up')
      expect(s.trendLabel).toBe('恶化')
      expect(s.cssClass).toBe('worst-up')
      expect(s.arrow).toBe('↑')
    })
    test('lower_is_better + down → 改善/绿 ↓ (如炎症水平下降)', () => {
      const s = ic.resolveChangeStyle('lower_is_better', 'down')
      expect(s.trendLabel).toBe('改善')
      expect(s.cssClass).toBe('impr-down')
      expect(s.arrow).toBe('↓')
    })
    test('neutral + up/down → 中性/灰', () => {
      const up = ic.resolveChangeStyle('neutral', 'up')
      const down = ic.resolveChangeStyle('neutral', 'down')
      expect(up.trendLabel).toBe('中性')
      expect(up.cssClass).toBe('neutral-up')
      expect(down.cssClass).toBe('neutral-down')
    })
    test('flat → 无变化/灰 →', () => {
      const s = ic.resolveChangeStyle('higher_is_better', 'flat')
      expect(s.trendLabel).toBe('中性')
      expect(s.cssClass).toBe('flat')
      expect(s.arrow).toBe('→')
    })
    test('未知 goodDirection 兜底为中性', () => {
      const s = ic.resolveChangeStyle('unknown_direction', 'up')
      expect(s.cssClass).toBe('neutral-up')
    })
  })

  describe('formatDelta(delta, deltaPct)', () => {
    test('正 delta 显示 + 并带百分比', () => {
      expect(ic.formatDelta(2, 100)).toBe('+2 (+100%)')
    })
    test('负 delta 显示 -', () => {
      expect(ic.formatDelta(-1.5, -30)).toBe('-1.5 (-30%)')
    })
    test('null 安全', () => {
      expect(ic.formatDelta(null, null)).toBe('--')
      expect(ic.formatDelta(0, 0)).toBe('0 (0%)')
    })
  })
})

步骤 2 — 运行确认失败:

cd cfc-frontend && npx jest __tests__/utils/indicator-change.test.js --no-coverage

预期:Cannot find module '@/utils/indicator-change'(模块不存在 → 红)。

步骤 3 — 实现 cfc-frontend/utils/indicator-change.js:

/**
 * 指标变化展示规则纯函数
 * 规则来源:design.md §8 变化颜色与箭头规则
 * 颜色映射:改善=绿 / 恶化=红 / 中性=灰
 */

/**
 * 由 good_direction × movement 推导展示样式
 * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
 * @param {string} movement up|down|flat
 * @returns {{trendLabel:string, cssClass:string, arrow:string}}
 */
export function resolveChangeStyle(goodDirection, movement) {
  var gd = goodDirection || 'neutral'
  var mv = movement || 'flat'
  if (mv === 'flat') {
    return { trendLabel: '中性', cssClass: 'flat', arrow: '→' }
  }
  // 是否"改善":higher_is_better 升=改善;lower_is_better 降=改善;in_range 视区间判定(此处交给 withRange 变体)
  var isImprove = false
  if (gd === 'higher_is_better') {
    isImprove = mv === 'up'
  } else if (gd === 'lower_is_better') {
    isImprove = mv === 'down'
  } else {
    // neutral / in_range_is_better(一期 in_range 的管理端手配方向;区间内移动判定留二期,先按中性展示)
    return {
      trendLabel: '中性',
      cssClass: gd === 'in_range_is_better' ? 'neutral-up' : mv === 'up' ? 'neutral-up' : 'neutral-down',
      arrow: mv === 'up' ? '↑' : '↓'
    }
  }
  if (isImprove) {
    return { trendLabel: '改善', cssClass: gd === 'higher_is_better' ? 'impr-up' : 'impr-down', 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
 * @param {string} cssClass resolveChangeStyle 返回的类
 * @returns {string}
 */
export function colorClassFor(cssClass) {
  if (cssClass.indexOf('impr') === 0) return 'green-text'
  if (cssClass.indexOf('worst') === 0) return 'red-text'
  return 'gray-text'
}

步骤 4 — 运行确认通过:

cd cfc-frontend && npx jest __tests__/utils/indicator-change.test.js --no-coverage

预期:7+3 个 test 全部通过(绿)。

步骤 5 — commit:feat(miniapp): 指标变化展示规则纯函数 resolveChangeStyle/formatDelta


任务 M2:utils/api.js 新增 4 个封装 + API 契约单测(TDD 红 → 绿)

目标:/api/indicator/trend、/api/indicator/change-ranking、/api/indicator/quick-lookup、/api/indicator/correlation/list 四个 URL 各一个规范入口。先写契约测试(mock uni.request),再实现。

步骤 1 — 写失败测试 cfc-frontend/__tests__/api/api.indicator.test.js:

/**
 * 指标 API 封装契约测试
 * 验证 URL / method / body 正确,且是规范入口(单 URL 单封装)
 */
import * as api from '@/utils/api'
import { getIndicatorTrend, getIndicatorChangeRanking, getIndicatorQuickLookup, getIndicatorCorrelationList } from '@/utils/api'

describe('indicator API 封装', () => {
  beforeEach(() => {
    global.uni._storage = {}
    global.uni.request.mockReset()
    global.uni.showToast.mockReset()
    global.uni.reLaunch.mockReset()
    global.uni.login.mockReset()
    global.getCurrentPages.mockReset()
    global.getCurrentPages.mockReturnValue([{ route: 'pages/index-home/index' }])
    global.uni.getAccountInfoSync.mockReset()
    global.uni.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'develop' } })
    api.clearApiDedup()
  })

  /** 捕获最近一次 request 调用参数 */
  function captureRequest(params) {
    var captured = null
    global.uni.request.mockImplementation((opts) => {
      captured = opts
      process.nextTick(() => { if (opts.success) opts.success({ data: { code: 200, data: {} } }) })
    })
    return params.then(() => captured)
  }

  test('getIndicatorTrend → POST /api/indicator/trend with subjectId+definitionIds', async () => {
    const opts = await captureRequest(getIndicatorTrend({ subjectId: 5, definitionIds: [1, 2] }))
    expect(opts.url).toBe('/api/indicator/trend')
    expect(opts.method).toBe('POST')
    expect(opts.data.subjectId).toBe(5)
    expect(opts.data.definitionIds).toEqual([1, 2])
  })

  test('getIndicatorChangeRanking → POST /api/indicator/change-ranking with scope+orderBy', async () => {
    const opts = await captureRequest(getIndicatorChangeRanking({ subjectId: 5, scope: 'adjacent', orderBy: 'magnitude', limit: 50 }))
    expect(opts.url).toBe('/api/indicator/change-ranking')
    expect(opts.method).toBe('POST')
    expect(opts.data.scope).toBe('adjacent')
    expect(opts.data.orderBy).toBe('magnitude')
  })

  test('getIndicatorQuickLookup → POST /api/indicator/quick-lookup with page', async () => {
    const opts = await captureRequest(getIndicatorQuickLookup({ subjectId: 5, category: '核心菌属', page: 1, pageSize: 30 }))
    expect(opts.url).toBe('/api/indicator/quick-lookup')
    expect(opts.method).toBe('POST')
    expect(opts.data.page).toBe(1)
    expect(opts.data.pageSize).toBe(30)
  })

  test('getIndicatorCorrelationList → POST /api/indicator/correlation/list with subjectId+definitionId', async () => {
    const opts = await captureRequest(getIndicatorCorrelationList({ subjectId: 5, definitionId: 3 }))
    expect(opts.url).toBe('/api/indicator/correlation/list')
    expect(opts.method).toBe('POST')
    expect(opts.data.definitionId).toBe(3)
  })
})

步骤 2 — 运行确认失败:cd cfc-frontend && npx jest __tests__/api/api.indicator.test.js --no-coverage → 预期 getIndicatorTrend is not a function(红)。

步骤 3 — 实现:在 cfc-frontend/utils/api.js 末尾新增(先读文件末尾,确认无同名封装;若有则按 AGENTS.md 规则 2 委托):

// ============ 指标变化曲线 / 速查 / 关联 ============
// 规范入口(singular per URL)。后端契约见 design.md §6.1-6.4。
/** 指标趋势(曲线页):{ subjectId, definitionIds:[] } */
export const getIndicatorTrend = (params) => request('/api/indicator/trend', 'POST', params || {})
/** 变化幅度排名:{ subjectId, reportType?, category?, scope:'adjacent'|'first-vs-latest', orderBy:'up'|'down'|'magnitude', limit } */
export const getIndicatorChangeRanking = (params) => request('/api/indicator/change-ranking', 'POST', params || {})
/** 指标速查(分类分页):{ subjectId, category?, page, pageSize } */
export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick-lookup', 'POST', params || {})
/** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})

步骤 4 — 运行确认通过:

cd cfc-frontend && npx jest __tests__/api/api.indicator.test.js --no-coverage
node scripts/audit-duplicate-api-calls.js   # exit 0,确认无同 URL 多封装 / N+1
node --check 校验(提取 api.js script 不适用,api.js 本身是 .js:直接 node --check utils/api.js)

步骤 5 — commit:feat(miniapp): 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList


任务 M3:TrendChart.vue 通用折线组件

目标:多序列折线图(横轴=报告日期升序,纵轴=数值;数据点标注数值;支持多指标序列叠加),手写 canvas。

文件:cfc-frontend/components/TrendChart.vue(新建)

关键实现(参照 RadarChart.vue 的 canvas 2d 模式):

<template>
  <view class="trend-chart-wrap">
    <view class="trend-container" :style="containerStyle">
      <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
      <view class="trend-tap-layer" @click="onCanvasTap"></view>
    </view>
    <view class="trend-legend" v-if="series.length > 1">
      <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
        <view class="legend-line" :style="'border-color:' + s.color"></view>
        <text class="legend-text">{{ s.name }}</text>
      </view>
    </view>
  </view>
</template>

<script>
/**
 * TrendChart - 多序列折线图 Canvas 组件
 * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
 * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
 */
export default {
  props: {
    /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
    series: { type: Array, default: function() { return [] } },
    width: { type: Number, default: 690 },
    height: { type: Number, default: 420 },
    /** Y 轴自动范围下界(默认 min-5%) */
    yMin: { type: Number, default: 0 },
    /** 是否绘制数据点数值标注 */
    showValues: { type: Boolean, default: true }
  },
  data: function() {
    return {
      canvasReady: false, canvasWidth: 0, canvasHeight: 0, ctx: null, dpr: 1,
      _canvasNode: null, _canvasLeft: 0, _canvasTop: 0,
      _destroyed: false, _initTimer: null,
      canvasId: 'trendCanvas_' + (this._uid || 0)
    }
  },
  computed: {
    containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
    canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
  },
  watch: {
    series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
  },
  mounted: function() {
    var self = this
    var retryCount = 0
    var maxRetry = 5
    function tryInit() {
      if (self._destroyed) return
      self.initCanvas(function(success) {
        if (self._destroyed) return
        if (!success && retryCount < maxRetry) {
          retryCount++
          self._initTimer = setTimeout(tryInit, 60)
        } else if (!success) {
          console.log('[TrendChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
        }
      })
    }
    self._initTimer = setTimeout(tryInit, 100)
  },
  beforeDestroy: function() {
    this._destroyed = true
    if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null }
  },
  methods: {
    getItemKey: function(s, i) { return (s && s.name) || i },

    initCanvas: function(cb) {
      var self = this
      uni.createSelectorQuery().in(this)
        .select('#' + this.canvasId)
        .fields({ node: true, size: true, rect: true })
        .exec(function(res) {
          try {
            if (self._destroyed) return
            if (!res || !res[0] || !res[0].node) { if (cb) cb(false); return }
            var info = res[0]
            var canvas = info.node
            var width = info.width
            var height = info.height
            if (!width || !height) {
              var winWidth = 375
              try { winWidth = uni.getWindowInfo().windowWidth || 375 } catch (e) {}
              width = Math.round((self.width || 690) * winWidth / 750)
              height = Math.round((self.height || 420) * winWidth / 750)
            }
            var pixelRatio = 2
            try { pixelRatio = uni.getWindowInfo().pixelRatio || 2 } catch (e) {}
            canvas.width = width * pixelRatio
            canvas.height = height * pixelRatio
            var ctx = canvas.getContext('2d')
            if (!ctx) { if (cb) cb(false); return }
            ctx.scale(pixelRatio, pixelRatio)
            self.ctx = ctx
            self._canvasNode = canvas
            self.canvasWidth = width
            self.canvasHeight = height
            self.dpr = pixelRatio
            self._canvasLeft = info.left || 0
            self._canvasTop = info.top || 0
            self.canvasReady = true
            self._doRender()
            if (cb) cb(true)
          } catch (e) {
            console.log('[TrendChart] initCanvas 回调异常', e)
            if (cb) cb(false)
          }
        })
    },

    _safeSeries: function(arr) {
      if (!Array.isArray(arr)) return []
      return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
    },

    /** 计算 X 轴时间范围与 Y 轴数值范围 */
    _computeRanges: function(seriesList) {
      var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
      seriesList.forEach(function(s) {
        s.points.forEach(function(p) {
          var v = parseFloat(p.value)
          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
        })
      })
      if (min === Infinity) { min = 0; max = 1 }
      if (max === min) { max = min + 1 }
      var pad = (max - min) * 0.08
      return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
    },

    _doRender: function() {
      if (!this.ctx) return
      var ctx = this.ctx
      var w = this.canvasWidth, h = this.canvasHeight
      var padL = 34, padR = 14, padT = 16, padB = 26
      var plotW = w - padL - padR, plotH = h - padT - padB
      var seriesList = this._safeSeries(this.series)
      if (seriesList.length === 0) {
        ctx.clearRect(0, 0, w, h)
        ctx.fillStyle = '#94A3B8'
        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 allDates = []
      seriesList.forEach(function(s) {
        s.points.forEach(function(p) {
          if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
        })
      })
      allDates.sort()
      function xOf(dateStr) {
        if (allDates.length <= 1) return padL + plotW / 2
        var idx = allDates.indexOf(dateStr)
        if (idx < 0) return padL
        return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
      }

      ctx.clearRect(0, 0, w, h)
      // 网格(4 条横线 + 2 条纵线)
      ctx.strokeStyle = '#EEF2F7'
      ctx.lineWidth = 1
      for (var gi = 0; gi <= 3; gi++) {
        var gy = padT + plotH * (gi / 3)
        ctx.beginPath()
        ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy)
        ctx.stroke()
      }
      // Y 轴刻度文字
      ctx.fillStyle = '#94A3B8'
      ctx.font = '10px sans-serif'
      ctx.textAlign = 'right'
      for (var ti = 0; ti <= 3; ti++) {
        var tv = yMin + (yMax - yMin) * (1 - ti / 3)
        var ty = padT + plotH * (ti / 3)
        ctx.fillText(this._fmtNum(tv), padL - 4, ty + 3)
      }
      // 折线 + 数据点 + 数值
      var palette = ['#F97316', '#0EA5E9', '#10B981', '#8B5CF6', '#EF4444', '#F59E0B']
      seriesList.forEach(function(s, si) {
        var color = s.color || palette[si % palette.length]
        ctx.strokeStyle = color
        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()
        // 数据点 + 值
        s.points.forEach(function(p) {
          var v = parseFloat(p.value)
          if (isNaN(v)) return
          var x = xOf(p.reportDate), y = yOf(v)
          ctx.fillStyle = color
          ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
          if (selfShowValues(si)) {
            ctx.fillStyle = '#475569'
            ctx.font = '10px sans-serif'
            ctx.textAlign = 'center'
            ctx.fillText(selfFmtValue(p), x, y - 8)
          }
        })
        // 序列名(最右端上方)
        ctx.fillStyle = color
        ctx.font = '11px sans-serif'
        ctx.textAlign = 'right'
        var last = s.points[s.points.length - 1]
        if (last && !isNaN(parseFloat(last.value))) {
          ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
        }
      })
      // X 轴日期标签(首/中/末)
      ctx.fillStyle = '#94A3B8'
      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)
        }
      }
    },

    _fmtNum: function(v) {
      if (v >= 100) return Math.round(v) + ''
      if (v >= 10) return v.toFixed(1)
      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) {
      // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
      this.$emit('chartTap')
    }
  }
}
</script>

注:上述 selfShowValues/selfFmtValue 是占位,实现时需改为直接引用组件方法(Vue 2 methods 内通过 this.showValues / this._fmtValue 访问)。交付时必须替换为实际代码,无占位符。

验证(通过才 commit):

# 提取 <script> 块做语法校验
node -e "const fs=require('fs');const s=fs.readFileSync('components/TrendChart.vue','utf8');const m=s.match(/<script[^>]*>([\s\S]*?)<\/script>/);require('fs').writeFileSync('/tmp/trend-check.js',m[1]);" && node --check /tmp/trend-check.js

commit:feat(miniapp): TrendChart 通用折线组件(canvas 2d + DPR,参照 RadarChart 模式)


任务 M4:指标变化曲线页 indicator-curve.vue

目标:成员切换 + 报告类型筛选 + 指标选择(TrendChart)+ 变化排名区(Tab + 排序 + good_direction 着色)。

文件:cfc-frontend/pages/health/indicator-curve.vue(新建)

页面结构:

<template>
  <view class="page">
    <!-- 顶部筛选:成员 / 报告类型 -->
    <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>

    <!-- 曲线区:指标选择器 + TrendChart -->
    <view class="curve-section">
      <view class="section-title">指标变化曲线</view>
      <view class="indicator-picker">
        <scroll-view scroll-x class="indicator-scroll">
          <view
            class="indicator-chip"
            :class="theIndicatorId === getIndicatorId(def) ? 'chip-active' : ''"
            v-for="def in indicatorOptions"
            :key="getIndicatorKey(def)"
            @tap="onIndicatorTap(getIndicatorId(def))"
          >{{ getIndicatorName(def) }}</view>
        </scroll-view>
      </view>
      <TrendChart v-if="series && series.length > 0" :series="series" />
      <view class="empty-tip" v-else>暂无趋势数据</view>
    </view>

    <!-- 变化排名区 -->
    <view class="ranking-section">
      <view class="section-title">变化排名</view>
      <view class="rank-tabs">
        <view
          class="rank-tab"
          :class="'rank-' + activeRankTab"
          v-for="(tab, ti) in rankTabs"
          :key="getTabKey(tab, ti)"
          @tap="onRankTabTap(tab.value)"
        >{{ tab.label }}</view>
      </view>
      <view class="rank-sorts">
        <view
          class="sort-btn"
          :class="sortBy === s.value ? 'sort-active' : ''"
          v-for="s in sortOptions"
          :key="getSortKey(s)"
          @tap="onSortTap(s.value)"
        >{{ s.label }}</view>
      </view>
      <view class="rank-list" v-if="rankItems.length > 0">
        <view class="rank-item" v-for="item in rankItems" :key="getRankKey(item)">
          <view class="rank-name-row">
            <text class="rank-name">{{ item.name }}</text>
            <text class="rank-category">{{ item.category }}</text>
          </view>
          <view class="rank-value-row">
            <text class="rank-before">{{ item.before ? item.before.value : '--' }}</text>
            <text :class="'rank-arrow ' + colorClassFor(getChangeStyle(item).cssClass)">→</text>
            <text class="rank-after">{{ item.after ? item.after.value : '--' }}</text>
            <text :class="'rank-delta ' + colorClassFor(getChangeStyle(item).cssClass)">{{ formatDelta(item.delta, item.deltaPct) }}</text>
          </view>
          <text :class="'rank-trend ' + colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).trendLabel }} {{ getChangeStyle(item).arrow }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

script 关键逻辑(_onLoadFired 模式 + 参考既有健康页面的成员加载 getFamilyMemberList):

import TrendChart from '@/components/TrendChart.vue'
import { getIndicatorTrend, getIndicatorChangeRanking } from '@/utils/api'
import { getFamilyMemberList } from '@/utils/api'
import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
import { parseDate } from '@/utils/format'

export default {
  components: { TrendChart },
  data: function() {
    return {
      members: [], currentMemberId: null,
      reportTypeOptions: [
        { value: 'gut_flora', label: '肠道菌群' },
        { value: 'food_recommend', label: '食材推荐' }
      ],
      activeReportType: 'gut_flora',
      indicatorOptions: [], theIndicatorId: null,
      series: [], loadedDefinitionIds: [],
      rankTabs: [ { value: 'flora', label: '菌群变化' }, { value: 'food', label: '食材变化' }, { value: 'indicator', label: '指标变化' } ],
      activeRankTab: 'flora',
      sortOptions: [ { value: 'magnitude', label: '绝对幅度' }, { value: 'up', label: '升幅↑' }, { value: 'down', label: '降幅↓' } ],
      sortBy: 'magnitude',
      rankItems: [], overall: {},
      _onLoadFired: false, _loading: false
    }
  },
  computed: {
    memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
    reportTypeNames: function() {
      var self = this
      return this.reportTypeOptions.map(function(o) { return o.label })
    },
    currentMemberName: function() {
      var self = this
      var m = this.members.find(function(x) { return x.id === self.currentMemberId })
      return (m && (m.name || m.nickname)) || '选择成员'
    },
    currentReportTypeName: function() {
      var self = this
      var o = this.reportTypeOptions.find(function(x) { return x.value === self.activeReportType })
      return (o && o.label) || ''
    }
  },
  onLoad: function(options) {
    this.loadMembers()
    if (options && options.definitionId) {
      this.theIndicatorId = Number(options.definitionId)
    }
    this._onLoadFired = true
  },
  onShow: function() {
    if (this._onLoadFired) {
      this._onLoadFired = false
    } else if (this.currentMemberId) {
      this.reloadAll()
    }
  },
  methods: {
    getIndicatorId: function(def) { return def ? def.definitionId : null },
    getIndicatorName: function(def) { return (def && def.name) || '' },
    getIndicatorKey: function(def) { return def ? (def.definitionId || def.name) : '' },
    getTabKey: function(tab, i) { return tab.value || i },
    getSortKey: function(s) { return s.value },
    getRankKey: function(item) { return item.definitionId },
    getChangeStyle: function(item) { return resolveChangeStyle(item.goodDirection, item.movement) },

    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
          self.onMemberSelected()
        }
      })
    },
    onMemberSelected: function() { this.reloadAll() },
    onMemberChange: function(e) {
      var idx = Number(e.detail.value)
      var m = this.members[idx]
      if (m) { this.currentMemberId = m.id; this.reloadAll() }
    },
    onReportTypeChange: function(e) {
      var idx = Number(e.detail.value)
      var o = this.reportTypeOptions[idx]
      if (o) { this.activeReportType = o.value; this.reloadAll() }
    },
    reloadAll: function() {
      if (!this.currentMemberId) return
      this.loadCurve()
      this.loadRanking()
    },
    loadCurve: function() {
      // 曲线页:先拉 ranking 获得指标候选,再拉 trend(避免硬编码 definition 列表)
      this.loadRankingThenCurve()
    },
    loadRankingThenCurve: function() {
      var self = this
      if (!this.currentMemberId) return
      var reportType = this.activeReportType === 'food_recommend' ? null : this.activeReportType
      getIndicatorChangeRanking({
        subjectId: this.currentMemberId,
        reportType: reportType,
        category: this.activeRankTab === 'food' ? null : (this.activeRankTab === 'flora' ? '核心菌属' : null),
        scope: 'adjacent', orderBy: 'magnitude', limit: 30
      }).then(function(res) {
        var items = (res.data && res.data.items) || []
        // 用排名结果作为指标候选
        self.indicatorOptions = items.slice(0, 10).map(function(it) {
          return { definitionId: it.definitionId, name: it.name }
        })
        if (self.indicatorOptions.length > 0) {
          var target = self.theIndicatorId
          var found = self.indicatorOptions.find(function(d) { return d.definitionId === target })
          if (!found) { self.theIndicatorId = self.indicatorOptions[0].definitionId }
          self.loadTrendFor([self.theIndicatorId])
        } else {
          self.series = []
        }
      })
    },
    loadTrendFor: function(definitionIds) {
      var self = this
      if (!this.currentMemberId || !definitionIds || definitionIds.length === 0) return
      getIndicatorTrend({ subjectId: this.currentMemberId, definitionIds: definitionIds }).then(function(res) {
        var items = (res.data && res.data.items) || []
        var series = items.map(function(it) {
          var color = ''
          return { name: it.name, color: color || '#F97316', points: (it.points || []).map(function(p) { return { reportDate: String(p.reportDate).slice(0, 10), value: p.value, unit: p.unit } }) }
        })
        self.series = series
      })
    },
    onIndicatorTap: function(definitionId) {
      if (definitionId === this.theIndicatorId) return
      this.theIndicatorId = definitionId
      this.loadTrendFor([definitionId])
    },
    onRankTabTap: function(value) { this.activeRankTab = value; this.loadRanking() },
    onSortTap: function(value) { this.sortBy = value; this.loadRanking() },
    loadRanking: function() {
      var self = this
      if (!this.currentMemberId) return
      var category = null
      if (this.activeRankTab === 'flora') category = '核心菌属'
      else if (this.activeRankTab === 'indicator') category = null
      var reportType = this.activeRankTab === 'food' ? 'food_recommend' : (this.activeRankTab === 'flora' ? 'gut_flora' : null)
      getIndicatorChangeRanking({
        subjectId: this.currentMemberId,
        reportType: reportType,
        category: category,
        scope: 'adjacent', orderBy: this.sortBy, limit: 50
      }).then(function(res) {
        self.rankItems = (res.data && res.data.items) || []
        self.overall = (res.data && res.data.overall) || {}
      })
    }
  }
}
</script>

注:上述脚本中 loadCurve 委托 loadRankingThenCurve 已满足单 URL 单请求;onLoad 只触发 loadMembers 一次(_onLoadFired 跳过 onShow 首刷),成员选中后并行 loadCurve+loadRanking。实现时若发现 loadMembers 内部还有默认成员触发多次 ranking,需用 _loading 标志防重。

验证:

# 提取 <script> 块做语法校验(与 M3 同法,仓库规范:node --check)
node -e "const fs=require('fs');const s=fs.readFileSync('pages/health/indicator-curve.vue','utf8');const m=s.match(/<script[^>]*>([\s\S]*?)<\/script>/);require('fs').writeFileSync('/tmp/curve-check.js',m[1]);" && node --check /tmp/curve-check.js
# 另校验 quicklook 页(M5 完成后同样执行)
node -e "const fs=require('fs');const s=fs.readFileSync('pages/health/indicator-quicklook.vue','utf8');const m=s.match(/<script[^>]*>([\s\S]*?)<\/script>/);require('fs').writeFileSync('/tmp/ql-check.js',m[1]);" && node --check /tmp/ql-check.js
node scripts/audit-duplicate-api-calls.js   # exit 0(生命周期双发 / 同 URL 双封装检测)

commit:feat(miniapp): 指标变化曲线页(筛选 + TrendChart + 变化排名 Tab/排序/着色)


任务 M5:指标速查页 indicator-quicklook.vue

目标:分类 Tab + 分页 + 五维/七维小节 + 点击单项跳转曲线页。

文件:cfc-frontend/pages/health/indicator-quicklook.vue(新建)

页面结构(关键部分):

<template>
  <view class="page">
    <!-- 成员切换 -->
    <picker :range="memberNames" @change="onMemberChange">
      <view class="member-picker">{{ currentMemberName }} ▾</view>
    </picker>

    <!-- 分类 Tab -->
    <scroll-view scroll-x class="cat-scroll">
      <view class="cat-tab" :class="activeCategory === '' ? 'cat-active' : ''" @tap="onCatTap('')">全部</view>
      <view
        class="cat-tab"
        :class="activeCategory === c.name ? 'cat-active' : ''"
        v-for="c in categories"
        :key="getCatKey(c)"
        @tap="onCatTap(c.name)"
      >{{ c.name }}({{ c.total }})</view>
    </scroll-view>

    <!-- 指标列表 -->
    <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-name-row">
          <text class="ql-name">{{ item.name }}</text>
          <text :class="'ql-trend ' + colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).arrow }} {{ getChangeStyle(item).trendLabel }}</text>
        </view>
        <view class="ql-value-row">
          <text class="ql-value">{{ item.value }}{{ item.unit || '' }}</text>
          <text class="ql-ref">参考: {{ item.refRange || '--' }}</text>
          <text class="ql-status" :class="getStatusClass(item)">{{ item.status || '' }}</text>
        </view>
        <text class="ql-date">最近报告: {{ formatDate(item.latestReportDate) }}</text>
      </view>
    </view>
    <view class="empty-tip" v-else>暂无数据</view>

    <!-- 分页 -->
    <view class="pager" v-if="totalPages > 1">
      <view class="pager-btn" :class="page <= 1 ? 'pager-disabled' : ''" @tap="onPrevPage">上一页</view>
      <text class="pager-info">{{ page }} / {{ totalPages }}</text>
      <view class="pager-btn" :class="page >= totalPages ? 'pager-disabled' : ''" @tap="onNextPage">下一页</view>
    </view>

    <!-- 五维/七维评分小节 -->
    <view class="dim-section" v-if="dimensionScores.length > 0">
      <view class="section-title">维度评分趋势</view>
      <view class="dim-list">
        <view class="dim-row" v-for="d in dimensionScores" :key="getDimKey(d)">
          <text class="dim-name">{{ d.name }}</text>
          <text class="dim-score">{{ d.score }}</text>
          <text :class="'dim-trend ' + colorClassFor(getChangeStyle(d).cssClass)">{{ getChangeStyle(d).arrow }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

script 关键逻辑(getChangeStyle 对 dimensionScores 需兼容:dimension 的 trend 字段同 movement;getChangeStyle 签名不变):

import { getIndicatorQuickLookup } from '@/utils/api'
import { getFamilyMemberList } from '@/utils/api'
import { resolveChangeStyle, colorClassFor } from '@/utils/indicator-change'

export default {
  data: function() { return { members: [], currentMemberId: null, categories: [], activeCategory: '', items: [], dimensionScores: [], page: 1, pageSize: 30, totalPages: 1, _onLoadFired: false, _loading: false } },
  computed: { /* memberNames / currentMemberName 同 M4 */ },
  onLoad: function() { this.loadMembers(); this._onLoadFired = true },
  onShow: function() { if (this._onLoadFired) { this._onLoadFired = false } else if (this.currentMemberId) { this.reload() } },
  methods: {
    /* getItemKey/getItemId/getCatKey/getDimKey/goCurve 等 key 方法(禁止 :key 表达式) */
    goCurve: function(definitionId) {
      uni.navigateTo({ url: '/pages/health/indicator-curve?definitionId=' + definitionId })
    },
    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; self.reload() }
      })
    },
    reload: function() { this.page = 1; this.loadData() },
    loadData: function() {
      var self = this
      if (!this.currentMemberId || this._loading) return
      this._loading = true
      getIndicatorQuickLookup({ subjectId: this.currentMemberId, category: this.activeCategory || undefined, page: this.page, pageSize: this.pageSize }).then(function(res) {
        self._loading = false
        var data = res.data || {}
        self.categories = data.categories || []
        self.items = data.items || []
        self.dimensionScores = data.dimensionScores || []
        var total = data.total !== undefined ? data.total : self.items.length
        self.totalPages = Math.max(1, Math.ceil(total / self.pageSize))
      }).catch(function() { self._loading = false })
    },
    getChangeStyle: function(item) {
      var movement = item.trend || item.movement || 'flat'
      var gd = item.goodDirection || 'neutral'
      return resolveChangeStyle(gd, movement)
    },
    getStatusClass: function(item) { return 'status-' + (item._cssClass || 'normal') },
    onCatTap: function(name) { this.activeCategory = name; this.reload() },
    onPrevPage: function() { if (this.page > 1) { this.page--; this.loadData() } },
    onNextPage: function() { if (this.page < this.totalPages) { this.page++; this.loadData() } },
    formatDate: function(s) { if (!s) return '--'; return String(s).slice(0, 10) }
  }
}
</script>

验证:与 M4 相同的语法校验 + audit 脚本 + jest 已有 M1/M2 保障纯函数。

commit:feat(miniapp): 指标速查页(分类分页 + 五维/七维小节 + 跳转曲线)


任务 M6:gut-flora-detail.vue 增加「变化」区块

目标:报告详情页展示最近两次同类型报告的菌属 Δ(红/绿标注),一期仅此页。

文件:cfc-frontend/pages/health/gut-flora-detail.vue(改)

插入位置:quick-nav 区块(L72 后)与 菌群功能分析(L74 前)之间插入「变化」区块。

实现:在 template 的 quick-nav 之后插入:

      <!-- 指标变化(最近两次报告) -->
      <view class="change-section" v-if="changeItems.length > 0">
        <view class="section-title-bar">
          <text class="analysis-title">📈 指标变化</text>
          <text class="analysis-subtitle">与上次报告对比</text>
        </view>
        <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="colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).arrow }}</text>
            <text class="change-after">{{ item.after ? item.after.value : '--' }}</text>
            <text class="change-delta" :class="colorClassFor(getChangeStyle(item).cssClass)">{{ formatDelta(item.delta, item.deltaPct) }}</text>
            <text class="change-trend" :class="colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).trendLabel }}</text>
          </view>
          <view class="change-more" @tap="goCurve" v-if="changeItems.length > 0">
            <text class="change-more-text">查看完整变化曲线 ›</text>
          </view>
        </view>
      </view>

script 新增(data + methods):

// data 内新增:
// changeItems: [], _changeLoaded: false

// methods 内新增:
loadChangeBlock: function() {
  var self = this
  // subjectId 取当前详情页已加载的 report.subjectId(报告头部已有 personName 等)
  if (!this.report || !this.report.subjectId) return
  getIndicatorChangeRanking({
    subjectId: this.report.subjectId,
    reportType: 'gut_flora',
    scope: 'adjacent',
    orderBy: 'magnitude',
    limit: 6
  }).then(function(res) {
    self.changeItems = (res.data && res.data.items) || []
  })
},
getChangeStyle: function(item) { return resolveChangeStyle(item.goodDirection, item.movement) },
getChangeKey: function(item, i) { return item.definitionId || i },
goCurve: function() {
  uni.navigateTo({ url: '/pages/health/indicator-curve?subjectId=' + this.report.subjectId + '&reportType=gut_flora' })
}

接入点:在该页 onLoad/数据加载完成的回调末尾调用一次 loadChangeBlock()(在 report 数据就绪后),并遵守 _onLoadFired 模式避免 onLoad/onShow 双发。若该页本身已用 _onLoadFired 模式,在数据加载函数的尾部追加一次调用即可。

imports 追加:

import { getIndicatorChangeRanking } from '@/utils/api'
import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'

验证:语法校验(node --check 提取 script)+ audit 脚本 exit 0。

commit:feat(miniapp): 菌群报告详情页增加指标变化区块(最近两次对比)


任务 M7:pages.json 注册 2 个新页面

目标:health 分包注册 indicator-curve 与 indicator-quicklook,配 navigationBarTitleText。

文件:cfc-frontend/pages.json(改)

改动:找到 health 分包("root": "pages/health")的 pages 数组,追加两项(保持数组末尾不含多余逗号):

{
  "path": "indicator-curve",
  "style": { "navigationBarTitleText": "指标变化" }
},
{
  "path": "indicator-quicklook",
  "style": { "navigationBarTitleText": "指标速查" }
}

验证:

# JSON 合法性
node -e "const p=require('./pages.json');const s=p.subPackages.find(x=>x.root==='pages/health');if(!s.pages.some(x=>x.path==='indicator-curve')){throw new Error('missing indicator-curve')}if(!s.pages.some(x=>x.path==='indicator-quicklook')){throw new Error('missing indicator-quicklook')}console.log('pages.json OK')"

commit:feat(miniapp): pages.json 注册 indicator-curve / indicator-quicklook


任务 M8:API_REFERENCE 同步 + 全量门禁验证

文件:docs/superpowers/api/API_REFERENCE.md(改)

改动:在指标模块章节新增 4 项接口记录(若 API_REFERENCE 尚无「指标」章节则新增):

接口 方法 请求 响应 状态
/api/indicator/trend POST {subjectId, definitionIds:[]} {items:[{definitionId,name,points:[{reportDate,value,unit,status}]}]} 新增(本期)
/api/indicator/change-ranking POST {subjectId, reportType?, category?, scope, orderBy, limit} {items:[...], overall:{improvedCount,worsenedCount,flatCount}} 新增(本期)
/api/indicator/quick-lookup POST {subjectId, category?, page, pageSize} {categories:[], items:[], dimensionScores:[]} 新增(本期)
/api/indicator/correlation/list POST {subjectId, definitionId} {relations:[...]} 新增(本期,展示二期)

全量门禁:

cd cfc-frontend && npx jest __tests__/utils/indicator-change.test.js __tests__/api/api.indicator.test.js --no-coverage
node scripts/audit-duplicate-api-calls.js && echo "AUDIT PASS"
# 三个新增 .vue 全部 node --check script 块(TrendChart / curve / quicklook / gut-flora-detail)

commit:docs(api): 同步指标 4 接口至 API_REFERENCE


任务 M9:HBuilderX 打包验证(用户在 HBuilderX 完成,非 Agent)

目标:HBuilderX 打开 cfc-frontend/ → 运行到微信开发者工具 → 真机/模拟器人工验证。

人工验收清单(对应 design.md §9 验收 #1/#2/#3):

  • 上传第二份同类型(gut_flora)报告后,曲线页显示完整前后对比(多数据点曲线)
  • 变化排名按幅度排序正确(绝对幅度 / 升幅 / 降幅)
  • 变化箭头颜色符合 good_direction:炎症类 lower_is_better 下降=绿;双歧杆菌 higher_is_better 上升=绿
  • 速查页分类分页正常(每类 ≤30 项),含五维/七维小节
  • 速查页点击单项 → 跳转曲线页并展示该指标
  • 菌群详情页「变化」区块显示最近两次 Δ(红/绿),点击「查看完整变化曲线」跳转曲线页
  • 无控制台报错(canvas 初始化成功日志可见)

5. 自检清单(全计划完成前逐项过)

  • 5 个前端文件新建/修改完成、无占位符代码(尤其 TrendChart 的 selfShowValues 占位已替换)
  • jest 单测全绿:indicator-change.test.js + api.indicator.test.js
  • node scripts/audit-duplicate-api-calls.js exit 0(API 去重规范)
  • 2 个新 .vue 页面 + 1 个组件 + 改动页面的 script 块 node --check 通过
  • pages.json JSON 合法且 2 页已注册(任务 M7 校验命令通过)
  • 前端硬规则逐条过:无可选链 / 无 CSS Grid / 无 :key 表达式 / 无 :class 方法调用 / 无中文 CSS 类 / 无 uni.getSystemInfoSync / 数组 prop 有兜底 / 静态标题在 pages.json
  • _onLoadFired 模式在 2 个新页面 + 详情页改动中落实,无 onLoad/onShow 双发
  • docs/superpowers/api/API_REFERENCE.md 已同步 4 接口
  • 未触碰计划外文件(除 API_REFERENCE 外)
  • commit 历史清晰:M1-M8 每个任务一个 commit
  • HBuilderX 人工验收清单已由用户确认(任务 M9)

6. 风险与缓解

风险 缓解
后端 P1-P3 未就绪时前端联调受阻 前端严格按 design.md §6 契约开发;接口先以 mock 数据(本地 fixture)跑通渲染,后端就绪后替换为真实调用(仅替换数据源,不改变页面结构)
canvas 在部分机型初始化失败 参照 RadarChart 的重试机制(5 次 × 60ms)+ uni.getWindowInfo() DPR 兜底
曲线页 ranking+trend 两次请求时序 onIndicatorTap 用 theIndicatorId 比较防重复;series 每次 loadTrendFor 全量覆盖,无竞态
指标候选过多(200+ 菌属) 曲线页指标选择器取 ranking 前 10 作为候选,不一次性拉全量;速查页分类分页 30/页
good_direction 缺失时颜色误判 resolveChangeStyle 对 neutral 兜底中性色;后端保证返回 goodDirection

7. 依赖与顺序

  1. 依赖后端 2026-10-09-indicator-change-curve-backend.md 的 P3(视图 API)先行或并行(前端用 mock 先行)。
  2. 任务顺序:M1(纯函数)→ M2(API 封装)→ M3(组件)→ M4/M5/M6(页面,可并行)→ M7(注册)→ M8(文档+门禁)→ M9(人工验收)。
  3. M4/M5/M6 相互独立,可并行执行(若子代理驱动),但都依赖 M1/M2。

8. 完工定义(Definition of Done)

  • 所有任务 M1-M8 commit 完成,M9 人工验收清单逐项勾选
  • jest 单测 + audit + node --check 三门禁全绿
  • 与 design.md §7.1 / §9 验收 #1-#3 对齐