|
|
@@ -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>
|