|
@@ -0,0 +1,520 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="page" :style="{ '--font-scale': fontScale }">
|
|
|
|
|
+ <!-- ① 顶部栏:成员 / 报告类型 -->
|
|
|
|
|
+ <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>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ⑤ 指标横向切换(chip) -->
|
|
|
|
|
+ <scroll-view scroll-x class="chip-scroll" v-if="indicatorOptions.length > 1">
|
|
|
|
|
+ <view
|
|
|
|
|
+ class="indicator-chip"
|
|
|
|
|
+ :class="def._chipClass"
|
|
|
|
|
+ v-for="def in indicatorOptions"
|
|
|
|
|
+ :key="getIndicatorKey(def)"
|
|
|
|
|
+ @tap="onIndicatorTap(getIndicatorId(def))"
|
|
|
|
|
+ >{{ getIndicatorName(def) }}</view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ② 指标头部卡 -->
|
|
|
|
|
+ <view class="head-card" v-if="hasItem">
|
|
|
|
|
+ <view class="head-row">
|
|
|
|
|
+ <text class="head-name">{{ item.name }}</text>
|
|
|
|
|
+ <text class="status-tag" :class="latestMeta._cssClass">{{ latestMeta._statusText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="head-value-row">
|
|
|
|
|
+ <text class="head-value">{{ latestValueText }}</text>
|
|
|
|
|
+ <text class="head-unit" v-if="item.unit">{{ item.unit }}</text>
|
|
|
|
|
+ <text class="head-count">共 {{ pointsCount }} 期</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="head-ref" v-if="item.refRange">参考 {{ item.refRange }}<text v-if="item.unit"> {{ item.unit }}</text></view>
|
|
|
|
|
+ <view class="head-change" v-if="showHeadChange">
|
|
|
|
|
+ <text class="head-change-text">{{ headChangeText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ②·5 残缺横幅(P0 红线,不可裁剪) -->
|
|
|
|
|
+ <IndicatorIncompleteBanner
|
|
|
|
|
+ :points-count="pointsCount"
|
|
|
|
|
+ :total-report-count="totalReportCount"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ③ 图表区(形态由 chartMode 决定) -->
|
|
|
|
|
+ <view class="chart-card">
|
|
|
|
|
+ <!-- loading -->
|
|
|
|
|
+ <view class="chart-loading" v-if="loading">
|
|
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- error -->
|
|
|
|
|
+ <view v-else-if="errorState">
|
|
|
|
|
+ <IndicatorDegrade scene="error" :retry="retryLoad" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- empty (n=0) -->
|
|
|
|
|
+ <view v-else-if="chartMode === 'empty'">
|
|
|
|
|
+ <text class="empty-text">暂无该指标数据</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- single (n=1):不画图,单值卡 + 首次记录 -->
|
|
|
|
|
+ <view v-else-if="chartMode === 'single'">
|
|
|
|
|
+ <view class="single-card">
|
|
|
|
|
+ <text class="single-value">{{ latestValueText }}</text>
|
|
|
|
|
+ <text class="single-unit" v-if="item.unit">{{ item.unit }}</text>
|
|
|
|
|
+ <text class="single-status" :class="latestMeta._cssClass">{{ latestMeta._statusText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="single-note">首次记录 · 暂无对比</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- pair (n=2):不画折线,双期对比条 -->
|
|
|
|
|
+ <view v-else-if="chartMode === 'pair'">
|
|
|
|
|
+ <view class="pair-bar">
|
|
|
|
|
+ <view class="pair-point">
|
|
|
|
|
+ <text class="pair-dot"></text>
|
|
|
|
|
+ <text class="pair-value">{{ previousValueText }}</text>
|
|
|
|
|
+ <text class="pair-date">{{ previousDateText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="pair-arrow-box">
|
|
|
|
|
+ <text class="pair-arrow" :class="pairArrowClass">{{ pairArrow }}</text>
|
|
|
|
|
+ <text class="pair-delta" :class="pairArrowClass">{{ pairDeltaText }}</text>
|
|
|
|
|
+ <text class="pair-trend" :class="pairArrowClass">{{ pairTrendText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="pair-point">
|
|
|
|
|
+ <text class="pair-dot"></text>
|
|
|
|
|
+ <text class="pair-value">{{ latestValueText }}</text>
|
|
|
|
|
+ <text class="pair-date">{{ latestDateText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- line / line_aggregated -->
|
|
|
|
|
+ <view v-else>
|
|
|
|
|
+ <view class="granularity-switch" v-if="chartMode === 'line_aggregated'">
|
|
|
|
|
+ <text class="gran-btn" :class="granularity === 'month' ? 'gran-active' : ''" @tap="onGranularityTap('month')">按月</text>
|
|
|
|
|
+ <text class="gran-btn" :class="granularity === 'raw' ? 'gran-active' : ''" @tap="onGranularityTap('raw')">按原始点</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <TrendChart
|
|
|
|
|
+ :series="series"
|
|
|
|
|
+ :mode="chartMode"
|
|
|
|
|
+ :ref-low="refLow"
|
|
|
|
|
+ :ref-high="refHigh"
|
|
|
|
|
+ :all-period-dates="allPeriodDates"
|
|
|
|
|
+ :latest-unit="item.unit"
|
|
|
|
|
+ :show-values="chartShowValues"
|
|
|
|
|
+ :show-axis-labels="true"
|
|
|
|
|
+ />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ④ 期次明细表(倒序,最新在最上) -->
|
|
|
|
|
+ <view class="period-card" v-if="periodRows.length > 0">
|
|
|
|
|
+ <view class="section-title">期次明细</view>
|
|
|
|
|
+ <view class="period-row" v-for="(row, ri) in periodRows" :key="getPeriodKey(row, ri)">
|
|
|
|
|
+ <text class="period-date">{{ row._dateText }}</text>
|
|
|
|
|
+ <text class="period-value" :class="row.isMissing ? 'period-missing' : ''">{{ row._valueText }}</text>
|
|
|
|
|
+ <text class="period-status" v-if="row.status">{{ row.status }}</text>
|
|
|
|
|
+ <text class="period-delta">{{ row.deltaText }}</text>
|
|
|
|
|
+ <text class="period-trend" :class="row.isMissing ? 'period-missing' : ''">{{ row.trendLabel }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="period-footnote" v-if="hasUploadDate">
|
|
|
|
|
+ <text>* 未识别到采样日期,按上传日期记录</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="period-footnote" v-if="hasUnitMismatch">
|
|
|
|
|
+ <text>* 该期单位与其他期不同,未做换算</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ⑥ 合规 footer(常显不可关) -->
|
|
|
|
|
+ <view class="footer">
|
|
|
|
|
+ <text class="footer-text">本页数据来自检测报告,趋势仅表示数值变化,不构成医学诊断或治疗建议。</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getFamilyMemberList, getIndicatorTimeline, getIndicatorSourceTypes, getIndicatorChangeRanking } from '@/utils/api'
|
|
|
|
|
+import { resolveChange, formatCompareDate } from '@/utils/indicator-change.js'
|
|
|
|
|
+import { consumeChartMode, buildAllPeriodDates, buildPeriodRows, formatPeriodDate, dateWithSourceMark, normalizeDate } from '@/utils/indicator-timeline.js'
|
|
|
|
|
+import IndicatorIncompleteBanner from '@/components/indicator/IndicatorIncompleteBanner.vue'
|
|
|
|
|
+import IndicatorDegrade from '@/components/indicator/IndicatorDegrade.vue'
|
|
|
|
|
+import TrendChart from '@/pages/health/components/TrendChart.vue'
|
|
|
|
|
+import fontScaleMixin from '@/mixins/font-scale.js'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ mixins: [fontScaleMixin],
|
|
|
|
|
+ components: { IndicatorIncompleteBanner, IndicatorDegrade, TrendChart },
|
|
|
|
|
+ data: function() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ members: [],
|
|
|
|
|
+ currentMemberId: null,
|
|
|
|
|
+ reportTypeOptions: [],
|
|
|
|
|
+ activeReportType: '',
|
|
|
|
|
+ definitionId: null,
|
|
|
|
|
+ indicatorOptions: [],
|
|
|
|
|
+ item: null,
|
|
|
|
|
+ loading: false,
|
|
|
|
|
+ errorState: false,
|
|
|
|
|
+ granularity: 'raw',
|
|
|
|
|
+ _hasLeftPage: false,
|
|
|
|
|
+ _pendingEntry: false,
|
|
|
|
|
+ reportId: null
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ memberNames: function() {
|
|
|
|
|
+ return this.members.map(function(m) { return m.name || m.nickname || '' })
|
|
|
|
|
+ },
|
|
|
|
|
+ currentMemberName: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var m = this.members.find(function(x) { return x.id === self.currentMemberId })
|
|
|
|
|
+ return (m && (m.name || m.nickname)) || '选择成员'
|
|
|
|
|
+ },
|
|
|
|
|
+ reportTypeNames: function() {
|
|
|
|
|
+ return this.reportTypeOptions.map(function(o) { return o.label })
|
|
|
|
|
+ },
|
|
|
|
|
+ currentReportTypeName: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var o = this.reportTypeOptions.find(function(x) { return x.sourceType === self.activeReportType })
|
|
|
|
|
+ return (o && o.label) || ''
|
|
|
|
|
+ },
|
|
|
|
|
+ hasItem: function() { return !!this.item },
|
|
|
|
|
+ /** 最新一期状态(预计算,模板 :class 不调用带参方法) */
|
|
|
|
|
+ latestMeta: function() {
|
|
|
|
|
+ if (!this.item || !this.item.latest) return { _cssClass: 'status-normal', _statusText: '正常' }
|
|
|
|
|
+ var s = this.item.latest.status ? String(this.item.latest.status).trim() : ''
|
|
|
|
|
+ var cls = 'status-normal'
|
|
|
|
|
+ if (s === '偏高' || s === 'high') cls = 'status-high'
|
|
|
|
|
+ else if (s === '偏低' || s === 'low') cls = 'status-low'
|
|
|
|
|
+ return { _cssClass: cls, _statusText: s || '正常' }
|
|
|
|
|
+ },
|
|
|
|
|
+ pointsCount: function() { return this.item && this.item.pointsCount || 0 },
|
|
|
|
|
+ totalReportCount: function() { return this.item && this.item.totalReportCount || 0 },
|
|
|
|
|
+ chartMode: function() { return this.item && this.item.chartMode || 'empty' },
|
|
|
|
|
+ series: function() {
|
|
|
|
|
+ if (!this.item || !Array.isArray(this.item.points)) return []
|
|
|
|
|
+ var pts = this.item.points
|
|
|
|
|
+ return [{
|
|
|
|
|
+ name: this.item.name,
|
|
|
|
|
+ color: '#F97316',
|
|
|
|
|
+ points: pts.map(function(p) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ reportDate: String(p.reportDate).slice(0, 10),
|
|
|
|
|
+ value: p.value,
|
|
|
|
|
+ numericValue: p.numericValue,
|
|
|
|
|
+ unit: p.unit,
|
|
|
|
|
+ dateSource: p.dateSource
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }]
|
|
|
|
|
+ },
|
|
|
|
|
+ refLow: function() { return this.item && this.item.refLow !== undefined ? this.item.refLow : null },
|
|
|
|
|
+ refHigh: function() { return this.item && this.item.refHigh !== undefined ? this.item.refHigh : null },
|
|
|
|
|
+ allPeriodDates: function() {
|
|
|
|
|
+ if (!this.item) return []
|
|
|
|
|
+ return buildAllPeriodDates(this.item.points || [], this.item.missingPeriods || [])
|
|
|
|
|
+ },
|
|
|
|
|
+ periodRows: function() {
|
|
|
|
|
+ if (!this.item) return []
|
|
|
|
|
+ var rows = buildPeriodRows(this.item.points || [], this.item.missingPeriods || [])
|
|
|
|
|
+ var latestDate = this.item.latest && this.item.latest.reportDate
|
|
|
|
|
+ var latestUnit = this.item.unit
|
|
|
|
|
+ return rows.map(function(r) {
|
|
|
|
|
+ var d = r.reportDate
|
|
|
|
|
+ var nd = normalizeDate(d)
|
|
|
|
|
+ r._dateText = dateWithSourceMark(formatPeriodDate(nd, latestDate), r.dateSource)
|
|
|
|
|
+ if (r.isMissing) {
|
|
|
|
|
+ r._valueText = '该期未检测'
|
|
|
|
|
+ } else {
|
|
|
|
|
+ r._valueText = r.value + (r.unit ? ' ' + r.unit : '')
|
|
|
|
|
+ }
|
|
|
|
|
+ return r
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ latestValueText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.latest) return '--'
|
|
|
|
|
+ var v = this.item.latest
|
|
|
|
|
+ return (v.numericValue !== undefined && v.numericValue !== null) ? String(v.numericValue) : v.value
|
|
|
|
|
+ },
|
|
|
|
|
+ previousValueText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change || !this.item.change.previous) return '--'
|
|
|
|
|
+ var v = this.item.change.previous
|
|
|
|
|
+ return (v.numericValue !== undefined && v.numericValue !== null) ? String(v.numericValue) : v.value
|
|
|
|
|
+ },
|
|
|
|
|
+ latestDateText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.latest || !this.item.latest.reportDate) return ''
|
|
|
|
|
+ return formatPeriodDate(this.item.latest.reportDate, this.item.latest.reportDate)
|
|
|
|
|
+ },
|
|
|
|
|
+ previousDateText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change || !this.item.change.previous || !this.item.change.previous.reportDate) return ''
|
|
|
|
|
+ return formatPeriodDate(this.item.change.previous.reportDate, this.item.latest && this.item.latest.reportDate)
|
|
|
|
|
+ },
|
|
|
|
|
+ showHeadChange: function() { return this.pointsCount >= 2 },
|
|
|
|
|
+ headChangeText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change) return ''
|
|
|
|
|
+ var ch = this.item.change
|
|
|
|
|
+ var r = resolveChange(ch.trendCode, ch.movement, ch.trendLabel)
|
|
|
|
|
+ var unitMark = ch.unitMismatch ? ' *' : ''
|
|
|
|
|
+ return '较 ' + formatCompareDate(ch.previous.reportDate, this.item.latest && this.item.latest.reportDate, ch.previous.dateSource) + ':' + r.arrow + ' ' + (ch.delta > 0 ? '+' : '') + (ch.delta || 0) + ' (' + (ch.deltaPct > 0 ? '+' : '') + (ch.deltaPct || 0) + '%)' + unitMark + ' ' + r.trendLabel
|
|
|
|
|
+ },
|
|
|
|
|
+ pairArrow: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change) return '→'
|
|
|
|
|
+ var mv = this.item.change.movement
|
|
|
|
|
+ return mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→')
|
|
|
|
|
+ },
|
|
|
|
|
+ pairDeltaText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change) return ''
|
|
|
|
|
+ var ch = this.item.change
|
|
|
|
|
+ var d = ch.delta, pct = ch.deltaPct
|
|
|
|
|
+ if (d === null || d === undefined || isNaN(d)) return '—'
|
|
|
|
|
+ var sign = d > 0 ? '+' : ''
|
|
|
|
|
+ if (pct === null || pct === undefined || isNaN(pct) || ch.unitMismatch) return sign + d + ' (—)'
|
|
|
|
|
+ var ps = pct > 0 ? '+' : ''
|
|
|
|
|
+ return sign + d + ' (' + ps + pct + '%)'
|
|
|
|
|
+ },
|
|
|
|
|
+ pairTrendText: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change) return ''
|
|
|
|
|
+ return this.item.change.trendLabel
|
|
|
|
|
+ },
|
|
|
|
|
+ pairArrowClass: function() {
|
|
|
|
|
+ if (!this.item || !this.item.change) return ''
|
|
|
|
|
+ var tc = this.item.change.trendCode
|
|
|
|
|
+ if (tc === 'improved') return 'pair-impr'
|
|
|
|
|
+ if (tc === 'worsened') return 'pair-worst'
|
|
|
|
|
+ return 'pair-flat'
|
|
|
|
|
+ },
|
|
|
|
|
+ chartShowValues: function() {
|
|
|
|
|
+ if (!this.item) return false
|
|
|
|
|
+ var n = this.pointsCount
|
|
|
|
|
+ var cfg = consumeChartMode(this.chartMode, n)
|
|
|
|
|
+ return cfg.showValues
|
|
|
|
|
+ },
|
|
|
|
|
+ hasUploadDate: function() {
|
|
|
|
|
+ if (!this.item || !Array.isArray(this.item.points)) return false
|
|
|
|
|
+ return this.item.points.some(function(p) { return p.dateSource && p.dateSource !== 'real' && p.dateSource !== 'unknown' })
|
|
|
|
|
+ },
|
|
|
|
|
+ hasUnitMismatch: function() {
|
|
|
|
|
+ return this.item && this.item.unitMismatch === true
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad: function(options) {
|
|
|
|
|
+ var o = options || {}
|
|
|
|
|
+ if (o.subjectId) this.currentMemberId = Number(o.subjectId)
|
|
|
|
|
+ if (o.definitionId) this.definitionId = Number(o.definitionId)
|
|
|
|
|
+ if (o.reportType) this.activeReportType = String(o.reportType)
|
|
|
|
|
+ if (o.reportId) this.reportId = Number(o.reportId)
|
|
|
|
|
+ this._pendingEntry = true
|
|
|
|
|
+ this.loadMembers()
|
|
|
|
|
+ },
|
|
|
|
|
+ onShow: function() {
|
|
|
|
|
+ // 首次进入由 onLoad 链路加载;从其他页返回本页时按需刷新
|
|
|
|
|
+ if (this._hasLeftPage) { this._hasLeftPage = false; this.reloadAll() }
|
|
|
|
|
+ },
|
|
|
|
|
+ onHide: function() {
|
|
|
|
|
+ this._hasLeftPage = true
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ 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 }
|
|
|
|
|
+ if (self._pendingEntry) { self._pendingEntry = false }
|
|
|
|
|
+ self.loadSourceTypes()
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ onMemberChange: function(e) {
|
|
|
|
|
+ var idx = Number(e.detail.value)
|
|
|
|
|
+ var m = this.members[idx]
|
|
|
|
|
+ if (m) { this.currentMemberId = m.id; this.reloadAll() }
|
|
|
|
|
+ },
|
|
|
|
|
+ loadSourceTypes: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ if (!this.currentMemberId) return
|
|
|
|
|
+ getIndicatorSourceTypes({ subjectId: this.currentMemberId }).then(function(res) {
|
|
|
|
|
+ var arr = (res.data instanceof Array) ? res.data : []
|
|
|
|
|
+ if (arr.length > 0) {
|
|
|
|
|
+ self.reportTypeOptions = arr
|
|
|
|
|
+ var first = arr[0].sourceType
|
|
|
|
|
+ if (!self.activeReportType) { self.activeReportType = first }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.reportTypeOptions = [
|
|
|
|
|
+ { sourceType: 'gut_flora', label: '肠道菌群' },
|
|
|
|
|
+ { sourceType: 'food_recommend', label: '食材推荐' }
|
|
|
|
|
+ ]
|
|
|
|
|
+ if (!self.activeReportType) { self.activeReportType = 'gut_flora' }
|
|
|
|
|
+ }
|
|
|
|
|
+ self.onReportTypeSelected()
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ onReportTypeChange: function(e) {
|
|
|
|
|
+ var idx = Number(e.detail.value)
|
|
|
|
|
+ var o = this.reportTypeOptions[idx]
|
|
|
|
|
+ if (o) { this.activeReportType = o.sourceType; this.onReportTypeSelected() }
|
|
|
|
|
+ },
|
|
|
|
|
+ onReportTypeSelected: function() {
|
|
|
|
|
+ this.loadIndicatorOptions()
|
|
|
|
|
+ this.loadTimeline()
|
|
|
|
|
+ },
|
|
|
|
|
+ loadIndicatorOptions: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ if (!this.currentMemberId) return
|
|
|
|
|
+ // 以 change-ranking 为候选列表(与 indicator-curve 一致),limit 20 覆盖多数指标
|
|
|
|
|
+ var rt = (!this.activeReportType || this.activeReportType === 'food_recommend') ? null : this.activeReportType
|
|
|
|
|
+ getIndicatorChangeRanking({ subjectId: this.currentMemberId, reportType: rt, scope: 'adjacent', orderBy: 'magnitude', limit: 20 }).then(function(res) {
|
|
|
|
|
+ var items = (res.data && res.data.items) || []
|
|
|
|
|
+ self.indicatorOptions = items.map(function(it) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ definitionId: it.definitionId,
|
|
|
|
|
+ name: it.name,
|
|
|
|
|
+ _chipClass: it.definitionId === self.definitionId ? 'chip-active' : ''
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ // 入口传入的 definitionId 不在榜单中时,追加占位(名称由 timeline 负载补全)
|
|
|
|
|
+ if (self.definitionId && !self.indicatorOptions.some(function(d) { return d.definitionId === self.definitionId })) {
|
|
|
|
|
+ self.indicatorOptions.push({ definitionId: self.definitionId, name: String(self.definitionId), _chipClass: 'chip-active' })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ loadTimeline: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ if (!this.currentMemberId || !this.definitionId) return
|
|
|
|
|
+ self.loading = true
|
|
|
|
|
+ self.errorState = false
|
|
|
|
|
+ getIndicatorTimeline({
|
|
|
|
|
+ subjectId: this.currentMemberId,
|
|
|
|
|
+ definitionIds: [this.definitionId],
|
|
|
|
|
+ sourceType: this.activeReportType || undefined,
|
|
|
|
|
+ granularity: this.granularity
|
|
|
|
|
+ }).then(function(res) {
|
|
|
|
|
+ self.loading = false
|
|
|
|
|
+ if (res.code === 200) {
|
|
|
|
|
+ var items = (res.data && res.data.items) || []
|
|
|
|
|
+ if (items.length > 0) {
|
|
|
|
|
+ self.item = items[0]
|
|
|
|
|
+ // 选中态同步到 chip;入口 definitionId 不在榜单中时补全名称
|
|
|
|
|
+ var found = false
|
|
|
|
|
+ self.indicatorOptions = self.indicatorOptions.map(function(d) {
|
|
|
|
|
+ var active = d.definitionId === self.definitionId
|
|
|
|
|
+ if (active) { found = true; d.name = self.item.name }
|
|
|
|
|
+ d._chipClass = active ? 'chip-active' : ''
|
|
|
|
|
+ return d
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!found) {
|
|
|
|
|
+ self.indicatorOptions.unshift({
|
|
|
|
|
+ definitionId: self.item.definitionId,
|
|
|
|
|
+ name: self.item.name,
|
|
|
|
|
+ _chipClass: 'chip-active'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.item = null
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.errorState = true
|
|
|
|
|
+ self.item = null
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(function() {
|
|
|
|
|
+ self.loading = false
|
|
|
|
|
+ self.errorState = true
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ onIndicatorTap: function(definitionId) {
|
|
|
|
|
+ if (definitionId === this.definitionId) return
|
|
|
|
|
+ this.definitionId = definitionId
|
|
|
|
|
+ this.loadTimeline()
|
|
|
|
|
+ },
|
|
|
|
|
+ onGranularityTap: function(gran) {
|
|
|
|
|
+ if (gran === this.granularity) return
|
|
|
|
|
+ this.granularity = gran
|
|
|
|
|
+ this.loadTimeline()
|
|
|
|
|
+ },
|
|
|
|
|
+ retryLoad: function() { this.loadTimeline() },
|
|
|
|
|
+ getIndicatorKey: function(def) { return def ? (def.definitionId || def.name) : '' },
|
|
|
|
|
+ getIndicatorId: function(def) { return def ? def.definitionId : null },
|
|
|
|
|
+ getIndicatorName: function(def) { return (def && def.name) || '' },
|
|
|
|
|
+ /** 重载全部数据(切成员 / 下拉刷新) */
|
|
|
|
|
+ reloadAll: function() {
|
|
|
|
|
+ this.loadSourceTypes()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+/* ---- layout ---- */
|
|
|
|
|
+.page { padding: 24rpx; display: flex; flex-direction: column; gap: 24rpx; min-height: 100vh; box-sizing: border-box; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---- ① 顶部筛选栏 ---- */
|
|
|
|
|
+.filter-bar { display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: 16rpx; }
|
|
|
|
|
+.filter-item { flex: 1; display: flex; align-items: center; justify-content: center; height: 72rpx; padding: 0 16rpx; background: #f7f8fa; border-radius: 16rpx; font-size: calc(26rpx * var(--font-scale, 1)); color: #374151; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---- ⑤ 指标 chip ---- */
|
|
|
|
|
+.chip-scroll { display: flex; flex-direction: row; align-items: center; padding: 0 4rpx; min-height: 28rpx; white-space: nowrap; }
|
|
|
|
|
+.indicator-chip { display: flex; align-items: center; justify-content: center; height: 48rpx; padding: 0 24rpx; margin-right: 16rpx; border-radius: 24rpx; font-size: calc(24rpx * var(--font-scale, 1)); color: #374151; background: #f3f4f6; flex-shrink: 0; }
|
|
|
|
|
+.indicator-chip.chip-active { background: #F97316; color: #fff; font-weight: 600; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---- ② 指标头部卡 ---- */
|
|
|
|
|
+.head-card { background: #fff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
|
|
|
|
+.head-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
|
|
|
|
|
+.head-name { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 700; color: #111827; }
|
|
|
|
|
+.status-tag { font-size: calc(22rpx * var(--font-scale, 1)); padding: 6rpx 16rpx; border-radius: 20rpx; }
|
|
|
|
|
+.status-tag.status-normal { background: #ecfdf5; color: #047857; }
|
|
|
|
|
+.status-tag.status-high { background: #fff7ed; color: #c2410c; }
|
|
|
|
|
+.status-tag.status-low { background: #eff6ff; color: #1d4ed8; }
|
|
|
|
|
+.head-value-row { display: flex; flex-direction: row; align-items: baseline; margin-bottom: 8rpx; }
|
|
|
|
|
+.head-value { font-size: calc(48rpx * var(--font-scale, 1)); font-weight: 800; color: #111827; line-height: 1.2; }
|
|
|
|
|
+.head-unit { font-size: calc(24rpx * var(--font-scale, 1)); color: #6b7280; margin-left: 8rpx; }
|
|
|
|
|
+.head-count { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; margin-left: 16rpx; }
|
|
|
|
|
+.head-ref { font-size: calc(22rpx * var(--font-scale, 1)); color: #6b7280; margin-bottom: 8rpx; }
|
|
|
|
|
+.head-change { padding: 12rpx 16rpx; background: #fef9c3; border-radius: 12rpx; margin-top: 8rpx; }
|
|
|
|
|
+.head-change-text { font-size: calc(24rpx * var(--font-scale, 1)); color: #92400e; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---- ③ 图表卡 ---- */
|
|
|
|
|
+.chart-card { background: #fff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); min-height: 320rpx; }
|
|
|
|
|
+.chart-loading { display: flex; align-items: center; justify-content: center; height: 200rpx; }
|
|
|
|
|
+.loading-text { font-size: calc(24rpx * var(--font-scale, 1)); color: #9ca3af; }
|
|
|
|
|
+.empty-text { display: block; text-align: center; font-size: calc(24rpx * var(--font-scale, 1)); color: #9ca3af; margin-top: 80rpx; }
|
|
|
|
|
+.single-card { display: flex; flex-direction: row; align-items: baseline; justify-content: center; padding: 24rpx 0; }
|
|
|
|
|
+.single-value { font-size: calc(56rpx * var(--font-scale, 1)); font-weight: 800; color: #111827; line-height: 1.2; }
|
|
|
|
|
+.single-unit { font-size: calc(26rpx * var(--font-scale, 1)); color: #6b7280; margin-left: 8rpx; }
|
|
|
|
|
+.single-status { font-size: calc(22rpx * var(--font-scale, 1)); padding: 4rpx 12rpx; border-radius: 12rpx; margin-left: 16rpx; }
|
|
|
|
|
+.single-note { display: block; text-align: center; font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; margin-top: 8rpx; }
|
|
|
|
|
+.pair-bar { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 24rpx 0; }
|
|
|
|
|
+.pair-point { display: flex; flex-direction: column; align-items: center; }
|
|
|
|
|
+.pair-dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: #F97316; }
|
|
|
|
|
+.pair-value { font-size: calc(32rpx * var(--font-scale, 1)); font-weight: 700; color: #111827; margin-top: 8rpx; }
|
|
|
|
|
+.pair-date { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; margin-top: 4rpx; }
|
|
|
|
|
+.pair-arrow-box { display: flex; flex-direction: column; align-items: center; padding: 0 24rpx; }
|
|
|
|
|
+.pair-arrow { font-size: calc(40rpx * var(--font-scale, 1)); line-height: 1; }
|
|
|
|
|
+.pair-delta { font-size: calc(24rpx * var(--font-scale, 1)); margin-top: 6rpx; }
|
|
|
|
|
+.pair-trend { font-size: calc(22rpx * var(--font-scale, 1)); margin-top: 4rpx; }
|
|
|
|
|
+.pair-impr { color: #047857; }
|
|
|
|
|
+.pair-worst { color: #dc2626; }
|
|
|
|
|
+.pair-flat { color: #6b7280; }
|
|
|
|
|
+.granularity-switch { display: flex; flex-direction: row; justify-content: flex-end; margin-bottom: 16rpx; }
|
|
|
|
|
+.gran-btn { font-size: calc(22rpx * var(--font-scale, 1)); color: #6b7280; padding: 8rpx 16rpx; margin-left: 8rpx; border-radius: 8rpx; border: 1rpx solid #e5e7eb; }
|
|
|
|
|
+.gran-btn.gran-active { background: #F97316; color: #fff; border-color: #F97316; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---- ④ 期次明细表 ---- */
|
|
|
|
|
+.period-card { background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
|
|
|
|
+.section-title { font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 700; color: #111827; margin-bottom: 16rpx; }
|
|
|
|
|
+.period-row { display: flex; flex-direction: row; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f3f4f6; }
|
|
|
|
|
+.period-row:last-child { border-bottom: none; }
|
|
|
|
|
+.period-date { width: 140rpx; font-size: calc(24rpx * var(--font-scale, 1)); color: #374151; }
|
|
|
|
|
+.period-value { flex: 1; font-size: calc(26rpx * var(--font-scale, 1)); color: #111827; font-weight: 600; }
|
|
|
|
|
+.period-value.period-missing { color: #9ca3af; font-weight: 400; }
|
|
|
|
|
+.period-status { width: 100rpx; font-size: calc(22rpx * var(--font-scale, 1)); text-align: right; }
|
|
|
|
|
+.period-delta { width: 200rpx; font-size: calc(24rpx * var(--font-scale, 1)); text-align: right; color: #6b7280; }
|
|
|
|
|
+.period-trend { width: 160rpx; font-size: calc(24rpx * var(--font-scale, 1)); text-align: right; }
|
|
|
|
|
+.period-footnote { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; padding: 12rpx 0 0 0; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---- ⑥ 合规 footer ---- */
|
|
|
|
|
+.footer { padding: 24rpx 0; }
|
|
|
|
|
+.footer-text { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; text-align: center; line-height: 1.6; }
|
|
|
|
|
+</style>
|