Browse Source

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

iwt 2 days ago
parent
commit
848522d388
1 changed files with 444 additions and 0 deletions
  1. 444 0
      cfc-frontend/pages/health/indicator-curve.vue

+ 444 - 0
cfc-frontend/pages/health/indicator-curve.vue

@@ -0,0 +1,444 @@
+<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="def.definitionId === theIndicatorId ? '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 ' + item._cssClass">→</text>
+            <text class="rank-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text :class="'rank-delta ' + item._cssClass">{{ item._deltaText }}</text>
+          </view>
+          <text :class="'rank-trend ' + item._cssClass">{{ item._trendLabel }} {{ item._arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+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 },
+
+    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) {
+        var items = (res.data && res.data.items) || []
+        // 预计算展示字段,避免小程序模板在 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        self.rankItems = items.map(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement)
+          it._cssClass = colorClassFor(st.cssClass)
+          it._trendLabel = st.trendLabel
+          it._arrow = st.arrow
+          it._deltaText = formatDelta(it.delta, it.deltaPct)
+          return it
+        })
+        self.overall = (res.data && res.data.overall) || {}
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F9FC;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+/* 顶部筛选 */
+.filter-bar {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 24rpx;
+}
+
+.filter-item {
+  flex: 1;
+  background-color: #FFFFFF;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 16rpx 24rpx;
+  margin-right: 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  text-align: center;
+}
+
+.filter-item:last-child {
+  margin-right: 0;
+}
+
+/* 区块通用 */
+.curve-section,
+.ranking-section {
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(30, 41, 59, 0.06);
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 20rpx;
+}
+
+/* 指标选择器 */
+.indicator-picker {
+  margin-bottom: 20rpx;
+}
+
+.indicator-scroll {
+  white-space: nowrap;
+  width: 100%;
+}
+
+.indicator-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 10rpx 24rpx;
+  margin-right: 16rpx;
+  border-radius: 32rpx;
+  background-color: #EDF2F7;
+  font-size: 24rpx;
+  color: #64748B;
+}
+
+.chip-active {
+  background-color: #5B9BD5;
+  color: #FFFFFF;
+}
+
+.empty-tip {
+  padding: 60rpx 0;
+  text-align: center;
+  font-size: 26rpx;
+  color: #94A3B8;
+}
+
+/* 排名 Tab */
+.rank-tabs {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 16rpx;
+}
+
+.rank-tab {
+  flex: 1;
+  text-align: center;
+  padding: 14rpx 0;
+  font-size: 26rpx;
+  color: #64748B;
+  border-bottom: 4rpx solid transparent;
+}
+
+.rank-tab.rank-flora,
+.rank-tab.rank-food,
+.rank-tab.rank-indicator {
+  color: #64748B;
+}
+
+.rank-sorts {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 20rpx;
+}
+
+.sort-btn {
+  padding: 8rpx 24rpx;
+  margin-right: 16rpx;
+  border-radius: 24rpx;
+  font-size: 24rpx;
+  color: #64748B;
+  background-color: #EDF2F7;
+}
+
+.sort-active {
+  background-color: #D6EAF8;
+  color: #3A7CC4;
+}
+
+/* 排名列表 */
+.rank-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.rank-item {
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #E2E8F0;
+}
+
+.rank-item:last-child {
+  border-bottom: none;
+}
+
+.rank-name-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.rank-name {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 500;
+  margin-right: 16rpx;
+}
+
+.rank-category {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+
+.rank-value-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+
+.rank-before {
+  font-size: 26rpx;
+  color: #64748B;
+}
+
+.rank-arrow {
+  font-size: 26rpx;
+  margin: 0 12rpx;
+}
+
+.rank-after {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 600;
+}
+
+.rank-delta {
+  font-size: 24rpx;
+  margin-left: 16rpx;
+}
+
+.rank-trend {
+  display: block;
+  font-size: 22rpx;
+}
+
+/* 变化着色:改善=绿 / 恶化=红 / 中性=灰 */
+.green-text {
+  color: #16A34A;
+}
+
+.red-text {
+  color: #DC2626;
+}
+
+.gray-text {
+  color: #94A3B8;
+}
+</style>