版本: 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 代理执行。目标:为 uni-app 微信小程序(cfc-frontend/,Vue 2 Options API)实现「指标变化曲线页」「指标速查页」「菌群报告详情页变化区块」及通用 TrendChart 折线组件。所有 API 依赖后端 P1-P3。
执行纪律(违反即失败):
npm run build:mp-weixin / npm run dev:mp-weixin。只做语法/结构校验(node --check)、审计(audit-duplicate-api-calls.js)、jest 单测。最终打包由用户在 HBuilderX 完成。?. → 用 &&:key 表达式 → methods 方法 getItemKey(item):class 绑定方法调用 → 内联表达式 :class="'xxx-' + item._cssClass";_cssClass 由前端 computed/后端字段映射为英文new Date(string) → utils/format.js 的 parseDate()uni.getSystemInfoSync() → uni.getWindowInfo()|| [] + Array.isArray 兜底(三态兼容)pages.json 的 navigationBarTitleText 配置,模板内禁止手写 <text class="nav-title">cfc-frontend/AGENTS.md):_onLoadFired 标志位;utils/api.js 每个 URL 单一规范入口,别名委托;禁止循环内请求;CI 门禁 node scripts/audit-duplicate-api-calls.js 必须 exit 0。docs/superpowers/api/API_REFERENCE.md(接口校准不存在时留待后端实现后补)。| # | 交付物 | 文件 | 说明 |
|---|---|---|---|
| 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 封装契约 |
health-knowledge-popup 组件已有能力,如需展示弹出框直接复用,不新增接口)correlation/list 封装本期提供,展示留二期;quick-lookup 页面不渲染关联区块)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 }
→ 渲染最近两次报告的菌属 Δ 区块(红/绿标注)
| 项 | 约定 |
|---|---|
| 框架 | 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() |
统一 export const fnName = (params) => request('/api/indicator/xxx', 'POST', params || {})。request() 已内置 3 秒去重 + PENDING 合并(同 URL+method+body),页面侧仍需遵守 _onLoadFired。
| 操作 | 文件 | 内容 |
|---|---|---|
| 新建 | 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 个新增封装对应的接口记录 |
每个任务验收 = 测试/校验命令通过 + commit。commit message 遵循仓库风格(Conventional Commits 中文,参考
git log历史)。
目标:把「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
目标:/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
目标:多序列折线图(横轴=报告日期升序,纵轴=数值;数据点标注数值;支持多指标序列叠加),手写 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 模式)
目标:成员切换 + 报告类型筛选 + 指标选择(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/排序/着色)
目标:分类 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): 指标速查页(分类分页 + 五维/七维小节 + 跳转曲线)
目标:报告详情页展示最近两次同类型报告的菌属 Δ(红/绿标注),一期仅此页。
文件: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): 菌群报告详情页增加指标变化区块(最近两次对比)
目标: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
文件: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
目标:HBuilderX 打开 cfc-frontend/ → 运行到微信开发者工具 → 真机/模拟器人工验证。
人工验收清单(对应 design.md §9 验收 #1/#2/#3):
selfShowValues 占位已替换)indicator-change.test.js + api.indicator.test.jsnode scripts/audit-duplicate-api-calls.js exit 0(API 去重规范)node --check 通过pages.json JSON 合法且 2 页已注册(任务 M7 校验命令通过):key 表达式 / 无 :class 方法调用 / 无中文 CSS 类 / 无 uni.getSystemInfoSync / 数组 prop 有兜底 / 静态标题在 pages.json_onLoadFired 模式在 2 个新页面 + 详情页改动中落实,无 onLoad/onShow 双发docs/superpowers/api/API_REFERENCE.md 已同步 4 接口| 风险 | 缓解 |
|---|---|
| 后端 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 |
2026-10-09-indicator-change-curve-backend.md 的 P3(视图 API)先行或并行(前端用 mock 先行)。